22 july
This commit is contained in:
parent
bad7d38777
commit
3feb2ff20a
|
|
@ -12,8 +12,8 @@
|
|||
rel="stylesheet"
|
||||
/>
|
||||
<script src="https://accounts.google.com/gsi/client" async defer></script>
|
||||
<script type="module" crossorigin src="/assets/index-DJFhkFks.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-YPkmj8ow.css">
|
||||
<script type="module" crossorigin src="/assets/index-Dj8crrlq.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-1SgcqEoJ.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -38,6 +38,12 @@ export function AppShell() {
|
|||
}
|
||||
}, [selectedEventId, activeNav, setActiveNav]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeNav === "dashboard" && selectedEventId) {
|
||||
setSidebarCollapsed(true);
|
||||
}
|
||||
}, [activeNav, selectedEventId, setSidebarCollapsed]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback } from "react";
|
|||
import { useAppNav } from "../contexts/AppNavContext";
|
||||
import { useBreakpoint } from "../hooks/useBreakpoint";
|
||||
import { CreateEventPanel } from "./panels/CreateEventPanel";
|
||||
import { WebsiteThemeSettingsPanel } from "./panels/WebsiteThemeSettingsPanel";
|
||||
import { EventHomeHeaderActions } from "./dashboard/EventHomeHeaderActions";
|
||||
import { EventHomeModules } from "./dashboard/EventHomeModules";
|
||||
import { EventAnalyticsPage } from "./analytics/EventAnalyticsPage";
|
||||
|
|
@ -49,6 +50,7 @@ export function MainContent() {
|
|||
setSettingsInitialSection,
|
||||
} = useAppNav();
|
||||
const [editEventPanelOpen, setEditEventPanelOpen] = useState(false);
|
||||
const [websiteThemePanelOpen, setWebsiteThemePanelOpen] = useState(false);
|
||||
const bp = useBreakpoint();
|
||||
const [dashEvent, setDashEvent] = useState<Event | null>(null);
|
||||
const [dashLoading, setDashLoading] = useState(false);
|
||||
|
|
@ -197,6 +199,7 @@ export function MainContent() {
|
|||
loading={dashLoading && !!selectedEventId}
|
||||
canEdit={!!selectedEventId}
|
||||
onEditEvent={() => selectedEventId && setEditEventPanelOpen(true)}
|
||||
onWebsiteSettings={() => selectedEventId && setWebsiteThemePanelOpen(true)}
|
||||
/>
|
||||
{editEventPanelOpen && selectedEventId && (
|
||||
<CreateEventPanel
|
||||
|
|
@ -208,6 +211,15 @@ export function MainContent() {
|
|||
}}
|
||||
/>
|
||||
)}
|
||||
{websiteThemePanelOpen && selectedEventId && (
|
||||
<WebsiteThemeSettingsPanel
|
||||
eventId={selectedEventId}
|
||||
onClose={() => setWebsiteThemePanelOpen(false)}
|
||||
onSuccess={() => {
|
||||
void loadDashboardData();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<EventHomeModules />
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ const EVENT_TAB_MODULES: Record<string, string> = {
|
|||
dashboard: "events",
|
||||
agenda: "sessions",
|
||||
participants: "participants",
|
||||
attendance: "participants",
|
||||
attendance: "attendance",
|
||||
"event-activities": "live_polls",
|
||||
rewards: "gamification",
|
||||
analytics: "events",
|
||||
|
|
|
|||
|
|
@ -54,7 +54,6 @@ const PORTFOLIO_NAV: NavItem[] = [
|
|||
id: "design", label: "Design", icon: <Palette size={16} />,
|
||||
children: [
|
||||
{ id: "design-brochure", label: "Brochure" },
|
||||
{ id: "design-microsite", label: "Microsite Templates" },
|
||||
{ id: "design-badges", label: "Badges" },
|
||||
{ id: "design-emailers", label: "Emailers" },
|
||||
{ id: "design-whatsapp-templates", label: "WhatsApp Templates" },
|
||||
|
|
@ -459,28 +458,154 @@ function BottomUser({ collapsed, onNavigate }: { collapsed: boolean; onNavigate:
|
|||
)}
|
||||
|
||||
{collapsed ? (
|
||||
<Tooltip label={`${displayName} · Log out`}>
|
||||
<button type="button" onClick={handleLogout} style={{ ...btnStyle, padding: "6px 0" }}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.background = "var(--accent)"; }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = "transparent"; }}>
|
||||
<div style={{ width: "28px", height: "28px", borderRadius: "50%", background: "var(--primary)", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<span style={{ color: "#fff", fontSize: "10px", fontFamily: "var(--font-inter)", fontWeight: "600" }}>{initials}</span>
|
||||
<>
|
||||
<Tooltip label={`${displayName} · Settings`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigate("settings")}
|
||||
style={{ ...btnStyle, padding: "6px 0" }}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background = "var(--accent)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "28px",
|
||||
height: "28px",
|
||||
borderRadius: "50%",
|
||||
background: "var(--primary)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "#fff", fontSize: "10px", fontFamily: "var(--font-inter)", fontWeight: "600" }}>
|
||||
{initials}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Log out">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
style={btnStyle}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background = "var(--accent)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "var(--muted)", flexShrink: 0, display: "flex" }}>
|
||||
<LogOut size={16} />
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
padding: "6px 10px",
|
||||
borderRadius: "var(--radius-button)",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigate("settings")}
|
||||
title="Settings"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
padding: 0,
|
||||
textAlign: "left",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
const nameEl = e.currentTarget.querySelector("[data-user-name]") as HTMLElement | null;
|
||||
if (nameEl) nameEl.style.color = "var(--primary)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
const nameEl = e.currentTarget.querySelector("[data-user-name]") as HTMLElement | null;
|
||||
if (nameEl) nameEl.style.color = "var(--sidebar-foreground)";
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "28px",
|
||||
height: "28px",
|
||||
borderRadius: "50%",
|
||||
background: "var(--primary)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "#fff", fontSize: "10px", fontFamily: "var(--font-inter)", fontWeight: "600" }}>
|
||||
{initials}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: "hidden" }}>
|
||||
<p
|
||||
data-user-name
|
||||
style={{
|
||||
fontSize: "var(--text-xs)",
|
||||
fontFamily: "var(--font-noto-sans)",
|
||||
fontWeight: "var(--font-weight-medium)",
|
||||
color: "var(--sidebar-foreground)",
|
||||
lineHeight: "1.3",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{displayName}
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "10px",
|
||||
fontFamily: "var(--font-noto-sans)",
|
||||
color: "var(--muted)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{email || "—"}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px", padding: "6px 10px", borderRadius: "var(--radius-button)" }}>
|
||||
<div style={{ width: "28px", height: "28px", borderRadius: "50%", background: "var(--primary)", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
|
||||
<span style={{ color: "#fff", fontSize: "10px", fontFamily: "var(--font-inter)", fontWeight: "600" }}>{initials}</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: "hidden" }}>
|
||||
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--sidebar-foreground)", lineHeight: "1.3", margin: 0 }}>{displayName}</p>
|
||||
<p style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", margin: 0 }}>{email || "—"}</p>
|
||||
</div>
|
||||
<button type="button" onClick={handleLogout} title="Log out"
|
||||
style={{ background: "none", border: "none", cursor: "pointer", color: "var(--muted)", display: "flex", padding: "2px", flexShrink: 0 }}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.color = "var(--foreground)"; }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
title="Log out"
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: "var(--muted)",
|
||||
display: "flex",
|
||||
padding: "2px",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.color = "var(--foreground)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.color = "var(--muted)";
|
||||
}}
|
||||
>
|
||||
<LogOut size={13} />
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,17 @@
|
|||
import React, { useState, useRef, useEffect, useMemo } from "react";
|
||||
import { Bell, Search, Menu, X, ArrowRight, MapPin, Calendar, ChevronRight, CreditCard } from "lucide-react";
|
||||
import { Bell, Search, Menu, X, ArrowRight, MapPin, Calendar, ChevronRight, CreditCard, ChevronDown } from "lucide-react";
|
||||
import { useBreakpoint } from "../hooks/useBreakpoint";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { useAppNav } from "../contexts/AppNavContext";
|
||||
import { organizationApi, subscriptionApi } from "../../services/api";
|
||||
import { organizationApi, subscriptionApi, eventsApi } from "../../services/api";
|
||||
import type { OrganizationRecord, SubscriptionPlanCatalog } from "../../services/api/types";
|
||||
import { portfolioListOrganizationId } from "../../services/api/scopedOrganization";
|
||||
import { ORGANIZATION_SUBSCRIPTION_UPDATED } from "../constants/appEvents";
|
||||
import {
|
||||
doSearch, SUGGESTED_SHORTCUTS,
|
||||
STATUS_BADGE, LOC_ICON,
|
||||
doSearch,
|
||||
STATUS_BADGE,
|
||||
mapApiEventToSearchEvent,
|
||||
SearchEvent, SearchPage, SearchAction, SearchResults,
|
||||
ALL_EVENTS,
|
||||
} from "./search/searchIndex";
|
||||
|
||||
/* ─────────────────────────────────────────
|
||||
|
|
@ -76,72 +77,9 @@ function SearchDropdown({
|
|||
</div>
|
||||
);
|
||||
|
||||
/* ── Suggested shortcuts (empty query) ── */
|
||||
/* ── Empty query: no menu (avoid static Suggested / Recent Events panel) ── */
|
||||
if (!trimmed) {
|
||||
return (
|
||||
<div>
|
||||
{/* Suggested chips */}
|
||||
<div style={{ padding: "12px 14px 8px" }}>
|
||||
<p style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "700", color: "var(--muted)", textTransform: "uppercase", letterSpacing: "0.07em", marginBottom: "8px" }}>
|
||||
Suggested
|
||||
</p>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "6px" }}>
|
||||
{SUGGESTED_SHORTCUTS.map(s => (
|
||||
<button
|
||||
key={s.navId}
|
||||
onClick={() => onPageClick({ id: s.navId, navId: s.navId, title: s.label, section: "", description: "", icon: s.icon })}
|
||||
style={{
|
||||
display: "inline-flex", alignItems: "center", gap: "5px",
|
||||
padding: "4px 10px", borderRadius: "var(--radius-button)",
|
||||
border: "1px solid var(--border)", background: "var(--secondary)",
|
||||
fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)",
|
||||
color: "var(--card-foreground)", cursor: "pointer",
|
||||
transition: "background 0.1s, border-color 0.1s",
|
||||
}}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--card-foreground)"; }}
|
||||
>
|
||||
<span style={{ color: "var(--primary)" }}>{s.icon}</span>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={{ borderTop: "1px solid var(--border)", margin: "4px 0" }} />
|
||||
|
||||
{/* Recent events */}
|
||||
<div style={{ padding: "4px 14px 10px" }}>
|
||||
<p style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "700", color: "var(--muted)", textTransform: "uppercase", letterSpacing: "0.07em", marginBottom: "4px", paddingTop: "8px" }}>
|
||||
Recent Events
|
||||
</p>
|
||||
{ALL_EVENTS.slice(0, 3).map(e => {
|
||||
const badge = STATUS_BADGE[e.status];
|
||||
return (
|
||||
<button key={e.id} onClick={() => onEventClick(e)}
|
||||
style={{
|
||||
display: "flex", alignItems: "center", gap: "10px",
|
||||
width: "100%", padding: "8px 0", textAlign: "left",
|
||||
background: "none", border: "none", borderBottom: "1px solid var(--border)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onMouseEnter={e2 => { (e2.currentTarget as HTMLElement).style.background = "var(--secondary)"; (e2.currentTarget as HTMLElement).style.borderRadius = "var(--radius-button)"; (e2.currentTarget as HTMLElement).style.paddingLeft = "6px"; }}
|
||||
onMouseLeave={e2 => { (e2.currentTarget as HTMLElement).style.background = "none"; (e2.currentTarget as HTMLElement).style.paddingLeft = "0"; }}
|
||||
>
|
||||
<span style={{ width: "6px", height: "6px", borderRadius: "50%", background: badge.dot ?? badge.color, flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{e.title}
|
||||
</span>
|
||||
<span style={{ padding: "1px 6px", borderRadius: "999px", background: badge.bg, color: badge.color, fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", flexShrink: 0 }}>
|
||||
{e.status}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ── No results ── */
|
||||
|
|
@ -363,12 +301,138 @@ function formatRenewalSuffix(expiresAt: string | undefined): { desktop: string;
|
|||
};
|
||||
}
|
||||
|
||||
function NotificationsBell() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDoc);
|
||||
return () => document.removeEventListener("mousedown", onDoc);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={wrapRef} style={{ position: "relative" }}>
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "32px",
|
||||
height: "32px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: open ? "var(--secondary)" : "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: open ? "var(--foreground)" : "var(--muted)",
|
||||
borderRadius: "var(--radius-button)",
|
||||
}}
|
||||
aria-label="Notifications"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background = "var(--secondary)";
|
||||
(e.currentTarget as HTMLElement).style.color = "var(--foreground)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (open) return;
|
||||
(e.currentTarget as HTMLElement).style.background = "none";
|
||||
(e.currentTarget as HTMLElement).style.color = "var(--muted)";
|
||||
}}
|
||||
>
|
||||
<Bell size={16} />
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label="Notifications"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "36px",
|
||||
right: 0,
|
||||
width: "min(320px, calc(100vw - 24px))",
|
||||
background: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--radius-card)",
|
||||
boxShadow: "var(--elevation-sm)",
|
||||
zIndex: 250,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
style={{
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "8px",
|
||||
padding: "12px 14px",
|
||||
background: "var(--secondary)",
|
||||
border: "none",
|
||||
borderBottom: expanded ? "1px solid var(--border)" : "none",
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "var(--text-sm)",
|
||||
fontFamily: "var(--font-noto-sans)",
|
||||
fontWeight: "var(--font-weight-medium)",
|
||||
color: "var(--foreground)",
|
||||
}}
|
||||
>
|
||||
Notifications
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
style={{
|
||||
color: "var(--muted)",
|
||||
transform: expanded ? "rotate(180deg)" : "none",
|
||||
transition: "transform 0.15s",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div style={{ padding: "16px 14px" }}>
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: "var(--text-sm)",
|
||||
fontFamily: "var(--font-noto-sans)",
|
||||
color: "var(--muted)",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
No new notifications
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoggedInUserButton({ isMobile }: { isMobile: boolean }) {
|
||||
const { user } = useAuth();
|
||||
const { setActiveNav } = useAppNav();
|
||||
const name = userDisplayName(user ?? null);
|
||||
const initials = userInitials(user ?? null);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Open settings for ${name}`}
|
||||
title="Settings"
|
||||
onClick={() => setActiveNav("settings")}
|
||||
style={{ display: "flex", alignItems: "center", gap: isMobile ? "0" : "8px", padding: isMobile ? "4px" : "4px 8px 4px 4px", borderRadius: "var(--radius-button)", background: "none", border: "none", cursor: "pointer" }}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.background = "var(--secondary)"; }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = "none"; }}
|
||||
|
|
@ -398,19 +462,47 @@ export function TopBar({ onMenuClick, onNavigate, onEventSelect, onOpenSearchPag
|
|||
const [planCatalog, setPlanCatalog] = useState<SubscriptionPlanCatalog[]>([]);
|
||||
/** Bumps when subscription/checkout updates org server-side so we refetch without relying on user._id alone. */
|
||||
const [orgDataVersion, setOrgDataVersion] = useState(0);
|
||||
const [orgSearchEvents, setOrgSearchEvents] = useState<SearchEvent[]>([]);
|
||||
|
||||
const bp = useBreakpoint();
|
||||
const isMobile = bp === "mobile";
|
||||
const isTablet = bp === "tablet";
|
||||
const listOrgId = portfolioListOrganizationId(user);
|
||||
|
||||
const searchContainerRef = useRef<HTMLDivElement>(null);
|
||||
const desktopInputRef = useRef<HTMLInputElement>(null);
|
||||
const mobileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
/* ── Current-org events for search (never cross-tenant demo data) ── */
|
||||
useEffect(() => {
|
||||
if (!listOrgId) {
|
||||
setOrgSearchEvents([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const res = await eventsApi.list({
|
||||
organization: listOrgId,
|
||||
recordKind: "event",
|
||||
page: 1,
|
||||
limit: 200,
|
||||
});
|
||||
if (cancelled) return;
|
||||
if (res.error || !res.data?.events) {
|
||||
setOrgSearchEvents([]);
|
||||
return;
|
||||
}
|
||||
setOrgSearchEvents(res.data.events.map(mapApiEventToSearchEvent));
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [listOrgId]);
|
||||
|
||||
/* ── Search results ── */
|
||||
const results: SearchResults | null = useMemo(
|
||||
() => query.trim() ? doSearch(query) : null,
|
||||
[query],
|
||||
() => (query.trim() ? doSearch(query, orgSearchEvents) : null),
|
||||
[query, orgSearchEvents],
|
||||
);
|
||||
|
||||
/* ── Flat items for keyboard nav ── */
|
||||
|
|
@ -498,13 +590,13 @@ export function TopBar({ onMenuClick, onNavigate, onEventSelect, onOpenSearchPag
|
|||
setTimeout(() => mobileInputRef.current?.focus(), 50);
|
||||
} else {
|
||||
desktopInputRef.current?.focus();
|
||||
setDropdownOpen(true);
|
||||
if (query.trim()) setDropdownOpen(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handler);
|
||||
return () => document.removeEventListener("keydown", handler);
|
||||
}, [isMobile]);
|
||||
}, [isMobile, query]);
|
||||
|
||||
/* ── Keyboard navigation inside input ── */
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
|
|
@ -617,14 +709,14 @@ export function TopBar({ onMenuClick, onNavigate, onEventSelect, onOpenSearchPag
|
|||
type="text"
|
||||
value={query}
|
||||
placeholder="Search events, pages, actions…"
|
||||
onChange={e => { setQuery(e.target.value); setActiveIndex(-1); setDropdownOpen(true); }}
|
||||
onFocus={() => { setFocused(true); setDropdownOpen(true); }}
|
||||
onChange={e => { setQuery(e.target.value); setActiveIndex(-1); setDropdownOpen(!!e.target.value.trim()); }}
|
||||
onFocus={() => { setFocused(true); if (query.trim()) setDropdownOpen(true); }}
|
||||
onBlur={() => setFocused(false)}
|
||||
onKeyDown={handleKeyDown}
|
||||
style={{ background: "transparent", border: "none", outline: "none", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)", width: "100%" }}
|
||||
/>
|
||||
{query ? (
|
||||
<button onClick={() => { setQuery(""); setActiveIndex(-1); }} style={{ background: "none", border: "none", cursor: "pointer", color: "var(--muted)", display: "flex", padding: "0", flexShrink: 0 }}>
|
||||
<button onClick={() => { setQuery(""); setActiveIndex(-1); setDropdownOpen(false); }} style={{ background: "none", border: "none", cursor: "pointer", color: "var(--muted)", display: "flex", padding: "0", flexShrink: 0 }}>
|
||||
<X size={12} />
|
||||
</button>
|
||||
) : (
|
||||
|
|
@ -634,8 +726,8 @@ export function TopBar({ onMenuClick, onNavigate, onEventSelect, onOpenSearchPag
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Dropdown ── */}
|
||||
{dropdownOpen && (
|
||||
{/* ── Dropdown (only when there is a query) ── */}
|
||||
{dropdownOpen && query.trim() && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute", top: "32px", left: 0,
|
||||
|
|
@ -729,16 +821,7 @@ export function TopBar({ onMenuClick, onNavigate, onEventSelect, onOpenSearchPag
|
|||
</button>
|
||||
)}
|
||||
|
||||
{/* Bell */}
|
||||
<button
|
||||
style={{ position: "relative", width: "32px", height: "32px", display: "flex", alignItems: "center", justifyContent: "center", background: "none", border: "none", cursor: "pointer", color: "var(--muted)", borderRadius: "var(--radius-button)" }}
|
||||
aria-label="Notifications (3 unread)"
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.background = "var(--secondary)"; (e.currentTarget as HTMLElement).style.color = "var(--foreground)"; }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = "none"; (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}
|
||||
>
|
||||
<Bell size={16} />
|
||||
<span style={{ position: "absolute", top: "4px", right: "4px", minWidth: "14px", height: "14px", borderRadius: "999px", background: "var(--destructive)", border: "2px solid var(--card)", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "8px", fontFamily: "var(--font-inter)", fontWeight: "700", color: "var(--destructive-foreground)", padding: "0 2px", lineHeight: 1 }}>3</span>
|
||||
</button>
|
||||
<NotificationsBell />
|
||||
|
||||
<div style={{ width: "1px", height: "20px", background: "var(--border)", margin: "0 2px" }} />
|
||||
|
||||
|
|
@ -786,18 +869,20 @@ export function TopBar({ onMenuClick, onNavigate, onEventSelect, onOpenSearchPag
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* Dropdown results */}
|
||||
<div style={{ overflowY: "auto", flex: 1 }}>
|
||||
<SearchDropdown
|
||||
query={query}
|
||||
results={results}
|
||||
activeIndex={activeIndex}
|
||||
onEventClick={handleEventClick}
|
||||
onPageClick={handlePageClick}
|
||||
onActionClick={handleActionClick}
|
||||
onViewAll={handleViewAll}
|
||||
/>
|
||||
</div>
|
||||
{/* Dropdown results — only when typing */}
|
||||
{query.trim() ? (
|
||||
<div style={{ overflowY: "auto", flex: 1 }}>
|
||||
<SearchDropdown
|
||||
query={query}
|
||||
results={results}
|
||||
activeIndex={activeIndex}
|
||||
onEventClick={handleEventClick}
|
||||
onPageClick={handlePageClick}
|
||||
onActionClick={handleActionClick}
|
||||
onViewAll={handleViewAll}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
import React, { useState, useMemo, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Search, X, LayoutGrid, List, Calendar as CalIcon,
|
||||
MessageCircle, BarChart3, HelpCircle, Users, CheckCircle2,
|
||||
MessageCircle, BarChart3, HelpCircle, Users, CheckCircle2, Star,
|
||||
} from "lucide-react";
|
||||
import { useBreakpoint } from "../../hooks/useBreakpoint";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
|
|
@ -22,6 +22,7 @@ import {
|
|||
} from "../quiz/AddQuizPollModal";
|
||||
import type { Question } from "../quiz/AddQuestionModal";
|
||||
import { modalQuestionToApi } from "../quiz/quizQuestionMappers";
|
||||
import { formatActivityRating, weightedActivityRating } from "./activityRatingDisplay";
|
||||
|
||||
function mapQuestionForQuizPollApi(q: Question, kind: "quiz" | "poll") {
|
||||
return modalQuestionToApi(q, kind);
|
||||
|
|
@ -41,6 +42,10 @@ interface ActivityRow {
|
|||
participants: number;
|
||||
questions: number;
|
||||
responses: number;
|
||||
eventRatingAverage: number;
|
||||
eventRatingCount: number;
|
||||
sessionRatingAverage: number;
|
||||
sessionRatingCount: number;
|
||||
subtype: "qna" | "quiz" | "poll";
|
||||
raw: Event;
|
||||
}
|
||||
|
|
@ -131,6 +136,10 @@ function eventToRow(ev: Event): ActivityRow {
|
|||
participants: st?.participantCount ?? 0,
|
||||
questions: st?.questionCount ?? 0,
|
||||
responses: st?.responseCount ?? 0,
|
||||
eventRatingAverage: st?.eventRatingAverage ?? 0,
|
||||
eventRatingCount: st?.eventRatingCount ?? 0,
|
||||
sessionRatingAverage: st?.sessionRatingAverage ?? 0,
|
||||
sessionRatingCount: st?.sessionRatingCount ?? 0,
|
||||
subtype: (ev.activitySubtype as "qna" | "quiz" | "poll") || "qna",
|
||||
raw: ev,
|
||||
};
|
||||
|
|
@ -391,11 +400,19 @@ export function ActivitiesPage({
|
|||
|
||||
const totals = useMemo(() => {
|
||||
const base = rows;
|
||||
const eventRating = weightedActivityRating(
|
||||
base.map((a) => ({ average: a.eventRatingAverage, count: a.eventRatingCount })),
|
||||
);
|
||||
const sessionRating = weightedActivityRating(
|
||||
base.map((a) => ({ average: a.sessionRatingAverage, count: a.sessionRatingCount })),
|
||||
);
|
||||
return {
|
||||
count: base.length,
|
||||
active: base.filter(a => a.status === "Active").length,
|
||||
participants: base.reduce((s, a) => s + a.participants, 0),
|
||||
responses: base.reduce((s, a) => s + a.responses, 0),
|
||||
eventRating,
|
||||
sessionRating,
|
||||
};
|
||||
}, [rows]);
|
||||
|
||||
|
|
@ -474,11 +491,23 @@ export function ActivitiesPage({
|
|||
|
||||
{!loading && orgId ? (
|
||||
<>
|
||||
<div style={{ display: "grid", gridTemplateColumns: isMobile ? "1fr 1fr" : "repeat(4, 1fr)", gap: "12px" }}>
|
||||
<div style={{ display: "grid", gridTemplateColumns: isMobile ? "1fr 1fr" : "repeat(3, 1fr)", gap: "12px" }}>
|
||||
<StatCard label="Total Activities" value={String(totals.count)} />
|
||||
<StatCard label="Active" value={String(totals.active)} color="#15803d" />
|
||||
<StatCard label="Total Participants" value={String(totals.participants)} />
|
||||
<StatCard label="Total Responses" value={String(totals.responses)} />
|
||||
<StatCard
|
||||
label="Event Rating"
|
||||
value={formatActivityRating(totals.eventRating.average, totals.eventRating.count)}
|
||||
sublabel={totals.eventRating.count ? `${totals.eventRating.count} ratings` : "No ratings yet"}
|
||||
color="#d97706"
|
||||
/>
|
||||
<StatCard
|
||||
label="Session Rating"
|
||||
value={formatActivityRating(totals.sessionRating.average, totals.sessionRating.count)}
|
||||
sublabel={totals.sessionRating.count ? `${totals.sessionRating.count} ratings` : "No ratings yet"}
|
||||
color="#d97706"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" }}>
|
||||
|
|
@ -704,7 +733,7 @@ export function ActivitiesPage({
|
|||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value, color }: { label: string; value: string; color?: string }) {
|
||||
function StatCard({ label, value, color, sublabel }: { label: string; value: string; color?: string; sublabel?: string }) {
|
||||
return (
|
||||
<div style={{ background: "var(--card)", borderRadius: "var(--radius-card)", borderWidth: "1px", borderStyle: "solid", borderColor: "var(--border)", padding: "16px 20px" }}>
|
||||
<div style={{ fontSize: "var(--text-2xl)", fontFamily: "var(--font-inter)", fontWeight: "var(--font-weight-medium)", color: color || "var(--foreground)", marginBottom: "4px" }}>
|
||||
|
|
@ -713,6 +742,11 @@ function StatCard({ label, value, color }: { label: string; value: string; color
|
|||
<div style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", textTransform: "uppercase", letterSpacing: "0.5px" }}>
|
||||
{label}
|
||||
</div>
|
||||
{sublabel ? (
|
||||
<div style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginTop: "6px" }}>
|
||||
{sublabel}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -753,7 +787,7 @@ function ActivityCard({ activity, onClick }: { activity: ActivityRow; onClick: (
|
|||
</p>
|
||||
) : null}
|
||||
|
||||
<div style={{ display: "flex", gap: "16px", paddingTop: "12px", borderTop: "1px solid var(--border)" }}>
|
||||
<div style={{ display: "flex", gap: "16px", paddingTop: "12px", borderTop: "1px solid var(--border)", flexWrap: "wrap" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
|
||||
<Users size={14} style={{ color: "var(--muted)" }} />
|
||||
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)" }}>{activity.participants}</span>
|
||||
|
|
@ -766,6 +800,18 @@ function ActivityCard({ activity, onClick }: { activity: ActivityRow; onClick: (
|
|||
<CheckCircle2 size={14} style={{ color: "var(--muted)" }} />
|
||||
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)" }}>{activity.responses}</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "6px" }} title="Event rating">
|
||||
<Star size={14} style={{ color: "#d97706" }} />
|
||||
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)" }}>
|
||||
{formatActivityRating(activity.eventRatingAverage, activity.eventRatingCount)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "6px" }} title="Session rating">
|
||||
<Star size={14} style={{ color: "#d97706", opacity: 0.75 }} />
|
||||
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)" }}>
|
||||
{formatActivityRating(activity.sessionRatingAverage, activity.sessionRatingCount)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -802,10 +848,16 @@ function ActivityListRow({ activity, isLast, onClick }: { activity: ActivityRow;
|
|||
{activity.type}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: "12px", minWidth: "120px" }}>
|
||||
<div style={{ display: "flex", gap: "12px", minWidth: "120px", flexWrap: "wrap" }}>
|
||||
<div style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)" }}>
|
||||
{activity.participants} participants
|
||||
</div>
|
||||
<div style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)" }}>
|
||||
Event {formatActivityRating(activity.eventRatingAverage, activity.eventRatingCount)}
|
||||
</div>
|
||||
<div style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)" }}>
|
||||
Session {formatActivityRating(activity.sessionRatingAverage, activity.sessionRatingCount)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span style={{ padding: "3px 10px", borderRadius: "999px", background: statusConfig.bg, color: statusConfig.color, fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", flexShrink: 0, textTransform: "uppercase" }}>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* Activity detail — single-session activity shell with tabs (Questions, Quiz & Polls, Participants, Settings).
|
||||
*/
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { QrCode } from "lucide-react";
|
||||
import { QrCode, Users, HelpCircle, CheckCircle2, Star } from "lucide-react";
|
||||
import { useAppNav } from "../../contexts/AppNavContext";
|
||||
import { useBreakpoint } from "../../hooks/useBreakpoint";
|
||||
import { eventsApi } from "../../../services/api/events";
|
||||
|
|
@ -23,6 +23,7 @@ import {
|
|||
} from "../quiz/AddQuizPollModal";
|
||||
import type { Question } from "../quiz/AddQuestionModal";
|
||||
import { modalQuestionToApi } from "../quiz/quizQuestionMappers";
|
||||
import { formatActivityRating } from "./activityRatingDisplay";
|
||||
|
||||
function mapQuestionForQuizPollApi(q: Question, kind: "quiz" | "poll") {
|
||||
return modalQuestionToApi(q, kind);
|
||||
|
|
@ -61,7 +62,7 @@ export function ActivityDetailPage() {
|
|||
setLoadError(null);
|
||||
try {
|
||||
const [evRes, sesRes] = await Promise.all([
|
||||
eventsApi.get(selectedEventId),
|
||||
eventsApi.get(selectedEventId, { enrich: true }),
|
||||
sessionsApi.list(selectedEventId),
|
||||
]);
|
||||
if (evRes.error || !evRes.data) {
|
||||
|
|
@ -105,6 +106,7 @@ export function ActivityDetailPage() {
|
|||
}, [tabs, activeTab]);
|
||||
|
||||
const progress = event ? activityProgress(event) : 0;
|
||||
const activityStats = event?.activityStats;
|
||||
const enableQnAOn = session ? effectiveEnableQnA(session) : true;
|
||||
const enableQnPOn = session ? effectiveEnableQnP(session) : true;
|
||||
const subtype = event?.activitySubtype || "qna";
|
||||
|
|
@ -272,6 +274,35 @@ export function ActivityDetailPage() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{activityStats ? (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: isMobile ? "1fr 1fr" : "repeat(6, minmax(0, 1fr))",
|
||||
gap: "10px",
|
||||
}}
|
||||
>
|
||||
<ActivityMetric label="Participants" value={String(activityStats.participantCount ?? 0)} icon={<Users size={14} />} />
|
||||
<ActivityMetric label="Questions" value={String(activityStats.questionCount ?? 0)} icon={<HelpCircle size={14} />} />
|
||||
<ActivityMetric label="Responses" value={String(activityStats.responseCount ?? 0)} icon={<CheckCircle2 size={14} />} />
|
||||
<ActivityMetric
|
||||
label="Event Rating"
|
||||
value={formatActivityRating(activityStats.eventRatingAverage, activityStats.eventRatingCount)}
|
||||
sublabel={activityStats.eventRatingCount ? `${activityStats.eventRatingCount} ratings` : undefined}
|
||||
icon={<Star size={14} />}
|
||||
accent="#d97706"
|
||||
/>
|
||||
<ActivityMetric
|
||||
label="Session Rating"
|
||||
value={formatActivityRating(activityStats.sessionRatingAverage, activityStats.sessionRatingCount)}
|
||||
sublabel={activityStats.sessionRatingCount ? `${activityStats.sessionRatingCount} ratings` : undefined}
|
||||
icon={<Star size={14} />}
|
||||
accent="#d97706"
|
||||
/>
|
||||
<ActivityMetric label="Engagement" value={`${progress}%`} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div style={{ borderBottom: "1px solid var(--border)" }}>
|
||||
<div style={{ display: "flex", gap: isMobile ? "8px" : "0", flexWrap: "wrap" }}>
|
||||
{tabs.map((tab) => (
|
||||
|
|
@ -435,3 +466,50 @@ export function ActivityDetailPage() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityMetric({
|
||||
label,
|
||||
value,
|
||||
sublabel,
|
||||
icon,
|
||||
accent,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
sublabel?: string;
|
||||
icon?: React.ReactNode;
|
||||
accent?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: "var(--card)",
|
||||
borderRadius: "var(--radius-card)",
|
||||
border: "1px solid var(--border)",
|
||||
padding: "12px 14px",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "6px", marginBottom: "4px" }}>
|
||||
{icon ? <span style={{ color: accent || "var(--muted)", display: "flex" }}>{icon}</span> : null}
|
||||
<span
|
||||
style={{
|
||||
fontSize: "var(--text-lg)",
|
||||
fontFamily: "var(--font-inter)",
|
||||
fontWeight: "var(--font-weight-medium)",
|
||||
color: accent || "var(--foreground)",
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", textTransform: "uppercase", letterSpacing: "0.4px" }}>
|
||||
{label}
|
||||
</div>
|
||||
{sublabel ? (
|
||||
<div style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginTop: "4px" }}>
|
||||
{sublabel}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -520,12 +520,24 @@ export function ActivityEventSettings({
|
|||
<option value="">{form.registrationRequired === "Yes" ? "Select mode" : "N/A"}</option>
|
||||
<option>Open</option>
|
||||
<option>Invite Only</option>
|
||||
<option>Approval Required</option>
|
||||
</select>
|
||||
</SelectWrap>
|
||||
</Field>
|
||||
</Row>
|
||||
<Row>
|
||||
<Field>
|
||||
<Label text="Registration Approval" />
|
||||
<RadioGroup
|
||||
name="registrationApproval"
|
||||
value={form.registrationApproval}
|
||||
onChange={(v) => f("registrationApproval", v as "auto" | "manual")}
|
||||
options={[
|
||||
{ label: "Auto Confirm", value: "auto" },
|
||||
{ label: "Manual Confirm", value: "manual" },
|
||||
]}
|
||||
disabled={form.registrationRequired !== "Yes"}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<Label text="Verify Email" />
|
||||
<RadioGroup
|
||||
|
|
@ -538,6 +550,8 @@ export function ActivityEventSettings({
|
|||
]}
|
||||
/>
|
||||
</Field>
|
||||
</Row>
|
||||
<Row>
|
||||
<Field>
|
||||
<Label text="Registration Deadline" />
|
||||
<input type="date" style={IS} value={form.registrationDeadline} onChange={(e) => f("registrationDeadline", e.target.value)} />
|
||||
|
|
|
|||
|
|
@ -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 { X, ChevronDown, ChevronUp, Check, Plus, UserPlus, Search } from "lucide-react";
|
||||
import { X, ChevronDown, ChevronUp, Check, Plus, UserPlus, Search, Trash2 } from "lucide-react";
|
||||
import {
|
||||
tracksApi,
|
||||
sessionsApi,
|
||||
|
|
@ -8,6 +8,7 @@ import {
|
|||
type ApiTrack,
|
||||
type ApiSession,
|
||||
type SessionPayload,
|
||||
type SessionTopic,
|
||||
type Speaker,
|
||||
} from "../../../services/api";
|
||||
import type { FormWithSubmissionCount } from "../../../services/api/types";
|
||||
|
|
@ -125,6 +126,38 @@ function StepCircle({ n, state }: { n: number; state: "todo" | "active" | "done"
|
|||
|
||||
type SectionErr = Record<string, string>;
|
||||
|
||||
type TopicRow = {
|
||||
type: string;
|
||||
duration: string;
|
||||
title: string;
|
||||
role: string;
|
||||
};
|
||||
|
||||
function emptyTopicRow(): TopicRow {
|
||||
return { type: "", duration: "", title: "", role: "" };
|
||||
}
|
||||
|
||||
function topicsFromSession(session?: ApiSession | null): TopicRow[] {
|
||||
if (!session?.topics?.length) return [];
|
||||
return session.topics.map((t) => ({
|
||||
type: t.type || "",
|
||||
duration: t.duration || "",
|
||||
title: t.title || "",
|
||||
role: t.role || "",
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeTopicsForPayload(rows: TopicRow[]): SessionTopic[] {
|
||||
return rows
|
||||
.map((t) => ({
|
||||
type: t.type.trim(),
|
||||
duration: t.duration.trim(),
|
||||
title: t.title.trim(),
|
||||
role: t.role.trim(),
|
||||
}))
|
||||
.filter((t) => t.type || t.duration || t.title || t.role);
|
||||
}
|
||||
|
||||
function extractSpeakerIds(s?: ApiSession | null): string[] {
|
||||
if (!s?.speakers?.length) return [];
|
||||
return s.speakers.map((sp) =>
|
||||
|
|
@ -201,6 +234,7 @@ export function AgendaSessionPanel({
|
|||
const [endLocal, setEndLocal] = useState(session?.endTime ? toDatetimeLocalValue(session.endTime) : def.end);
|
||||
|
||||
const [description, setDescription] = useState(session?.description || "");
|
||||
const [topics, setTopics] = useState<TopicRow[]>(() => topicsFromSession(session));
|
||||
|
||||
const audienceOn = (v: boolean | undefined) => v !== false;
|
||||
const [audienceFeedbackEnabled, setAudienceFeedbackEnabled] = useState(() =>
|
||||
|
|
@ -341,6 +375,15 @@ export function AgendaSessionPanel({
|
|||
const [err2, setErr2] = useState<SectionErr>({});
|
||||
const [err3, setErr3] = useState<SectionErr>({});
|
||||
const [err4, setErr4] = useState<SectionErr>({});
|
||||
const [err5, setErr5] = useState<SectionErr>({});
|
||||
|
||||
const updateTopic = (idx: number, key: keyof TopicRow, value: string) => {
|
||||
setTopics((prev) => prev.map((row, i) => (i === idx ? { ...row, [key]: value } : row)));
|
||||
};
|
||||
|
||||
const addTopic = () => setTopics((prev) => [...prev, emptyTopicRow()]);
|
||||
|
||||
const removeTopic = (idx: number) => setTopics((prev) => prev.filter((_, i) => i !== idx));
|
||||
|
||||
const validate1 = useCallback((): boolean => {
|
||||
const e: SectionErr = {};
|
||||
|
|
@ -374,7 +417,15 @@ export function AgendaSessionPanel({
|
|||
return true;
|
||||
}, []);
|
||||
|
||||
const validateAll = useCallback(() => validate1() && validate2() && validate3() && validate4(), [validate1, validate2, validate3, validate4]);
|
||||
const validate5 = useCallback((): boolean => {
|
||||
setErr5({});
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const validateAll = useCallback(
|
||||
() => validate1() && validate2() && validate3() && validate4() && validate5(),
|
||||
[validate1, validate2, validate3, validate4, validate5],
|
||||
);
|
||||
|
||||
const createInlineTrack = async () => {
|
||||
const e: SectionErr = {};
|
||||
|
|
@ -406,8 +457,9 @@ export function AgendaSessionPanel({
|
|||
if (section === 2 && !validate2()) return;
|
||||
if (section === 3 && !validate3()) return;
|
||||
if (section === 4 && !validate4()) return;
|
||||
if (section === 5 && !validate5()) return;
|
||||
setPassedSections((prev) => new Set([...prev, section]));
|
||||
if (section < 4) setOpenSection(section + 1);
|
||||
if (section < 5) setOpenSection(section + 1);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
|
|
@ -421,10 +473,13 @@ export function AgendaSessionPanel({
|
|||
if (!validate1()) setOpenSection(1);
|
||||
else if (!validate2()) setOpenSection(2);
|
||||
else if (!validate3()) setOpenSection(3);
|
||||
else setOpenSection(4);
|
||||
else if (!validate4()) setOpenSection(4);
|
||||
else setOpenSection(5);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedTopics = normalizeTopicsForPayload(topics);
|
||||
|
||||
const payload: SessionPayload = {
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
|
|
@ -434,6 +489,7 @@ export function AgendaSessionPanel({
|
|||
status: UI_TO_API_STATUS[status],
|
||||
track: trackId === UNASSIGNED_TRACK_ID ? null : trackId,
|
||||
speakers: selectedSpeakerIds,
|
||||
topics: normalizedTopics.length ? normalizedTopics : [],
|
||||
audienceFeedbackEnabled,
|
||||
audienceRatingsEnabled,
|
||||
audienceKeyPersonsEnabled,
|
||||
|
|
@ -460,7 +516,8 @@ export function AgendaSessionPanel({
|
|||
{ n: 1, title: "Track", subtitle: "Assign this session to a track. You can create a new track inline." },
|
||||
{ n: 2, title: "Session details", subtitle: "Title, format, and status." },
|
||||
{ n: 3, title: "Schedule", subtitle: "Start and end date/time." },
|
||||
{ n: 4, title: "Speakers & notes", subtitle: "Faculty from your organization; add new speakers as needed." },
|
||||
{ n: 4, title: "Session topics", subtitle: "Optional programme items within this session (type, duration, title, role)." },
|
||||
{ n: 5, title: "Speakers & notes", subtitle: "Faculty from your organization; add new speakers as needed." },
|
||||
];
|
||||
|
||||
const SaveBtn = () => (
|
||||
|
|
@ -750,6 +807,105 @@ export function AgendaSessionPanel({
|
|||
)}
|
||||
|
||||
{sec.n === 4 && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
|
||||
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: 0, fontFamily: "var(--font-noto-sans)", lineHeight: 1.45 }}>
|
||||
Add programme items for this session block — e.g. lectures, panel discussions, and speaker roles.
|
||||
</p>
|
||||
{topics.length === 0 ? (
|
||||
<p style={{ fontSize: "var(--text-sm)", color: "var(--muted)", margin: 0, fontFamily: "var(--font-noto-sans)" }}>
|
||||
No topics yet. Add one below.
|
||||
</p>
|
||||
) : null}
|
||||
{topics.map((topic, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
style={{
|
||||
border: "1px solid var(--border)",
|
||||
borderLeft: "3px solid color-mix(in srgb, var(--primary) 35%, var(--border))",
|
||||
borderRadius: "var(--radius-button)",
|
||||
padding: "12px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "10px",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<span style={{ fontSize: "var(--text-xs)", color: "var(--muted)", fontFamily: "var(--font-noto-sans)" }}>
|
||||
Topic {idx + 1}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeTopic(idx)}
|
||||
aria-label="Remove topic"
|
||||
style={{ border: "none", background: "none", cursor: "pointer", color: "var(--destructive)", display: "flex" }}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "10px" }}>
|
||||
<div>
|
||||
<label style={{ fontSize: "var(--text-xs)", display: "block", marginBottom: "4px", fontFamily: "var(--font-noto-sans)" }}>Topic type</label>
|
||||
<input
|
||||
value={topic.type}
|
||||
onChange={(e) => updateTopic(idx, "type", e.target.value)}
|
||||
placeholder="e.g. LECTURE, Panel Discussion"
|
||||
style={IS}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: "var(--text-xs)", display: "block", marginBottom: "4px", fontFamily: "var(--font-noto-sans)" }}>Duration</label>
|
||||
<input
|
||||
value={topic.duration}
|
||||
onChange={(e) => updateTopic(idx, "duration", e.target.value)}
|
||||
placeholder="e.g. 15 or 5+5"
|
||||
style={IS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: "var(--text-xs)", display: "block", marginBottom: "4px", fontFamily: "var(--font-noto-sans)" }}>Topic title</label>
|
||||
<input
|
||||
value={topic.title}
|
||||
onChange={(e) => updateTopic(idx, "title", e.target.value)}
|
||||
placeholder="Talk or segment title"
|
||||
style={IS}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: "var(--text-xs)", display: "block", marginBottom: "4px", fontFamily: "var(--font-noto-sans)" }}>Topic role</label>
|
||||
<input
|
||||
value={topic.role}
|
||||
onChange={(e) => updateTopic(idx, "role", e.target.value)}
|
||||
placeholder="e.g. Moderator, Speaker, Panellist"
|
||||
style={IS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addTopic}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
alignSelf: "flex-start",
|
||||
padding: "8px 12px",
|
||||
borderRadius: "var(--radius-button)",
|
||||
border: "1px dashed var(--border)",
|
||||
background: "transparent",
|
||||
cursor: "pointer",
|
||||
fontSize: "var(--text-sm)",
|
||||
color: "var(--primary)",
|
||||
fontFamily: "var(--font-noto-sans)",
|
||||
}}
|
||||
>
|
||||
<Plus size={14} /> Add session topic
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sec.n === 5 && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "14px" }}>
|
||||
<div>
|
||||
<label style={{ display: "block", fontSize: "var(--text-sm)", fontWeight: "var(--font-weight-medium)", marginBottom: "6px", fontFamily: "var(--font-noto-sans)" }}>Speakers</label>
|
||||
|
|
@ -1124,7 +1280,7 @@ export function AgendaSessionPanel({
|
|||
)}
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", marginTop: "16px", paddingTop: "12px", borderTop: "1px solid var(--border)" }}>
|
||||
{sec.n < 4 ? (
|
||||
{sec.n < 5 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => tryNext(sec.n)}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
X,
|
||||
} from "lucide-react";
|
||||
import { useAppNav } from "../../contexts/AppNavContext";
|
||||
import { usePermission } from "../../contexts/PermissionContext";
|
||||
import { attendancesApi } from "../../../services/api";
|
||||
import type {
|
||||
AttendanceRecord,
|
||||
|
|
@ -53,6 +54,10 @@ const emptyForm: FormState = {
|
|||
|
||||
export function AttendancePage() {
|
||||
const { selectedEventId } = useAppNav();
|
||||
const { canModule } = usePermission();
|
||||
const canReadAttendance = canModule("attendance", "read");
|
||||
const canWriteAttendance = canModule("attendance", "write");
|
||||
const canDeleteAttendance = canModule("attendance", "delete");
|
||||
const [sessions, setSessions] = useState<AttendanceRecord[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
|
@ -75,7 +80,7 @@ export function AttendancePage() {
|
|||
const selected = sessions.find((s) => s._id === selectedId) ?? null;
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
if (!selectedEventId) return;
|
||||
if (!selectedEventId || !canReadAttendance) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await attendancesApi.list(selectedEventId);
|
||||
|
|
@ -85,7 +90,7 @@ export function AttendancePage() {
|
|||
return;
|
||||
}
|
||||
setSessions(res.data?.attendances ?? []);
|
||||
}, [selectedEventId]);
|
||||
}, [selectedEventId, canReadAttendance]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions();
|
||||
|
|
@ -226,6 +231,14 @@ export function AttendancePage() {
|
|||
);
|
||||
}
|
||||
|
||||
if (!canReadAttendance) {
|
||||
return (
|
||||
<div style={{ padding: 24, color: "var(--muted)", fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)" }}>
|
||||
You do not have permission to view Attendance.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%", minHeight: 0 }}>
|
||||
<div
|
||||
|
|
@ -252,24 +265,26 @@ export function AttendancePage() {
|
|||
Attendance
|
||||
</h1>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreate}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "8px 14px",
|
||||
background: "var(--primary)",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
borderRadius: "var(--radius-button)",
|
||||
cursor: "pointer",
|
||||
fontSize: "var(--text-sm)",
|
||||
}}
|
||||
>
|
||||
<Plus size={14} /> Add session
|
||||
</button>
|
||||
{canWriteAttendance && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreate}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "8px 14px",
|
||||
background: "var(--primary)",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
borderRadius: "var(--radius-button)",
|
||||
cursor: "pointer",
|
||||
fontSize: "var(--text-sm)",
|
||||
}}
|
||||
>
|
||||
<Plus size={14} /> Add session
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
|
|
@ -370,14 +385,16 @@ export function AttendancePage() {
|
|||
)}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEdit(selected)}
|
||||
title="Edit"
|
||||
style={iconBtn}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
{canWriteAttendance && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEdit(selected)}
|
||||
title="Edit"
|
||||
style={iconBtn}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={exportCsv} title="Export CSV" style={iconBtn}>
|
||||
<Download size={14} />
|
||||
</button>
|
||||
|
|
@ -390,17 +407,21 @@ export function AttendancePage() {
|
|||
>
|
||||
<Link2 size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => regenerateLink(selected._id)}
|
||||
title="Regenerate link"
|
||||
style={iconBtn}
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
<button type="button" onClick={() => archiveSession(selected._id)} title="Archive" style={iconBtn}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
{canWriteAttendance && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => regenerateLink(selected._id)}
|
||||
title="Regenerate link"
|
||||
style={iconBtn}
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
)}
|
||||
{canDeleteAttendance && (
|
||||
<button type="button" onClick={() => archiveSession(selected._id)} title="Archive" style={iconBtn}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -449,11 +470,11 @@ export function AttendancePage() {
|
|||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canCheckIn || checkInBusy === p._id}
|
||||
disabled={!canWriteAttendance || !canCheckIn || checkInBusy === p._id}
|
||||
onClick={() => handleCheckIn(p._id)}
|
||||
style={{
|
||||
...checkInBtn,
|
||||
opacity: canCheckIn ? 1 : 0.5,
|
||||
opacity: canWriteAttendance && canCheckIn ? 1 : 0.5,
|
||||
}}
|
||||
>
|
||||
<UserCheck size={14} />
|
||||
|
|
|
|||
|
|
@ -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 { Calendar, MapPin, ExternalLink, Pencil } from "lucide-react";
|
||||
import { Calendar, MapPin, ExternalLink, Pencil, Settings } from "lucide-react";
|
||||
import { useBreakpoint } from "../../hooks/useBreakpoint";
|
||||
import type { Event } from "../../../services/api/types";
|
||||
import { defaultMicrositePageUrl, micrositePublicPageUrl } from "../../utils/micrositePublicUrl";
|
||||
|
|
@ -63,11 +63,13 @@ export function EventHeader({
|
|||
event,
|
||||
loading = false,
|
||||
onEditEvent,
|
||||
onWebsiteSettings,
|
||||
canEdit = true,
|
||||
}: {
|
||||
event: Event | null;
|
||||
loading?: boolean;
|
||||
onEditEvent?: () => void;
|
||||
onWebsiteSettings?: () => void;
|
||||
canEdit?: boolean;
|
||||
}) {
|
||||
const bp = useBreakpoint();
|
||||
|
|
@ -165,6 +167,32 @@ export function EventHeader({
|
|||
<ExternalLink size={12} /> View Website
|
||||
</a>
|
||||
) : null}
|
||||
{!isMobile && event ? (
|
||||
<button
|
||||
type="button"
|
||||
title="Website settings"
|
||||
aria-label="Website settings"
|
||||
disabled={!canEdit}
|
||||
onClick={() => canEdit && onWebsiteSettings?.()}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "30px",
|
||||
height: "30px",
|
||||
padding: 0,
|
||||
borderRadius: "var(--radius-button)",
|
||||
background: canEdit ? "rgba(255,255,255,0.15)" : "rgba(255,255,255,0.08)",
|
||||
backdropFilter: "blur(4px)",
|
||||
color: "#fff",
|
||||
border: "1px solid rgba(255,255,255,0.25)",
|
||||
cursor: canEdit ? "pointer" : "not-allowed",
|
||||
opacity: canEdit ? 1 : 0.7,
|
||||
}}
|
||||
>
|
||||
<Settings size={14} />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canEdit || !event}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { BarChart2, CalendarDays, Check, FileText, Globe, Link2, Plus, Share2, ShieldCheck, Tag, Ticket, Trophy, Upload } from "lucide-react";
|
||||
import { BarChart2, CalendarDays, Check, FileText, Globe, Link2, Mail, Plus, Share2, ShieldCheck, Tag, Ticket, Trophy, Upload } from "lucide-react";
|
||||
import { useBreakpoint } from "../../hooks/useBreakpoint";
|
||||
import { useAppNav } from "../../contexts/AppNavContext";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
|
|
@ -9,14 +9,17 @@ import { eventStaffApi } from "../../../services/api/eventStaff";
|
|||
import { quizPollsApi } from "../../../services/api/quizPolls";
|
||||
import { badgeBatchesApi } from "../../../services/api/badgeBatches";
|
||||
import { eventTicketsApi } from "../../../services/api/eventTickets";
|
||||
import { campaignsApi } from "../../../services/api/campaigns";
|
||||
import { countConfiguredRewardRules, RewardRulesSettingsPanel } from "../rewards/rewardRulesSettings";
|
||||
import { ManageStaticMicrositePanel } from "../microsite-v2/ManageStaticMicrositePanel";
|
||||
import { CreateFormPanel } from "../panels/CreateFormPanel";
|
||||
import { CreateEmailCampaignPanel } from "../panels/CreateEmailCampaignPanel";
|
||||
import { FormSubmissionsPanel } from "../panels/FormSubmissionsPanel";
|
||||
import { ShareFormPanel } from "../forms/ShareFormPanel";
|
||||
import { CreateBadgePanel } from "../panels/CreateBadgePanel";
|
||||
import { EventBadgesPanel } from "./EventBadgesPanel";
|
||||
import { EventDocumentsPanel } from "./EventDocumentsPanel";
|
||||
import { EventEmailCampaignsPanel } from "./EventEmailCampaignsPanel";
|
||||
import { AddQuizPollModal, buildQuizPollSettingsPayload } from "../quiz/AddQuizPollModal";
|
||||
import type { QuizPollModalForm } from "../quiz/AddQuizPollModal";
|
||||
import type { Question } from "../quiz/AddQuestionModal";
|
||||
|
|
@ -201,8 +204,24 @@ export function EventHomeModules() {
|
|||
const isSuperAdmin = user?.role === "super_admin";
|
||||
const canReadForms = canModule("forms", "read");
|
||||
const canWriteForms = canModule("forms", "write");
|
||||
const canReadSessions = canModule("sessions", "read");
|
||||
const canWriteSessions = canModule("sessions", "write");
|
||||
const canReadUserMgmt = canModule("user_management", "read");
|
||||
const canWriteUserMgmt = canModule("user_management", "write");
|
||||
const canReadActivities =
|
||||
canModule("live_polls", "read") || canModule("multiple_quiz_modes", "read");
|
||||
const canWriteActivities =
|
||||
canModule("live_polls", "write") || canModule("multiple_quiz_modes", "write");
|
||||
const canReadBadges = canModule("custom_badge_and_print_file_creation", "read");
|
||||
const canWriteBadges = canModule("custom_badge_and_print_file_creation", "write");
|
||||
const canReadTicketing = canModule("ticketing_free_and_paid", "read");
|
||||
const canReadMicrosite = canModule("event_website", "read");
|
||||
const canReadRewards = canModule("gamification", "read");
|
||||
const canReadWebCheckIn = canModule("web_check_in", "read");
|
||||
const canWriteWebCheckIn = canModule("web_check_in", "write");
|
||||
const canReadDocuments = canModule("content_management", "read");
|
||||
const canReadEmailCampaigns = canModule("email_outreach_campaigns", "read");
|
||||
const canWriteEmailCampaigns = canModule("email_outreach_campaigns", "write");
|
||||
const bp = useBreakpoint();
|
||||
const isMobile = bp === "mobile";
|
||||
const isTablet = bp === "tablet";
|
||||
|
|
@ -244,6 +263,10 @@ export function EventHomeModules() {
|
|||
const [webCheckInEnabled, setWebCheckInEnabled] = useState(false);
|
||||
const [webCheckInLoading, setWebCheckInLoading] = useState(false);
|
||||
const [showDocumentsPanel, setShowDocumentsPanel] = useState(false);
|
||||
const [emailCampaignCount, setEmailCampaignCount] = useState(0);
|
||||
const [emailCampaignLoading, setEmailCampaignLoading] = useState(false);
|
||||
const [showCreateEmailCampaign, setShowCreateEmailCampaign] = useState(false);
|
||||
const [showEmailCampaignsPanel, setShowEmailCampaignsPanel] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedEventId) {
|
||||
|
|
@ -450,6 +473,28 @@ export function EventHomeModules() {
|
|||
void loadAgendaMeta();
|
||||
}, [loadAgendaMeta]);
|
||||
|
||||
const loadEmailCampaignMeta = useCallback(async () => {
|
||||
if (!selectedEventId) {
|
||||
setEmailCampaignCount(0);
|
||||
setEmailCampaignLoading(false);
|
||||
return;
|
||||
}
|
||||
setEmailCampaignLoading(true);
|
||||
try {
|
||||
const res = await campaignsApi.listForEvent(selectedEventId);
|
||||
const campaigns = res.data?.campaigns ?? [];
|
||||
setEmailCampaignCount(campaigns.filter((c) => c.channel === "email").length);
|
||||
} catch {
|
||||
setEmailCampaignCount(0);
|
||||
} finally {
|
||||
setEmailCampaignLoading(false);
|
||||
}
|
||||
}, [selectedEventId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadEmailCampaignMeta();
|
||||
}, [loadEmailCampaignMeta]);
|
||||
|
||||
const handleBadgeSuccess = () => {
|
||||
setShowAddBadge(false);
|
||||
setDuplicateBadgeBatchId(null);
|
||||
|
|
@ -528,6 +573,11 @@ export function EventHomeModules() {
|
|||
: agendaLoading
|
||||
? "Loading…"
|
||||
: `${agendaCounts.tracks} track${agendaCounts.tracks === 1 ? "" : "s"} · ${agendaCounts.sessions} session${agendaCounts.sessions === 1 ? "" : "s"}`;
|
||||
const emailCampaignMeta = !selectedEventId
|
||||
? "Select an event to manage email campaigns"
|
||||
: emailCampaignLoading
|
||||
? "Loading…"
|
||||
: `${emailCampaignCount} email campaign${emailCampaignCount === 1 ? "" : "s"}`;
|
||||
const webCheckInMeta = !selectedEventId
|
||||
? "Select an event to configure web check-in"
|
||||
: webCheckInLoading
|
||||
|
|
@ -584,6 +634,7 @@ export function EventHomeModules() {
|
|||
gap: "12px",
|
||||
}}
|
||||
>
|
||||
{canReadSessions ? (
|
||||
<ModuleCard
|
||||
icon={<CalendarDays size={18} />}
|
||||
title="Agenda"
|
||||
|
|
@ -630,6 +681,7 @@ export function EventHomeModules() {
|
|||
</>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{canReadForms ? eventForms.map((form) => {
|
||||
const linkCopied = copiedFormLinkId === form._id;
|
||||
return (
|
||||
|
|
@ -776,6 +828,7 @@ export function EventHomeModules() {
|
|||
}
|
||||
/>
|
||||
) : null}
|
||||
{canReadUserMgmt ? (
|
||||
<ModuleCard
|
||||
icon={<ShieldCheck size={18} />}
|
||||
title="Users & Permissions"
|
||||
|
|
@ -824,6 +877,8 @@ export function EventHomeModules() {
|
|||
</>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{canReadActivities ? (
|
||||
<ModuleCard
|
||||
icon={<BarChart2 size={18} />}
|
||||
title="Activities"
|
||||
|
|
@ -832,20 +887,20 @@ export function EventHomeModules() {
|
|||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={actionsDisabled}
|
||||
disabled={actionsDisabled || !canWriteActivities}
|
||||
style={{
|
||||
...moduleButtonStyle(),
|
||||
opacity: actionsDisabled ? 0.55 : 1,
|
||||
cursor: actionsDisabled ? "not-allowed" : "pointer",
|
||||
opacity: actionsDisabled || !canWriteActivities ? 0.55 : 1,
|
||||
cursor: actionsDisabled || !canWriteActivities ? "not-allowed" : "pointer",
|
||||
}}
|
||||
onClick={() => setShowAddPoll(true)}
|
||||
onMouseEnter={(e) => {
|
||||
if (actionsDisabled) return;
|
||||
if (actionsDisabled || !canWriteActivities) return;
|
||||
(e.currentTarget as HTMLElement).style.borderColor = "var(--primary)";
|
||||
(e.currentTarget as HTMLElement).style.color = "var(--primary)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (actionsDisabled) return;
|
||||
if (actionsDisabled || !canWriteActivities) return;
|
||||
(e.currentTarget as HTMLElement).style.borderColor = "var(--border)";
|
||||
(e.currentTarget as HTMLElement).style.color = "var(--card-foreground)";
|
||||
}}
|
||||
|
|
@ -854,11 +909,11 @@ export function EventHomeModules() {
|
|||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={actionsDisabled}
|
||||
disabled={actionsDisabled || !canWriteActivities}
|
||||
style={{
|
||||
...moduleButtonStyle(true),
|
||||
opacity: actionsDisabled ? 0.55 : 1,
|
||||
cursor: actionsDisabled ? "not-allowed" : "pointer",
|
||||
opacity: actionsDisabled || !canWriteActivities ? 0.55 : 1,
|
||||
cursor: actionsDisabled || !canWriteActivities ? "not-allowed" : "pointer",
|
||||
}}
|
||||
onClick={() => setShowAddQuiz(true)}
|
||||
>
|
||||
|
|
@ -867,6 +922,8 @@ export function EventHomeModules() {
|
|||
</>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{canReadBadges ? (
|
||||
<ModuleCard
|
||||
icon={<Tag size={18} />}
|
||||
title="Tag & Badge Configuration"
|
||||
|
|
@ -895,6 +952,7 @@ export function EventHomeModules() {
|
|||
>
|
||||
View Badges
|
||||
</button>
|
||||
{canWriteBadges ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={actionsDisabled}
|
||||
|
|
@ -907,9 +965,12 @@ export function EventHomeModules() {
|
|||
>
|
||||
Add Badge
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{canReadTicketing ? (
|
||||
<ModuleCard
|
||||
icon={<Ticket size={18} />}
|
||||
title="Ticket configuration"
|
||||
|
|
@ -929,6 +990,8 @@ export function EventHomeModules() {
|
|||
</button>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{canReadMicrosite ? (
|
||||
<ModuleCard
|
||||
icon={<Globe size={18} />}
|
||||
title="Microsite"
|
||||
|
|
@ -948,6 +1011,8 @@ export function EventHomeModules() {
|
|||
</button>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{canReadRewards ? (
|
||||
<ModuleCard
|
||||
icon={<Trophy size={18} />}
|
||||
title="Reward Points Config"
|
||||
|
|
@ -967,6 +1032,8 @@ export function EventHomeModules() {
|
|||
</button>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{canReadWebCheckIn ? (
|
||||
<ModuleCard
|
||||
icon={<Plus size={18} />}
|
||||
title="Web check-in"
|
||||
|
|
@ -976,12 +1043,12 @@ export function EventHomeModules() {
|
|||
webCheckInEnabled ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={actionsDisabled || webCheckInLoading}
|
||||
disabled={actionsDisabled || webCheckInLoading || !canWriteWebCheckIn}
|
||||
style={{
|
||||
...moduleButtonStyle(),
|
||||
opacity: actionsDisabled || webCheckInLoading ? 0.55 : 1,
|
||||
opacity: actionsDisabled || webCheckInLoading || !canWriteWebCheckIn ? 0.55 : 1,
|
||||
cursor:
|
||||
actionsDisabled || webCheckInLoading
|
||||
actionsDisabled || webCheckInLoading || !canWriteWebCheckIn
|
||||
? "not-allowed"
|
||||
: "pointer",
|
||||
}}
|
||||
|
|
@ -992,12 +1059,12 @@ export function EventHomeModules() {
|
|||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={actionsDisabled || webCheckInLoading}
|
||||
disabled={actionsDisabled || webCheckInLoading || !canWriteWebCheckIn}
|
||||
style={{
|
||||
...moduleButtonStyle(true),
|
||||
opacity: actionsDisabled || webCheckInLoading ? 0.55 : 1,
|
||||
opacity: actionsDisabled || webCheckInLoading || !canWriteWebCheckIn ? 0.55 : 1,
|
||||
cursor:
|
||||
actionsDisabled || webCheckInLoading
|
||||
actionsDisabled || webCheckInLoading || !canWriteWebCheckIn
|
||||
? "not-allowed"
|
||||
: "pointer",
|
||||
}}
|
||||
|
|
@ -1008,6 +1075,8 @@ export function EventHomeModules() {
|
|||
)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{canReadDocuments ? (
|
||||
<ModuleCard
|
||||
icon={<Upload size={18} />}
|
||||
title="Upload Document"
|
||||
|
|
@ -1027,6 +1096,52 @@ export function EventHomeModules() {
|
|||
</button>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{canReadEmailCampaigns ? (
|
||||
<ModuleCard
|
||||
icon={<Mail size={18} />}
|
||||
title="Email Campaign"
|
||||
meta={emailCampaignMeta}
|
||||
actions={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={actionsDisabled}
|
||||
style={{
|
||||
...moduleButtonStyle(),
|
||||
opacity: actionsDisabled ? 0.55 : 1,
|
||||
cursor: actionsDisabled ? "not-allowed" : "pointer",
|
||||
}}
|
||||
onClick={() => setShowEmailCampaignsPanel(true)}
|
||||
onMouseEnter={(e) => {
|
||||
if (actionsDisabled) return;
|
||||
(e.currentTarget as HTMLElement).style.borderColor = "var(--primary)";
|
||||
(e.currentTarget as HTMLElement).style.color = "var(--primary)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (actionsDisabled) return;
|
||||
(e.currentTarget as HTMLElement).style.borderColor = "var(--border)";
|
||||
(e.currentTarget as HTMLElement).style.color = "var(--card-foreground)";
|
||||
}}
|
||||
>
|
||||
Manage
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={actionsDisabled || !canWriteEmailCampaigns}
|
||||
style={{
|
||||
...moduleButtonStyle(true),
|
||||
opacity: actionsDisabled || !canWriteEmailCampaigns ? 0.55 : 1,
|
||||
cursor: actionsDisabled || !canWriteEmailCampaigns ? "not-allowed" : "pointer",
|
||||
}}
|
||||
onClick={() => setShowCreateEmailCampaign(true)}
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{formShareTarget ? (
|
||||
|
|
@ -1127,6 +1242,23 @@ export function EventHomeModules() {
|
|||
onClose={() => setShowDocumentsPanel(false)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showEmailCampaignsPanel && selectedEventId ? (
|
||||
<EventEmailCampaignsPanel
|
||||
eventId={selectedEventId}
|
||||
eventName={eventName ?? undefined}
|
||||
onClose={() => setShowEmailCampaignsPanel(false)}
|
||||
onChanged={() => void loadEmailCampaignMeta()}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showCreateEmailCampaign && selectedEventId ? (
|
||||
<CreateEmailCampaignPanel
|
||||
eventId={selectedEventId}
|
||||
onClose={() => setShowCreateEmailCampaign(false)}
|
||||
onSuccess={() => void loadEmailCampaignMeta()}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import {
|
|||
Palette, Mic2, Users, FileText, BookOpen, MessageSquare,
|
||||
Shield, Image, Layout, Tag, Mail,
|
||||
Plus, Search, X, MoreHorizontal, Upload,
|
||||
Download, Trash2, Pencil, Copy, ChevronDown, ChevronLeft, ChevronRight, RefreshCw,
|
||||
Download, Trash2, Pencil, Copy, ChevronDown, ChevronLeft, ChevronRight, RefreshCw, RotateCcw,
|
||||
Link2, List, Check, Share2,
|
||||
} from "lucide-react";
|
||||
import { useBreakpoint } from "../../hooks/useBreakpoint";
|
||||
|
|
@ -46,6 +46,7 @@ import type {
|
|||
EventMicrosite,
|
||||
} from "../../../services/api";
|
||||
import { LoadingState } from "../shared";
|
||||
import { buildEmailerPreviewHtml } from "../../utils/emailerPreview";
|
||||
import { ConfirmModal } from "../shared";
|
||||
import { AddSpeakerPanel } from "../panels/AddSpeakerPanel";
|
||||
import { CreateCustomListPanel } from "../panels/CreateCustomListPanel";
|
||||
|
|
@ -147,6 +148,7 @@ function formatLastEdited(updatedAt?: string): string {
|
|||
|
||||
export function DesignPage({ subPage }: { subPage?: string }) {
|
||||
const { user } = useAuth();
|
||||
const { canModule } = usePermission();
|
||||
const bp = useBreakpoint();
|
||||
const isMobile = bp === "mobile";
|
||||
const activeTab = subPage === "design-brochure" ? "Brochure"
|
||||
|
|
@ -156,6 +158,8 @@ export function DesignPage({ subPage }: { subPage?: string }) {
|
|||
: "Brochure";
|
||||
const apiType = (subPage && SUBPAGE_TO_TYPE[subPage]) || "brochure";
|
||||
const isSuperAdmin = user?.role === "super_admin";
|
||||
const canWriteEmailers = isSuperAdmin || canModule("email_outreach_campaigns", "write");
|
||||
const canManageEmailers = apiType === "emailer" ? canWriteEmailers : isSuperAdmin;
|
||||
const canManageMicrosite =
|
||||
apiType === "microsite" &&
|
||||
user &&
|
||||
|
|
@ -371,6 +375,9 @@ export function DesignPage({ subPage }: { subPage?: string }) {
|
|||
if (c != null && typeof c === "object" && "html" in c && typeof (c as { html?: unknown }).html === "string") {
|
||||
html = String((c as { html: string }).html || "");
|
||||
}
|
||||
if (!html.trim() && c != null && typeof c === "object") {
|
||||
html = buildEmailerPreviewHtml(c as Record<string, unknown>);
|
||||
}
|
||||
if (!html.trim()) {
|
||||
html =
|
||||
'<p style="font-family:system-ui,sans-serif;padding:24px;color:#64748b">No HTML saved for this template yet. Use Edit to build the emailer.</p>';
|
||||
|
|
@ -462,7 +469,7 @@ export function DesignPage({ subPage }: { subPage?: string }) {
|
|||
<Upload size={13} /> Import
|
||||
</button>
|
||||
)}
|
||||
{(apiType === "microsite" ? canManageMicrosite : apiType === "badge" ? canManageBadgeBatch : isSuperAdmin) && (
|
||||
{(apiType === "microsite" ? canManageMicrosite : apiType === "badge" ? canManageBadgeBatch : canManageEmailers) && (
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "var(--primary-foreground)", border: "none", cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", transition: "opacity 0.15s", flex: isMobile ? 1 : "none", justifyContent: isMobile ? "center" : "flex-start" }}
|
||||
|
|
@ -507,7 +514,7 @@ export function DesignPage({ subPage }: { subPage?: string }) {
|
|||
{templateIcons[activeTab]}
|
||||
{hovered === t._id && (
|
||||
<div style={{ position: "absolute", inset: 0, background: "rgba(11,77,209,0.08)", display: "flex", alignItems: "center", justifyContent: "center", gap: "6px", flexWrap: "wrap" }}>
|
||||
{isSuperAdmin && (
|
||||
{(isSuperAdmin || (apiType === "emailer" && canWriteEmailers)) && (
|
||||
<button onClick={e => { e.stopPropagation(); handleEdit(t); }} style={{ padding: "5px 12px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "#fff", border: "none", cursor: "pointer", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)" }}>Edit</button>
|
||||
)}
|
||||
{apiType === "brochure" ? (
|
||||
|
|
@ -552,7 +559,7 @@ export function DesignPage({ subPage }: { subPage?: string }) {
|
|||
{emailerPreviewBusyId === t._id ? "…" : "Preview"}
|
||||
</button>
|
||||
) : null}
|
||||
{isSuperAdmin && (
|
||||
{((isSuperAdmin && apiType !== "emailer") || (apiType === "emailer" && canWriteEmailers && t.canDelete !== false && !t.isSystemTemplate)) && (
|
||||
<button onClick={e => { e.stopPropagation(); setDeleteConfirm(t); }} style={{ padding: "5px 12px", borderRadius: "var(--radius-button)", background: "var(--destructive)", color: "#fff", border: "none", cursor: "pointer", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)" }}>Delete</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -561,13 +568,21 @@ export function DesignPage({ subPage }: { subPage?: string }) {
|
|||
<div style={{ padding: "10px 12px" }}>
|
||||
<p style={{ fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", marginBottom: "2px" }}>{t.name}</p>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: "4px" }}>
|
||||
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", background: "var(--secondary)", padding: "1px 6px", borderRadius: "3px" }}>{t.category || "—"}</span>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: "4px", flexWrap: "wrap" }}>
|
||||
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", background: "var(--secondary)", padding: "1px 6px", borderRadius: "3px" }}>{t.category || "—"}</span>
|
||||
{apiType === "emailer" && t.isSystemTemplate ? (
|
||||
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: "var(--primary)", background: "var(--accent)", padding: "1px 6px", borderRadius: "3px" }}>Global</span>
|
||||
) : null}
|
||||
{apiType === "emailer" && t.isOrgOverride ? (
|
||||
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", background: "var(--secondary)", padding: "1px 6px", borderRadius: "3px" }}>Customized</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>{formatLastEdited(t.updatedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{isSuperAdmin && (
|
||||
{canManageEmailers && (
|
||||
<div
|
||||
onClick={handleCreate}
|
||||
style={{ border: "2px dashed var(--border)", borderRadius: "var(--radius-card)", minHeight: "170px", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: "8px", cursor: "pointer", transition: "border-color 0.12s, background 0.12s" }}
|
||||
|
|
@ -1070,7 +1085,9 @@ function toAllParticipantRow(p: ParticipantWithEvent) {
|
|||
? "Checked In"
|
||||
: statusRaw === "confirmed"
|
||||
? "Confirmed"
|
||||
: "Pending";
|
||||
: statusRaw === "rejected"
|
||||
? "Rejected"
|
||||
: "Pending";
|
||||
const ev = p.event as { _id?: string; name?: string } | undefined;
|
||||
const eventName = ev?.name ?? "—";
|
||||
const id = String(p._id);
|
||||
|
|
@ -1079,6 +1096,9 @@ function toAllParticipantRow(p: ParticipantWithEvent) {
|
|||
|
||||
export function AllParticipantsPage() {
|
||||
const { user } = useAuth();
|
||||
const { canModule } = usePermission();
|
||||
const canWriteParticipants = canModule("participants", "write");
|
||||
const canDeleteParticipants = canModule("participants", "delete");
|
||||
const listOrgId = portfolioListOrganizationId(user);
|
||||
const isSuperAdmin = user?.role === "super_admin";
|
||||
const { setActiveNav } = useAppNav();
|
||||
|
|
@ -1089,6 +1109,7 @@ export function AllParticipantsPage() {
|
|||
const [searchInput, setSearchInput] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [activeTag, setActiveTag] = useState<string | null>(null);
|
||||
const [showDeleted, setShowDeleted] = useState(false);
|
||||
const [hovered, setHovered] = useState<string | null>(null);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
@ -1100,6 +1121,7 @@ export function AllParticipantsPage() {
|
|||
const [tagOptions, setTagOptions] = useState<string[]>([]);
|
||||
const [editParticipantId, setEditParticipantId] = useState<string | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null);
|
||||
const [restoringId, setRestoringId] = useState<string | null>(null);
|
||||
const searchDebounce = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
|
||||
const {
|
||||
|
|
@ -1131,17 +1153,18 @@ export function AllParticipantsPage() {
|
|||
return () => { if (searchDebounce.current) clearTimeout(searchDebounce.current); };
|
||||
}, [searchInput]);
|
||||
|
||||
useEffect(() => { setPage(1); }, [search, activeTag]);
|
||||
useEffect(() => { setPage(1); }, [search, activeTag, showDeleted]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!listOrgId) {
|
||||
setTagOptions([]);
|
||||
return;
|
||||
}
|
||||
if (showDeleted) return;
|
||||
participantsApi.listDistinctTags({ organization: listOrgId }).then((r) => {
|
||||
if (r.data?.tags?.length) setTagOptions(r.data.tags);
|
||||
});
|
||||
}, [listOrgId, listNonce]);
|
||||
}, [listOrgId, listNonce, showDeleted]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!listOrgId) {
|
||||
|
|
@ -1157,7 +1180,8 @@ export function AllParticipantsPage() {
|
|||
page,
|
||||
limit,
|
||||
q: search || undefined,
|
||||
tag: activeTag ?? undefined,
|
||||
tag: showDeleted ? undefined : (activeTag ?? undefined),
|
||||
deleted: showDeleted || undefined,
|
||||
})
|
||||
.then((res) => {
|
||||
setLoading(false);
|
||||
|
|
@ -1167,7 +1191,7 @@ export function AllParticipantsPage() {
|
|||
}
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
}, [listOrgId, page, limit, search, activeTag, listNonce]);
|
||||
}, [listOrgId, page, limit, search, activeTag, listNonce, showDeleted]);
|
||||
|
||||
const clearFilters = () => { setSearchInput(""); setSearch(""); setActiveTag(null); setPage(1); };
|
||||
|
||||
|
|
@ -1214,12 +1238,24 @@ export function AllParticipantsPage() {
|
|||
setImportMsg(res.error);
|
||||
return;
|
||||
}
|
||||
setImportMsg(`Removed ${name}.`);
|
||||
setImportMsg(`Moved ${name} to Deleted.`);
|
||||
setSelectAllMatchingActive(false);
|
||||
setSelectedIds((prev) => prev.filter((x) => x !== id));
|
||||
setListNonce((n) => n + 1);
|
||||
};
|
||||
|
||||
const restoreOne = async (id: string, name: string) => {
|
||||
setRestoringId(id);
|
||||
const res = await participantsApi.restore(id);
|
||||
setRestoringId(null);
|
||||
if (res.error) {
|
||||
setImportMsg(res.error);
|
||||
return;
|
||||
}
|
||||
setImportMsg(`Restored ${name}.`);
|
||||
setListNonce((n) => n + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "20px" }}>
|
||||
{importMsg && (
|
||||
|
|
@ -1233,25 +1269,57 @@ export function AllParticipantsPage() {
|
|||
<p style={labelStyle}>Portfolio</p>
|
||||
<h1 style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-2xl)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>Participants</h1>
|
||||
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginTop: "4px" }}>
|
||||
{total} in your organization (Including people not yet assigned to an event) · Page {page} of {Math.max(1, Math.ceil(total / limit))}
|
||||
{total} {showDeleted ? "deleted" : "active"} in your organization · Page {page} of {Math.max(1, Math.ceil(total / limit))}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "8px" }}>
|
||||
<button type="button" disabled={exporting} onClick={runExportAll} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--card-foreground)", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", cursor: exporting ? "wait" : "pointer", opacity: exporting ? 0.7 : 1 }}>
|
||||
<button type="button" disabled={exporting || showDeleted} onClick={runExportAll} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--card-foreground)", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", cursor: exporting ? "wait" : "pointer", opacity: exporting || showDeleted ? 0.7 : 1 }}>
|
||||
<Download size={13} /> {exporting ? "Exporting…" : "Export"}
|
||||
</button>
|
||||
<button type="button" onClick={() => { setImportMsg(null); setShowImport(true); }} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--card-foreground)", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", cursor: "pointer" }}>
|
||||
<Upload size={13} /> Import
|
||||
</button>
|
||||
{canWriteParticipants && !showDeleted && (
|
||||
<button type="button" onClick={() => { setImportMsg(null); setShowImport(true); }} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--card-foreground)", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", cursor: "pointer" }}>
|
||||
<Upload size={13} /> Import
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={() => setActiveNav("participant-lists")} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--secondary)", color: "var(--card-foreground)", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", cursor: "pointer" }}>
|
||||
Custom lists
|
||||
</button>
|
||||
<button type="button" onClick={() => setShowCustomList(true)} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "var(--primary-foreground)", border: "none", cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)" }}>
|
||||
<Plus size={13} /> Create Custom List
|
||||
</button>
|
||||
{canWriteParticipants && (
|
||||
<button type="button" onClick={() => setShowCustomList(true)} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "var(--primary-foreground)", border: "none", cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)" }}>
|
||||
<Plus size={13} /> Create Custom List
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active / Deleted toggle */}
|
||||
<div style={{ display: "inline-flex", alignItems: "center", gap: "0", border: "1px solid var(--border)", borderRadius: "var(--radius-button)", overflow: "hidden", alignSelf: "flex-start" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setShowDeleted(false); clearSelection(); }}
|
||||
style={{
|
||||
padding: "6px 14px", border: "none", cursor: "pointer",
|
||||
background: !showDeleted ? "var(--primary)" : "var(--card)",
|
||||
color: !showDeleted ? "var(--primary-foreground)" : "var(--card-foreground)",
|
||||
fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)",
|
||||
}}
|
||||
>
|
||||
Active
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setShowDeleted(true); clearSelection(); setActiveTag(null); }}
|
||||
style={{
|
||||
padding: "6px 14px", border: "none", cursor: "pointer",
|
||||
background: showDeleted ? "var(--primary)" : "var(--card)",
|
||||
color: showDeleted ? "var(--primary-foreground)" : "var(--card-foreground)",
|
||||
fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)",
|
||||
}}
|
||||
>
|
||||
Deleted
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter row */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" }}>
|
||||
<ParticipantSelectAllMatching
|
||||
|
|
@ -1280,23 +1348,29 @@ export function AllParticipantsPage() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{!showDeleted && (
|
||||
<ParticipantBulkActionBar
|
||||
selectedCount={selectedIds.length}
|
||||
busy={bulk.bulkBusy}
|
||||
variant="portfolio"
|
||||
onAddTags={() => void bulk.openBulkDialog("addTags")}
|
||||
onRemoveTags={() => void bulk.openBulkDialog("removeTags")}
|
||||
onAssignEvents={() => void bulk.openBulkDialog("assignEvents")}
|
||||
onNewList={() => void bulk.openBulkDialog("newList")}
|
||||
onAddToList={() => void bulk.openBulkDialog("addToList")}
|
||||
onDeleteSelected={() => { if (window.confirm(`Delete ${selectedIds.length} participant(s) permanently?`)) void bulk.runBulkDelete(); }}
|
||||
onAddTags={canWriteParticipants ? () => void bulk.openBulkDialog("addTags") : undefined}
|
||||
onRemoveTags={canWriteParticipants ? () => void bulk.openBulkDialog("removeTags") : undefined}
|
||||
onAssignEvents={canWriteParticipants ? () => void bulk.openBulkDialog("assignEvents") : undefined}
|
||||
onNewList={canWriteParticipants ? () => void bulk.openBulkDialog("newList") : undefined}
|
||||
onAddToList={canWriteParticipants ? () => void bulk.openBulkDialog("addToList") : undefined}
|
||||
onDeleteSelected={
|
||||
canDeleteParticipants
|
||||
? () => { if (window.confirm(`Move ${selectedIds.length} participant(s) to Deleted?`)) void bulk.runBulkDelete(); }
|
||||
: undefined
|
||||
}
|
||||
onClearSelection={clearSelection}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<ScrollTable minWidth={720}>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "38px 1.1fr 1.35fr minmax(100px,1fr) 1fr 92px", padding: "8px 16px", background: "var(--secondary)", borderBottom: "1px solid var(--border)", alignItems: "center" }}>
|
||||
<input ref={headerSelectRef} type="checkbox" title="Select visible rows" onChange={toggleAllVisible} style={{ width: "16px", height: "16px", cursor: "pointer" }} />
|
||||
<input ref={headerSelectRef} type="checkbox" title="Select visible rows" onChange={toggleAllVisible} disabled={showDeleted} style={{ width: "16px", height: "16px", cursor: showDeleted ? "not-allowed" : "pointer", opacity: showDeleted ? 0.4 : 1 }} />
|
||||
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", color: "var(--muted)", textTransform: "uppercase" }}>Name</span>
|
||||
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", color: "var(--muted)", textTransform: "uppercase" }}>Email</span>
|
||||
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", color: "var(--muted)", textTransform: "uppercase" }}>Tags</span>
|
||||
|
|
@ -1309,10 +1383,12 @@ export function AllParticipantsPage() {
|
|||
<div style={{ padding: "40px", textAlign: "center" }}>
|
||||
<Search size={22} style={{ color: "var(--border)", margin: "0 auto 10px" }} />
|
||||
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", marginBottom: "4px" }}>
|
||||
No participants found
|
||||
{showDeleted ? "No deleted participants" : "No participants found"}
|
||||
</p>
|
||||
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginBottom: "14px" }}>
|
||||
{(search || activeTag) ? "Try adjusting your filters or import participants." : "No one in your organization yet. Import a CSV or add people from an event."}
|
||||
{showDeleted
|
||||
? "Soft-deleted people will appear here so you can restore them."
|
||||
: (search || activeTag) ? "Try adjusting your filters or import participants." : "No one in your organization yet. Import a CSV or add people from an event."}
|
||||
</p>
|
||||
{(search || activeTag) && (
|
||||
<button type="button" onClick={clearFilters}
|
||||
|
|
@ -1327,14 +1403,14 @@ export function AllParticipantsPage() {
|
|||
onMouseLeave={() => setHovered(null)}
|
||||
style={{ display: "grid", gridTemplateColumns: "38px 1.1fr 1.35fr minmax(100px,1fr) 1fr 92px", alignItems: "center", padding: "11px 16px", borderBottom: "1px solid var(--border)", background: hovered === p.rowKey ? "var(--secondary)" : "var(--card)", transition: "background 0.12s" }}
|
||||
>
|
||||
<input type="checkbox" checked={selectedIds.includes(p.id)} onChange={() => toggleRow(p.id)} style={{ width: "16px", height: "16px", cursor: "pointer" }} />
|
||||
<input type="checkbox" checked={selectedIds.includes(p.id)} onChange={() => toggleRow(p.id)} disabled={showDeleted} style={{ width: "16px", height: "16px", cursor: showDeleted ? "not-allowed" : "pointer", opacity: showDeleted ? 0.4 : 1 }} />
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px", minWidth: 0 }}>
|
||||
<div style={{ width: "28px", height: "28px", borderRadius: "50%", background: "var(--primary)", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
|
||||
<span style={{ color: "#fff", fontSize: "9px", fontFamily: "var(--font-inter)", fontWeight: "600" }}>{(p.name.slice(0, 2) || "?").toUpperCase()}</span>
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>{p.name}</p>
|
||||
<p style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: p.status === "—" || p.status === "Cancelled" ? "var(--muted)" : "#15803d" }}>{p.status}</p>
|
||||
<p style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: p.status === "—" || p.status === "Cancelled" || p.status === "Rejected" ? "var(--muted)" : "#15803d" }}>{p.status}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{p.email}</span>
|
||||
|
|
@ -1347,12 +1423,32 @@ export function AllParticipantsPage() {
|
|||
</div>
|
||||
<span style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-sm)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{p.eventName}</span>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "4px", justifyContent: "flex-end" }}>
|
||||
<button type="button" title="Edit" onClick={() => setEditParticipantId(p.id)} style={{ display: "flex", alignItems: "center", justifyContent: "center", width: "30px", height: "30px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--muted)", cursor: "pointer" }}>
|
||||
<Pencil size={13} />
|
||||
</button>
|
||||
<button type="button" title="Delete" onClick={() => setDeleteTarget({ id: p.id, name: p.name })} style={{ display: "flex", alignItems: "center", justifyContent: "center", width: "30px", height: "30px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--destructive)", cursor: "pointer" }}>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
{showDeleted ? (
|
||||
canDeleteParticipants ? (
|
||||
<button
|
||||
type="button"
|
||||
title="Restore participant"
|
||||
disabled={restoringId === p.id}
|
||||
onClick={() => void restoreOne(p.id, p.name)}
|
||||
style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: "4px", height: "30px", padding: "0 10px", borderRadius: "var(--radius-button)", border: "1px solid #86efac", background: "#dcfce7", color: "#15803d", cursor: restoringId === p.id ? "wait" : "pointer", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)" }}
|
||||
>
|
||||
<RotateCcw size={12} /> {restoringId === p.id ? "…" : "Restore"}
|
||||
</button>
|
||||
) : null
|
||||
) : (
|
||||
<>
|
||||
{canWriteParticipants && (
|
||||
<button type="button" title="Edit" onClick={() => setEditParticipantId(p.id)} style={{ display: "flex", alignItems: "center", justifyContent: "center", width: "30px", height: "30px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--muted)", cursor: "pointer" }}>
|
||||
<Pencil size={13} />
|
||||
</button>
|
||||
)}
|
||||
{canDeleteParticipants && (
|
||||
<button type="button" title="Delete" onClick={() => setDeleteTarget({ id: p.id, name: p.name })} style={{ display: "flex", alignItems: "center", justifyContent: "center", width: "30px", height: "30px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--destructive)", cursor: "pointer" }}>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -1420,7 +1516,7 @@ export function AllParticipantsPage() {
|
|||
{deleteTarget && (
|
||||
<ConfirmModal
|
||||
title="Delete participant?"
|
||||
message={`Remove ${deleteTarget.name} from your organization? This cannot be undone.`}
|
||||
message={`Move ${deleteTarget.name} to Deleted? You can restore them later from the Deleted tab.`}
|
||||
confirmLabel="Delete"
|
||||
onConfirm={() => void confirmDeleteOne()}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
|
|
@ -1435,6 +1531,7 @@ export function AllParticipantsPage() {
|
|||
onToggleTag={bulk.toggleSelectedTag}
|
||||
availableTags={tagOptions}
|
||||
removeTagOptions={Array.from(new Set(list.filter((participant) => selectedIds.includes(participant.id)).flatMap((participant) => participant.tags)))}
|
||||
tagScope="global"
|
||||
newListName={bulk.newListName}
|
||||
onNewListNameChange={bulk.setNewListName}
|
||||
newListDesc={bulk.newListDesc}
|
||||
|
|
@ -1478,6 +1575,7 @@ function formatListMemberStatus(s: string | undefined): string {
|
|||
if (raw === "checked_in") return "Checked In";
|
||||
if (raw === "confirmed") return "Confirmed";
|
||||
if (raw === "pending") return "Pending";
|
||||
if (raw === "rejected") return "Rejected";
|
||||
return String(s);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1392,15 +1392,40 @@ function EmailDeliverySection() {
|
|||
{err && <p style={{ color: "var(--destructive)", fontSize: "var(--text-sm)", margin: 0 }}>{err}</p>}
|
||||
{msg && <p style={{ color: "#15803d", fontSize: "var(--text-sm)", margin: 0 }}>{msg}</p>}
|
||||
<ToggleRow value={enabled} onChange={setEnabled} label="Use custom SMTP" />
|
||||
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: 0, lineHeight: 1.45 }}>
|
||||
Microsoft 365 / Outlook: host <code>smtp.office365.com</code>, port <code>587</code>, SSL off.
|
||||
Username = full mailbox email. Authenticated SMTP must be enabled for that user; with MFA use an App Password.
|
||||
Save settings, then re-enter the password if login still fails.
|
||||
</p>
|
||||
<Field>
|
||||
<Label text="SMTP host" />
|
||||
<input style={IS} value={host} onChange={(e) => setHost(e.target.value)} placeholder="smtp.example.com" onFocus={focusIn} onBlur={focusOut} />
|
||||
<input style={IS} value={host} onChange={(e) => setHost(e.target.value)} placeholder="smtp.office365.com" onFocus={focusIn} onBlur={focusOut} />
|
||||
</Field>
|
||||
<Field>
|
||||
<Label text="Port" />
|
||||
<input style={IS} type="number" value={port} onChange={(e) => setPort(parseInt(e.target.value, 10) || 587)} onFocus={focusIn} onBlur={focusOut} />
|
||||
<input
|
||||
style={IS}
|
||||
type="number"
|
||||
value={port}
|
||||
onChange={(e) => {
|
||||
const next = parseInt(e.target.value, 10) || 587;
|
||||
setPort(next);
|
||||
// Match common provider defaults so SSL mode matches the port.
|
||||
if (next === 465) setSecure(true);
|
||||
else if (next === 587 || next === 25 || next === 2525) setSecure(false);
|
||||
}}
|
||||
onFocus={focusIn}
|
||||
onBlur={focusOut}
|
||||
/>
|
||||
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: "4px 0 0" }}>
|
||||
Use 587 with STARTTLS (SSL off), or 465 with SSL/TLS on. Mixing these causes “wrong version number” errors.
|
||||
</p>
|
||||
</Field>
|
||||
<ToggleRow value={secure} onChange={setSecure} label="Use SSL/TLS (e.g. port 465)" />
|
||||
<ToggleRow
|
||||
value={secure}
|
||||
onChange={setSecure}
|
||||
label="Use SSL/TLS (port 465 only — leave off for 587)"
|
||||
/>
|
||||
<Field>
|
||||
<Label text="Username" />
|
||||
<input style={IS} value={smtpUser} onChange={(e) => setSmtpUser(e.target.value)} onFocus={focusIn} onBlur={focusOut} />
|
||||
|
|
|
|||
|
|
@ -10,25 +10,14 @@ import {
|
|||
type Event as ApiEvent,
|
||||
type ParticipantListRecord,
|
||||
type DesignTemplate,
|
||||
type CampaignRecipients,
|
||||
} from "../../../services/api";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import { useAppNav } from "../../contexts/AppNavContext";
|
||||
import { RichEmailEditor } from "./RichEmailEditor";
|
||||
import { PanelShell } from "../panels/PanelShell";
|
||||
import { PanelShell, RadioGroup, SelectWrap, IS, SS, focusIn, focusOut } from "../panels/PanelShell";
|
||||
import { EMAIL_PLACEHOLDERS } from "./mergePlaceholders";
|
||||
|
||||
const IS: React.CSSProperties = {
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--radius-button)",
|
||||
background: "var(--input-background)",
|
||||
fontSize: "var(--text-sm)",
|
||||
fontFamily: "var(--font-noto-sans)",
|
||||
color: "var(--card-foreground)",
|
||||
outline: "none",
|
||||
boxSizing: "border-box",
|
||||
};
|
||||
import { PARTICIPANT_STATUS, type ParticipantStatus } from "../../../config/constants";
|
||||
|
||||
function eventLabel(c: MessagingCampaign): string {
|
||||
const e = c.event;
|
||||
|
|
@ -36,6 +25,36 @@ function eventLabel(c: MessagingCampaign): string {
|
|||
return "—";
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<ParticipantStatus, string> = {
|
||||
pending: "Pending",
|
||||
confirmed: "Confirmed",
|
||||
checked_in: "Checked In",
|
||||
cancelled: "Cancelled",
|
||||
rejected: "Rejected",
|
||||
};
|
||||
|
||||
type RecipientSource = "participant_list" | "participants";
|
||||
|
||||
function buildRecipients(
|
||||
source: RecipientSource,
|
||||
listId: string,
|
||||
status: "" | ParticipantStatus,
|
||||
): CampaignRecipients | { error: string } {
|
||||
if (source === "participant_list") {
|
||||
if (!listId) return { error: "Select a contact list." };
|
||||
return { type: "participant_list", participantList: listId };
|
||||
}
|
||||
if (!status) return { type: "all" };
|
||||
return { type: "filtered", filters: { status } };
|
||||
}
|
||||
|
||||
function readFilterStatus(filters: CampaignRecipients["filters"]): "" | ParticipantStatus {
|
||||
if (!filters || typeof filters !== "object") return "";
|
||||
const raw = (filters as Record<string, unknown>).status;
|
||||
if (typeof raw !== "string") return "";
|
||||
return (PARTICIPANT_STATUS as readonly string[]).includes(raw) ? (raw as ParticipantStatus) : "";
|
||||
}
|
||||
|
||||
export function MessagingEmailPage() {
|
||||
const { user } = useAuth();
|
||||
const { setActiveNav, setSettingsInitialSection } = useAppNav();
|
||||
|
|
@ -51,7 +70,9 @@ export function MessagingEmailPage() {
|
|||
|
||||
const [formEventId, setFormEventId] = useState("");
|
||||
const [formName, setFormName] = useState("");
|
||||
const [recipientSource, setRecipientSource] = useState<RecipientSource>("participant_list");
|
||||
const [formListId, setFormListId] = useState("");
|
||||
const [formStatus, setFormStatus] = useState<"" | ParticipantStatus>("");
|
||||
const [msgMode, setMsgMode] = useState<"manual" | "template">("template");
|
||||
const [formTemplateId, setFormTemplateId] = useState("");
|
||||
const [formSubject, setFormSubject] = useState("");
|
||||
|
|
@ -98,7 +119,9 @@ export function MessagingEmailPage() {
|
|||
setEditing(null);
|
||||
setFormEventId(events[0]?._id || "");
|
||||
setFormName("");
|
||||
setRecipientSource("participant_list");
|
||||
setFormListId(lists[0]?._id || "");
|
||||
setFormStatus("");
|
||||
setMsgMode("template");
|
||||
setFormTemplateId(templates[0]?._id || "");
|
||||
setFormSubject("");
|
||||
|
|
@ -114,7 +137,16 @@ export function MessagingEmailPage() {
|
|||
const eid = typeof c.event === "string" ? c.event : c.event?._id;
|
||||
setFormEventId(eid || "");
|
||||
setFormName(c.name);
|
||||
setFormListId(c.recipients?.participantList || "");
|
||||
const recType = c.recipients?.type;
|
||||
if (recType === "all" || recType === "filtered") {
|
||||
setRecipientSource("participants");
|
||||
setFormListId(lists[0]?._id || "");
|
||||
setFormStatus(recType === "filtered" ? readFilterStatus(c.recipients?.filters) : "");
|
||||
} else {
|
||||
setRecipientSource("participant_list");
|
||||
setFormListId(c.recipients?.participantList || lists[0]?._id || "");
|
||||
setFormStatus("");
|
||||
}
|
||||
const hasTpl = Boolean(c.content?.template);
|
||||
setMsgMode(hasTpl ? "template" : "manual");
|
||||
setFormTemplateId(c.content?.template || "");
|
||||
|
|
@ -125,15 +157,22 @@ export function MessagingEmailPage() {
|
|||
setPanelOpen(true);
|
||||
};
|
||||
|
||||
const resolveRecipients = (): CampaignRecipients | null => {
|
||||
const built = buildRecipients(recipientSource, formListId, formStatus);
|
||||
if ("error" in built) {
|
||||
setErr(built.error);
|
||||
return null;
|
||||
}
|
||||
return built;
|
||||
};
|
||||
|
||||
const saveDraft = async (): Promise<string | null> => {
|
||||
if (!formEventId || !formName.trim()) {
|
||||
setErr("Event and campaign name are required.");
|
||||
return null;
|
||||
}
|
||||
if (!formListId) {
|
||||
setErr("Select a contact list.");
|
||||
return null;
|
||||
}
|
||||
const recipients = resolveRecipients();
|
||||
if (!recipients) return null;
|
||||
setSaving(true);
|
||||
setErr("");
|
||||
if (msgMode === "template" && !formTemplateId) {
|
||||
|
|
@ -149,7 +188,6 @@ export function MessagingEmailPage() {
|
|||
return null;
|
||||
}
|
||||
}
|
||||
const recipients = { type: "participant_list" as const, participantList: formListId };
|
||||
const content =
|
||||
msgMode === "template"
|
||||
? { subject: "", body: "", template: formTemplateId }
|
||||
|
|
@ -196,10 +234,8 @@ export function MessagingEmailPage() {
|
|||
setErr("Event and campaign name are required.");
|
||||
return;
|
||||
}
|
||||
if (!formListId) {
|
||||
setErr("Select a contact list.");
|
||||
return;
|
||||
}
|
||||
const recipients = resolveRecipients();
|
||||
if (!recipients) return;
|
||||
if (msgMode === "template" && !formTemplateId) {
|
||||
setErr("Select an emailer template.");
|
||||
return;
|
||||
|
|
@ -213,7 +249,6 @@ export function MessagingEmailPage() {
|
|||
}
|
||||
setSaving(true);
|
||||
setErr("");
|
||||
const recipients = { type: "participant_list" as const, participantList: formListId };
|
||||
const content =
|
||||
msgMode === "template"
|
||||
? { subject: "", body: "", template: formTemplateId }
|
||||
|
|
@ -466,31 +501,92 @@ export function MessagingEmailPage() {
|
|||
{!editing && (
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<span style={{ fontSize: "var(--text-sm)", fontWeight: 500 }}>Event</span>
|
||||
<select style={IS} value={formEventId} onChange={(e) => setFormEventId(e.target.value)}>
|
||||
<option value="">Select…</option>
|
||||
{events.map((ev) => (
|
||||
<option key={ev._id} value={ev._id}>
|
||||
{ev.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectWrap>
|
||||
<select
|
||||
style={SS}
|
||||
value={formEventId}
|
||||
onChange={(e) => setFormEventId(e.target.value)}
|
||||
onFocus={focusIn}
|
||||
onBlur={focusOut}
|
||||
>
|
||||
<option value="">Select…</option>
|
||||
{events.map((ev) => (
|
||||
<option key={ev._id} value={ev._id}>
|
||||
{ev.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</SelectWrap>
|
||||
</label>
|
||||
)}
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<span style={{ fontSize: "var(--text-sm)", fontWeight: 500 }}>Campaign name</span>
|
||||
<input style={IS} value={formName} onChange={(e) => setFormName(e.target.value)} placeholder="e.g. Registration reminder" />
|
||||
</label>
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<span style={{ fontSize: "var(--text-sm)", fontWeight: 500 }}>Contact list</span>
|
||||
<select style={IS} value={formListId} onChange={(e) => setFormListId(e.target.value)}>
|
||||
<option value="">Select…</option>
|
||||
{lists.map((l) => (
|
||||
<option key={l._id} value={l._id}>
|
||||
{l.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
style={IS}
|
||||
value={formName}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
onFocus={focusIn}
|
||||
onBlur={focusOut}
|
||||
placeholder="e.g. Registration reminder"
|
||||
/>
|
||||
</label>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<span style={{ fontSize: "var(--text-sm)", fontWeight: 500 }}>Recipients</span>
|
||||
<RadioGroup
|
||||
name="messaging-email-campaign-recipients"
|
||||
value={recipientSource}
|
||||
onChange={(v) => setRecipientSource(v as RecipientSource)}
|
||||
options={[
|
||||
{ label: "Contact list", value: "participant_list" },
|
||||
{ label: "All participants", value: "participants" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{recipientSource === "participant_list" ? (
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<span style={{ fontSize: "var(--text-sm)", fontWeight: 500 }}>Contact list</span>
|
||||
<SelectWrap>
|
||||
<select
|
||||
style={SS}
|
||||
value={formListId}
|
||||
onChange={(e) => setFormListId(e.target.value)}
|
||||
onFocus={focusIn}
|
||||
onBlur={focusOut}
|
||||
>
|
||||
<option value="">Select…</option>
|
||||
{lists.map((l) => (
|
||||
<option key={l._id} value={l._id}>
|
||||
{l.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</SelectWrap>
|
||||
</label>
|
||||
) : (
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<span style={{ fontSize: "var(--text-sm)", fontWeight: 500 }}>Participant status</span>
|
||||
<SelectWrap>
|
||||
<select
|
||||
style={SS}
|
||||
value={formStatus}
|
||||
onChange={(e) => setFormStatus(e.target.value as "" | ParticipantStatus)}
|
||||
onFocus={focusIn}
|
||||
onBlur={focusOut}
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
{PARTICIPANT_STATUS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{STATUS_LABELS[s]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</SelectWrap>
|
||||
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: 0 }}>
|
||||
Sends to participants registered for the selected event
|
||||
{formStatus ? ` with status ${STATUS_LABELS[formStatus]}` : ""}.
|
||||
</p>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button type="button" onClick={() => setMsgMode("template")} style={msgMode === "template" ? btnPrimary : btnSecondary}>
|
||||
|
|
@ -504,14 +600,22 @@ export function MessagingEmailPage() {
|
|||
{msgMode === "template" && (
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<span style={{ fontSize: "var(--text-sm)", fontWeight: 500 }}>Emailer template</span>
|
||||
<select style={IS} value={formTemplateId} onChange={(e) => setFormTemplateId(e.target.value)}>
|
||||
<option value="">Select…</option>
|
||||
{templates.map((t) => (
|
||||
<option key={t._id} value={t._id}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectWrap>
|
||||
<select
|
||||
style={SS}
|
||||
value={formTemplateId}
|
||||
onChange={(e) => setFormTemplateId(e.target.value)}
|
||||
onFocus={focusIn}
|
||||
onBlur={focusOut}
|
||||
>
|
||||
<option value="">Select…</option>
|
||||
{templates.map((t) => (
|
||||
<option key={t._id} value={t._id}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</SelectWrap>
|
||||
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: 0 }}>
|
||||
Subject and body come from this template. The email subject defaults to your campaign name if the template does not set one.
|
||||
</p>
|
||||
|
|
@ -522,7 +626,14 @@ export function MessagingEmailPage() {
|
|||
<>
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<span style={{ fontSize: "var(--text-sm)", fontWeight: 500 }}>Subject</span>
|
||||
<input style={IS} value={formSubject} onChange={(e) => setFormSubject(e.target.value)} placeholder="Subject line (placeholders ok)" />
|
||||
<input
|
||||
style={IS}
|
||||
value={formSubject}
|
||||
onChange={(e) => setFormSubject(e.target.value)}
|
||||
onFocus={focusIn}
|
||||
onBlur={focusOut}
|
||||
placeholder="Subject line (placeholders ok)"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
|
|
@ -541,7 +652,14 @@ export function MessagingEmailPage() {
|
|||
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<span style={{ fontSize: "var(--text-sm)", fontWeight: 500 }}>Schedule (optional)</span>
|
||||
<input type="datetime-local" style={IS} value={scheduleLocal} onChange={(e) => setScheduleLocal(e.target.value)} />
|
||||
<input
|
||||
type="datetime-local"
|
||||
style={IS}
|
||||
value={scheduleLocal}
|
||||
onChange={(e) => setScheduleLocal(e.target.value)}
|
||||
onFocus={focusIn}
|
||||
onBlur={focusOut}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: 8 }}>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export const EMAIL_PLACEHOLDERS: { token: string; label: string }[] = [
|
|||
{ token: "{{event.endDate}}", label: "Event end" },
|
||||
{ token: "{{event.venue}}", label: "Venue" },
|
||||
{ token: "{{event.virtualLink}}", label: "Virtual link" },
|
||||
{ token: "{{event.logoUrl}}", label: "Event logo URL" },
|
||||
];
|
||||
|
||||
/** Dropdown options: map each Meta `{{n}}` to a contact/event field for sending (samples still required for Meta review). */
|
||||
|
|
|
|||
|
|
@ -9,16 +9,27 @@ import {
|
|||
HelperText,
|
||||
} from '../panels/PanelShell';
|
||||
import {
|
||||
eventsApi,
|
||||
formsApi,
|
||||
sessionsApi,
|
||||
staticMicrositeApi,
|
||||
staticMicrositePublicUrl,
|
||||
type Event,
|
||||
type EventStaticMicrosite,
|
||||
type FormWithSubmissionCount,
|
||||
type MicrositeV2Template,
|
||||
type MicrositeV2TemplateSchema,
|
||||
type StaticMicrositeConfig,
|
||||
} from '../../../services/api';
|
||||
import { TemplatePicker } from './TemplatePicker';
|
||||
import { MicrositeConfigForm } from './MicrositeConfigForm';
|
||||
import { mergeConfig } from './configUtils';
|
||||
import {
|
||||
applyEventDetailsToMicrositeConfig,
|
||||
buildEventFeedbackUrl,
|
||||
buildEventRegistrationUrl,
|
||||
findContactFormId,
|
||||
} from './eventMicrositeAutofill';
|
||||
import { mergeScheduleDays, type ScheduleDay } from './scheduleSync';
|
||||
|
||||
const SCHEDULE_SECTION_ID = 'event_schedule';
|
||||
|
|
@ -53,19 +64,40 @@ export function ManageStaticMicrositePanel({
|
|||
const [scheduleSyncing, setScheduleSyncing] = useState(false);
|
||||
const scheduleAutoFetchedRef = useRef(false);
|
||||
const configureOpenedRef = useRef(false);
|
||||
const [eventDetails, setEventDetails] = useState<Event | null>(null);
|
||||
const [eventForms, setEventForms] = useState<FormWithSubmissionCount[]>([]);
|
||||
|
||||
const withEventAutofill = useCallback(
|
||||
(cfg: StaticMicrositeConfig, sch: MicrositeV2TemplateSchema, event: Event | null, forms: FormWithSubmissionCount[]) => {
|
||||
if (!event) return cfg;
|
||||
return applyEventDetailsToMicrositeConfig(cfg, event, sch, {
|
||||
registrationUrl: buildEventRegistrationUrl(event, forms),
|
||||
feedbackUrl: buildEventFeedbackUrl(event, forms),
|
||||
contactFormId: findContactFormId(forms),
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const [tplRes, siteRes] = await Promise.all([
|
||||
const [tplRes, siteRes, eventRes, formsRes] = await Promise.all([
|
||||
staticMicrositeApi.listTemplates(eventId),
|
||||
staticMicrositeApi.getForEvent(eventId),
|
||||
eventsApi.get(eventId),
|
||||
formsApi.listForEvent(eventId).catch(() => [] as FormWithSubmissionCount[]),
|
||||
]);
|
||||
|
||||
if (tplRes.error) throw new Error(tplRes.error);
|
||||
setTemplates(tplRes.data || []);
|
||||
|
||||
const event = !eventRes.error && eventRes.data ? eventRes.data : null;
|
||||
const forms = formsRes || [];
|
||||
setEventDetails(event);
|
||||
setEventForms(forms);
|
||||
|
||||
if (!siteRes.error && siteRes.data) {
|
||||
const site = siteRes.data;
|
||||
setExisting(site);
|
||||
|
|
@ -86,7 +118,7 @@ export function ManageStaticMicrositePanel({
|
|||
const sch = detail.data?.schema;
|
||||
if (!sch) throw new Error('Template configuration schema could not be loaded');
|
||||
setSchema(sch);
|
||||
setConfig(mergeConfig(sch, site.config));
|
||||
setConfig(withEventAutofill(mergeConfig(sch, site.config), sch, event, forms));
|
||||
} finally {
|
||||
setSchemaLoading(false);
|
||||
}
|
||||
|
|
@ -97,7 +129,7 @@ export function ManageStaticMicrositePanel({
|
|||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [eventId]);
|
||||
}, [eventId, withEventAutofill]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
|
|
@ -138,7 +170,7 @@ export function ManageStaticMicrositePanel({
|
|||
const sch = res.data?.schema;
|
||||
if (!sch) throw new Error('Template configuration schema could not be loaded');
|
||||
setSchema(sch);
|
||||
setConfig(mergeConfig(sch, config));
|
||||
setConfig(withEventAutofill(mergeConfig(sch, config), sch, eventDetails, eventForms));
|
||||
acc.advance(2);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to load template configuration');
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import React, { useRef, useState } from 'react';
|
||||
import { ChevronDown, Plus, RefreshCw, Trash2 } from 'lucide-react';
|
||||
|
||||
const SCHEDULE_SECTION_ID = 'event_schedule';
|
||||
import { uploadMediaFile } from '../../../services/api/media';
|
||||
import type { MicrositeV2FieldSchema, MicrositeV2SectionSchema, StaticMicrositeConfig } from '../../../services/api/types';
|
||||
import { Field, Label, HelperText } from '../panels/PanelShell';
|
||||
import { FlatpickrDateInput } from '../ui/FlatpickrDatetimeInput';
|
||||
import { getNestedValue, setNestedValue } from './configUtils';
|
||||
|
||||
const SCHEDULE_SECTION_ID = 'event_schedule';
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
padding: '8px 10px',
|
||||
|
|
@ -18,16 +19,154 @@ const inputStyle: React.CSSProperties = {
|
|||
boxSizing: 'border-box',
|
||||
};
|
||||
|
||||
const mediaActionsStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '8px',
|
||||
alignItems: 'center',
|
||||
};
|
||||
|
||||
function MediaUploadActions({
|
||||
uploading,
|
||||
hasValue,
|
||||
uploadLabel,
|
||||
changeLabel,
|
||||
onPickFile,
|
||||
onRemove,
|
||||
}: {
|
||||
uploading: boolean;
|
||||
hasValue: boolean;
|
||||
uploadLabel: string;
|
||||
changeLabel: string;
|
||||
onPickFile: () => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div style={mediaActionsStyle}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={uploading}
|
||||
onClick={onPickFile}
|
||||
style={{
|
||||
...inputStyle,
|
||||
cursor: uploading ? 'wait' : 'pointer',
|
||||
width: 'auto',
|
||||
}}
|
||||
>
|
||||
{uploading ? 'Uploading…' : hasValue ? changeLabel : uploadLabel}
|
||||
</button>
|
||||
{hasValue ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={uploading}
|
||||
onClick={onRemove}
|
||||
style={{
|
||||
...inputStyle,
|
||||
cursor: uploading ? 'wait' : 'pointer',
|
||||
width: 'auto',
|
||||
color: 'var(--destructive)',
|
||||
borderColor: 'color-mix(in srgb, var(--destructive) 35%, var(--border))',
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveFieldType(field: MicrositeV2FieldSchema): MicrositeV2FieldType {
|
||||
if (field.type === 'date' || field.type === 'time') return field.type;
|
||||
if (field.key === 'countdown.date') return 'date';
|
||||
if (field.key === 'countdown.time') return 'time';
|
||||
return field.type;
|
||||
}
|
||||
|
||||
function toDateInputValue(value: unknown): string {
|
||||
const raw = String(value ?? '').trim();
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return raw;
|
||||
const parsed = Date.parse(raw);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
const d = new Date(parsed);
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function parseTimeValue(value: unknown): { hour: string; minute: string } {
|
||||
const raw = String(value ?? '12:00').trim();
|
||||
const match = raw.match(/^(\d{1,2}):(\d{2})/);
|
||||
if (!match) return { hour: '12', minute: '00' };
|
||||
const hourNum = Math.min(23, Math.max(0, parseInt(match[1], 10) || 0));
|
||||
const minuteNum = Math.min(59, Math.max(0, parseInt(match[2], 10) || 0));
|
||||
return {
|
||||
hour: String(hourNum).padStart(2, '0'),
|
||||
minute: String(minuteNum).padStart(2, '0'),
|
||||
};
|
||||
}
|
||||
|
||||
function TimeSelect({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: unknown;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
const { hour, minute } = parseTimeValue(value);
|
||||
const hours = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'));
|
||||
const minutes = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0'));
|
||||
const selectStyle: React.CSSProperties = { ...inputStyle, width: 'auto', minWidth: '72px' };
|
||||
|
||||
const setTime = (nextHour: string, nextMinute: string) => {
|
||||
onChange(`${nextHour}:${nextMinute}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center' }}>
|
||||
<select
|
||||
value={hour}
|
||||
onChange={(e) => setTime(e.target.value, minute)}
|
||||
style={selectStyle}
|
||||
aria-label="Hour"
|
||||
>
|
||||
{hours.map((h) => (
|
||||
<option key={h} value={h}>
|
||||
{h}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span style={{ color: 'var(--muted)', fontWeight: 600 }}>:</span>
|
||||
<select
|
||||
value={minute}
|
||||
onChange={(e) => setTime(hour, e.target.value)}
|
||||
style={selectStyle}
|
||||
aria-label="Minute"
|
||||
>
|
||||
{minutes.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{m}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldInput({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
eventId,
|
||||
arrayDepth = 0,
|
||||
}: {
|
||||
field: MicrositeV2FieldSchema;
|
||||
value: unknown;
|
||||
onChange: (v: unknown) => void;
|
||||
eventId: string;
|
||||
arrayDepth?: number;
|
||||
}) {
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = React.useState(false);
|
||||
|
|
@ -44,7 +183,7 @@ function FieldInput({
|
|||
}
|
||||
};
|
||||
|
||||
switch (field.type) {
|
||||
switch (resolveFieldType(field)) {
|
||||
case 'boolean':
|
||||
return (
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: 'var(--text-sm)' }}>
|
||||
|
|
@ -86,6 +225,17 @@ function FieldInput({
|
|||
))}
|
||||
</select>
|
||||
);
|
||||
case 'date':
|
||||
return (
|
||||
<FlatpickrDateInput
|
||||
value={toDateInputValue(value)}
|
||||
onChange={(v) => onChange(v)}
|
||||
style={inputStyle}
|
||||
placeholder={field.placeholder || 'Select date…'}
|
||||
/>
|
||||
);
|
||||
case 'time':
|
||||
return <TimeSelect value={value} onChange={onChange} />;
|
||||
case 'image':
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
|
|
@ -101,19 +251,14 @@ function FieldInput({
|
|||
if (f) void handleUpload(f);
|
||||
e.target.value = '';
|
||||
}} />
|
||||
<button
|
||||
type="button"
|
||||
disabled={uploading}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
style={{
|
||||
...inputStyle,
|
||||
cursor: uploading ? 'wait' : 'pointer',
|
||||
width: 'auto',
|
||||
alignSelf: 'flex-start',
|
||||
}}
|
||||
>
|
||||
{uploading ? 'Uploading…' : value ? 'Change image' : 'Upload image'}
|
||||
</button>
|
||||
<MediaUploadActions
|
||||
uploading={uploading}
|
||||
hasValue={Boolean(value)}
|
||||
uploadLabel="Upload image"
|
||||
changeLabel="Change image"
|
||||
onPickFile={() => fileRef.current?.click()}
|
||||
onRemove={() => onChange('')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case 'video':
|
||||
|
|
@ -131,19 +276,14 @@ function FieldInput({
|
|||
if (f) void handleUpload(f);
|
||||
e.target.value = '';
|
||||
}} />
|
||||
<button
|
||||
type="button"
|
||||
disabled={uploading}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
style={{
|
||||
...inputStyle,
|
||||
cursor: uploading ? 'wait' : 'pointer',
|
||||
width: 'auto',
|
||||
alignSelf: 'flex-start',
|
||||
}}
|
||||
>
|
||||
{uploading ? 'Uploading…' : value ? 'Change video' : 'Upload video'}
|
||||
</button>
|
||||
<MediaUploadActions
|
||||
uploading={uploading}
|
||||
hasValue={Boolean(value)}
|
||||
uploadLabel="Upload video"
|
||||
changeLabel="Change video"
|
||||
onPickFile={() => fileRef.current?.click()}
|
||||
onRemove={() => onChange('')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case 'array':
|
||||
|
|
@ -153,6 +293,7 @@ function FieldInput({
|
|||
value={Array.isArray(value) ? value : []}
|
||||
onChange={onChange}
|
||||
eventId={eventId}
|
||||
depth={arrayDepth}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
|
|
@ -173,18 +314,32 @@ function ArrayFieldEditor({
|
|||
value,
|
||||
onChange,
|
||||
eventId,
|
||||
depth = 0,
|
||||
}: {
|
||||
field: MicrositeV2FieldSchema;
|
||||
value: Record<string, unknown>[];
|
||||
onChange: (v: Record<string, unknown>[]) => void;
|
||||
eventId: string;
|
||||
depth?: number;
|
||||
}) {
|
||||
const itemFields = field.itemFields || [];
|
||||
const isTopicsList = field.key === 'topics';
|
||||
const itemLabel = isTopicsList ? 'Topic' : field.label || 'item';
|
||||
const nestedStyle: React.CSSProperties = isTopicsList
|
||||
? {
|
||||
borderLeft: '3px solid color-mix(in srgb, var(--primary) 35%, var(--border))',
|
||||
paddingLeft: '12px',
|
||||
marginTop: '4px',
|
||||
}
|
||||
: {};
|
||||
|
||||
const addRow = () => {
|
||||
const row: Record<string, unknown> = {};
|
||||
for (const f of itemFields) {
|
||||
if (f.key) row[f.key] = f.default ?? '';
|
||||
if (f.key) {
|
||||
if (f.type === 'array') row[f.key] = [];
|
||||
else row[f.key] = f.default ?? '';
|
||||
}
|
||||
}
|
||||
onChange([...value, row]);
|
||||
};
|
||||
|
|
@ -199,7 +354,7 @@ function ArrayFieldEditor({
|
|||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', ...nestedStyle }}>
|
||||
{value.map((row, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
|
|
@ -213,7 +368,9 @@ function ArrayFieldEditor({
|
|||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--muted)' }}>Item {idx + 1}</span>
|
||||
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--muted)' }}>
|
||||
{itemLabel} {idx + 1}
|
||||
</span>
|
||||
<button type="button" onClick={() => removeRow(idx)} style={{ border: 'none', background: 'none', cursor: 'pointer', color: 'var(--destructive)' }}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
|
|
@ -226,6 +383,7 @@ function ArrayFieldEditor({
|
|||
value={row[f.key]}
|
||||
onChange={(v) => updateRow(idx, f.key, v)}
|
||||
eventId={eventId}
|
||||
arrayDepth={depth + 1}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -249,7 +407,7 @@ function ArrayFieldEditor({
|
|||
}}
|
||||
>
|
||||
<Plus size={14} />
|
||||
Add {field.label || 'item'}
|
||||
Add {isTopicsList ? 'Session Topic' : field.label || 'item'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
type: string;
|
||||
title: string;
|
||||
/** Session name for block rows with topics */
|
||||
name?: string;
|
||||
/** Nested session topics */
|
||||
topics?: ScheduleTopic[];
|
||||
/** Agenda session _id for synced rows; absent for manually added rows. */
|
||||
sourceId?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ScheduleTopic {
|
||||
type?: string;
|
||||
duration?: string;
|
||||
title?: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export interface ScheduleDay {
|
||||
label: string;
|
||||
date: string;
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
/**
|
||||
* Live badge preview canvas — draggable elements, resize handles, sample data.
|
||||
*/
|
||||
import React, { useCallback, useEffect, useRef } from "react";
|
||||
import React, { useCallback, useEffect, useRef, type RefObject } from "react";
|
||||
import QRCode from "qrcode";
|
||||
import { ImageIcon } from "lucide-react";
|
||||
import type { BadgeLayoutElement } from "./badgeLayout";
|
||||
import type { BadgeBackgroundImageLayout, BadgeLayoutElement } from "./badgeLayout";
|
||||
import {
|
||||
BADGE_BACKGROUND_IMAGE_ID,
|
||||
applyBackgroundImageLayoutPatch,
|
||||
applyElementSize,
|
||||
clampElementPosition,
|
||||
elementSizePct,
|
||||
|
|
@ -39,11 +41,27 @@ type ResizeState = {
|
|||
origW: number;
|
||||
};
|
||||
|
||||
type BgDragState = {
|
||||
startX: number;
|
||||
startY: number;
|
||||
origX: number;
|
||||
origY: number;
|
||||
};
|
||||
|
||||
type BgResizeState = {
|
||||
startX: number;
|
||||
startY: number;
|
||||
origX: number;
|
||||
origY: number;
|
||||
origW: number;
|
||||
origH: number;
|
||||
};
|
||||
|
||||
export function BadgeDesignerPreview({
|
||||
aspectRatio,
|
||||
backgroundMode,
|
||||
backgroundColor,
|
||||
backgroundImageUrl,
|
||||
backgroundImageLayout,
|
||||
layoutElements,
|
||||
customImages,
|
||||
badgeTypeLabel,
|
||||
|
|
@ -51,31 +69,44 @@ export function BadgeDesignerPreview({
|
|||
selectedId,
|
||||
onSelect,
|
||||
onLayoutChange,
|
||||
onBackgroundImageLayoutChange,
|
||||
surfaceRef,
|
||||
}: {
|
||||
aspectRatio: number;
|
||||
badgeAspectRatio: number;
|
||||
backgroundMode: "image" | "color";
|
||||
backgroundColor: string;
|
||||
backgroundImageUrl: string;
|
||||
backgroundImageLayout: BadgeBackgroundImageLayout;
|
||||
layoutElements: BadgeLayoutElement[];
|
||||
customImages: { id: string; imageUrl: string }[];
|
||||
badgeTypeLabel: string;
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string | null) => void;
|
||||
onLayoutChange: (next: BadgeLayoutElement[]) => void;
|
||||
onBackgroundImageLayoutChange: (next: BadgeBackgroundImageLayout) => void;
|
||||
/** Canvas + bg-image handles — clicks outside this (and the inspector) dismiss selection */
|
||||
surfaceRef?: RefObject<HTMLDivElement | null>;
|
||||
}) {
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const layoutRef = useRef(layoutElements);
|
||||
const bgLayoutRef = useRef(backgroundImageLayout);
|
||||
const onLayoutChangeRef = useRef(onLayoutChange);
|
||||
const onBgLayoutChangeRef = useRef(onBackgroundImageLayoutChange);
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
const resizeRef = useRef<ResizeState | null>(null);
|
||||
const bgDragRef = useRef<BgDragState | null>(null);
|
||||
const bgResizeRef = useRef<BgResizeState | null>(null);
|
||||
const qrUrlRef = useRef("");
|
||||
const [, setTick] = React.useState(0);
|
||||
|
||||
layoutRef.current = layoutElements;
|
||||
bgLayoutRef.current = backgroundImageLayout;
|
||||
onLayoutChangeRef.current = onLayoutChange;
|
||||
onBgLayoutChangeRef.current = onBackgroundImageLayoutChange;
|
||||
|
||||
const sample = samplePreviewData(badgeTypeLabel);
|
||||
const hasBgImage = Boolean(backgroundImageUrl.trim());
|
||||
const bgSelected = selectedId === BADGE_BACKGROUND_IMAGE_ID;
|
||||
|
||||
useEffect(() => {
|
||||
QRCode.toDataURL("eventbuzz:preview:sample", { width: 120, margin: 1 })
|
||||
|
|
@ -99,6 +130,36 @@ export function BadgeDesignerPreview({
|
|||
const box = canvasRef.current?.getBoundingClientRect();
|
||||
if (!box?.width || !box.height) return;
|
||||
|
||||
const bgDrag = bgDragRef.current;
|
||||
if (bgDrag) {
|
||||
e.preventDefault();
|
||||
const dx = ((e.clientX - bgDrag.startX) / box.width) * 100;
|
||||
const dy = ((e.clientY - bgDrag.startY) / box.height) * 100;
|
||||
const next = applyBackgroundImageLayoutPatch(bgLayoutRef.current, {
|
||||
x: bgDrag.origX + dx,
|
||||
y: bgDrag.origY + dy,
|
||||
});
|
||||
bgLayoutRef.current = next;
|
||||
onBgLayoutChangeRef.current(next);
|
||||
return;
|
||||
}
|
||||
|
||||
const bgResize = bgResizeRef.current;
|
||||
if (bgResize) {
|
||||
e.preventDefault();
|
||||
const dx = ((e.clientX - bgResize.startX) / box.width) * 100;
|
||||
const dy = ((e.clientY - bgResize.startY) / box.height) * 100;
|
||||
const next = applyBackgroundImageLayoutPatch(bgLayoutRef.current, {
|
||||
x: bgResize.origX,
|
||||
y: bgResize.origY,
|
||||
w: bgResize.origW + dx,
|
||||
h: bgResize.origH + dy,
|
||||
});
|
||||
bgLayoutRef.current = next;
|
||||
onBgLayoutChangeRef.current(next);
|
||||
return;
|
||||
}
|
||||
|
||||
const drag = dragRef.current;
|
||||
if (drag) {
|
||||
const el = layoutRef.current.find((x) => x.id === drag.id);
|
||||
|
|
@ -106,7 +167,7 @@ export function BadgeDesignerPreview({
|
|||
e.preventDefault();
|
||||
const dx = ((e.clientX - drag.startX) / box.width) * 100;
|
||||
const dy = ((e.clientY - drag.startY) / box.height) * 100;
|
||||
const pos = clampElementPosition(el, drag.origX + dx, drag.origY + dy);
|
||||
const pos = clampElementPosition(el, drag.origX + dx, drag.origY + dy, badgeAspectRatio);
|
||||
patchElement(drag.id, pos);
|
||||
return;
|
||||
}
|
||||
|
|
@ -126,6 +187,8 @@ export function BadgeDesignerPreview({
|
|||
const end = () => {
|
||||
dragRef.current = null;
|
||||
resizeRef.current = null;
|
||||
bgDragRef.current = null;
|
||||
bgResizeRef.current = null;
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", onMove, { passive: false });
|
||||
|
|
@ -136,10 +199,10 @@ export function BadgeDesignerPreview({
|
|||
window.removeEventListener("pointerup", end);
|
||||
window.removeEventListener("pointercancel", end);
|
||||
};
|
||||
}, [patchElement]);
|
||||
}, [patchElement, badgeAspectRatio]);
|
||||
|
||||
const startDrag = (e: React.PointerEvent, el: BadgeLayoutElement) => {
|
||||
if (resizeRef.current) return;
|
||||
if (resizeRef.current || bgDragRef.current || bgResizeRef.current) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onSelect(el.id);
|
||||
|
|
@ -165,52 +228,151 @@ export function BadgeDesignerPreview({
|
|||
};
|
||||
};
|
||||
|
||||
const bgStyle: React.CSSProperties =
|
||||
backgroundMode === "image" && backgroundImageUrl.trim()
|
||||
? {
|
||||
backgroundImage: `url(${backgroundImageUrl})`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
}
|
||||
: { backgroundColor };
|
||||
const startBgDrag = (e: React.PointerEvent) => {
|
||||
if (bgResizeRef.current || resizeRef.current) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onSelect(BADGE_BACKGROUND_IMAGE_ID);
|
||||
bgDragRef.current = {
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
origX: backgroundImageLayout.x,
|
||||
origY: backgroundImageLayout.y,
|
||||
};
|
||||
};
|
||||
|
||||
const startBgResize = (e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onSelect(BADGE_BACKGROUND_IMAGE_ID);
|
||||
bgDragRef.current = null;
|
||||
bgResizeRef.current = {
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
origX: backgroundImageLayout.x,
|
||||
origY: backgroundImageLayout.y,
|
||||
origW: backgroundImageLayout.w,
|
||||
origH: backgroundImageLayout.h,
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: "8px 12px 16px 20px" }}>
|
||||
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", margin: "0 0 10px" }}>
|
||||
Live preview — drag to move; drag corner handle to resize
|
||||
Live preview — drag fields to move; drag corner handles to resize
|
||||
{hasBgImage
|
||||
? "; drag the background image to pan (it may extend outside the badge), resize to zoom"
|
||||
: ""}
|
||||
</p>
|
||||
<div
|
||||
ref={canvasRef}
|
||||
role="presentation"
|
||||
onClick={() => onSelect(null)}
|
||||
ref={surfaceRef}
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
maxWidth: 420,
|
||||
aspectRatio: String(aspectRatio),
|
||||
position: "relative",
|
||||
borderRadius: "var(--radius-button)",
|
||||
border: "1px solid var(--border)",
|
||||
boxShadow: "0 4px 20px rgba(0,0,0,0.12)",
|
||||
overflow: "hidden",
|
||||
containerType: "size",
|
||||
touchAction: "none",
|
||||
...bgStyle,
|
||||
cursor: "default",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{layoutElements.map((el) => (
|
||||
<PreviewElement
|
||||
key={el.id}
|
||||
el={el}
|
||||
sample={sample}
|
||||
customImages={customImages}
|
||||
selected={selectedId === el.id}
|
||||
qrUrl={qrUrlRef.current}
|
||||
onDragStart={startDrag}
|
||||
onResizeStart={startResize}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
ref={canvasRef}
|
||||
role="presentation"
|
||||
onClick={() => onSelect(null)}
|
||||
style={{
|
||||
width: "100%",
|
||||
aspectRatio: String(aspectRatio),
|
||||
position: "relative",
|
||||
borderRadius: "var(--radius-button)",
|
||||
border: "1px solid var(--border)",
|
||||
boxShadow: "0 4px 20px rgba(0,0,0,0.12)",
|
||||
overflow: "hidden",
|
||||
containerType: "size",
|
||||
touchAction: "none",
|
||||
backgroundColor,
|
||||
cursor: "default",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{hasBgImage ? (
|
||||
<div
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect(BADGE_BACKGROUND_IMAGE_ID);
|
||||
startBgDrag(e);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${backgroundImageLayout.x}%`,
|
||||
top: `${backgroundImageLayout.y}%`,
|
||||
width: `${backgroundImageLayout.w}%`,
|
||||
height: `${backgroundImageLayout.h}%`,
|
||||
boxSizing: "border-box",
|
||||
cursor: bgSelected ? "grab" : "pointer",
|
||||
touchAction: "none",
|
||||
zIndex: 0,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={backgroundImageUrl}
|
||||
alt=""
|
||||
draggable={false}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{layoutElements.map((el) => (
|
||||
<PreviewElement
|
||||
key={el.id}
|
||||
el={el}
|
||||
sample={sample}
|
||||
customImages={customImages}
|
||||
selected={selectedId === el.id}
|
||||
qrUrl={qrUrlRef.current}
|
||||
onDragStart={startDrag}
|
||||
onResizeStart={startResize}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{hasBgImage && bgSelected ? (
|
||||
<div
|
||||
role="presentation"
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${backgroundImageLayout.x}%`,
|
||||
top: `${backgroundImageLayout.y}%`,
|
||||
width: `${backgroundImageLayout.w}%`,
|
||||
height: `${backgroundImageLayout.h}%`,
|
||||
border: "2px solid var(--primary)",
|
||||
boxSizing: "border-box",
|
||||
pointerEvents: "none",
|
||||
zIndex: 6,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
role="presentation"
|
||||
onPointerDown={startBgResize}
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: -5,
|
||||
bottom: -5,
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: 3,
|
||||
background: "var(--primary)",
|
||||
border: "2px solid var(--card)",
|
||||
cursor: "nwse-resize",
|
||||
touchAction: "none",
|
||||
pointerEvents: "auto",
|
||||
zIndex: 2,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -252,6 +414,7 @@ function PreviewElement({
|
|||
cursor: "grab",
|
||||
boxSizing: "border-box",
|
||||
touchAction: "none",
|
||||
zIndex: 1,
|
||||
...extra,
|
||||
}}
|
||||
>
|
||||
|
|
@ -299,6 +462,7 @@ function PreviewElement({
|
|||
touchAction: "none",
|
||||
overflow: "hidden",
|
||||
background: "#fff",
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{url ? (
|
||||
|
|
@ -481,6 +645,7 @@ function TextPreviewBlock({
|
|||
boxSizing: "border-box",
|
||||
textAlign: align,
|
||||
touchAction: "none",
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ export function BadgeFieldInspector({
|
|||
customTextValue,
|
||||
customImages,
|
||||
badgeAspectRatio,
|
||||
mediaEventId = "portfolio",
|
||||
onChange,
|
||||
onRemoveCustom,
|
||||
onCustomImageUploaded,
|
||||
|
|
@ -53,6 +54,7 @@ export function BadgeFieldInspector({
|
|||
customTextValue?: string;
|
||||
customImages: { id: string; imageUrl: string }[];
|
||||
badgeAspectRatio: number;
|
||||
mediaEventId?: string;
|
||||
onChange: (patch: Partial<BadgeLayoutElement>) => void;
|
||||
onRemoveCustom?: () => void;
|
||||
onCustomImageUploaded?: (id: string, url: string, imageAspectRatio: number) => void;
|
||||
|
|
@ -76,6 +78,7 @@ export function BadgeFieldInspector({
|
|||
element={element}
|
||||
customImages={customImages}
|
||||
badgeAspectRatio={badgeAspectRatio}
|
||||
mediaEventId={mediaEventId}
|
||||
onChange={onChange}
|
||||
onRemoveCustom={onRemoveCustom}
|
||||
onCustomImageUploaded={onCustomImageUploaded}
|
||||
|
|
@ -224,6 +227,7 @@ function ResizableInspector({
|
|||
element,
|
||||
customImages,
|
||||
badgeAspectRatio,
|
||||
mediaEventId = "portfolio",
|
||||
onChange,
|
||||
onRemoveCustom,
|
||||
onCustomImageUploaded,
|
||||
|
|
@ -231,6 +235,7 @@ function ResizableInspector({
|
|||
element: BadgeLayoutElement;
|
||||
customImages: { id: string; imageUrl: string }[];
|
||||
badgeAspectRatio: number;
|
||||
mediaEventId?: string;
|
||||
onChange: (patch: Partial<BadgeLayoutElement>) => void;
|
||||
onRemoveCustom?: () => void;
|
||||
onCustomImageUploaded?: (id: string, url: string, imageAspectRatio: number) => void;
|
||||
|
|
@ -250,7 +255,7 @@ function ResizableInspector({
|
|||
setUploading(true);
|
||||
try {
|
||||
const imageAspectRatio = await readFileImageAspectRatio(file);
|
||||
const r = await uploadMediaFile(file, { eventId: "portfolio", module: "badges/fixed-images" });
|
||||
const r = await uploadMediaFile(file, { eventId: mediaEventId, module: "badges/fixed-images" });
|
||||
if (r.url) {
|
||||
const w = element.w ?? 25;
|
||||
const h = heightPctForImageWidth(w, imageAspectRatio, badgeAspectRatio);
|
||||
|
|
|
|||
|
|
@ -19,18 +19,21 @@ import {
|
|||
import type { Event as ApiEvent, ParticipantListRecord } from "../../../services/api";
|
||||
import { BadgeDesignerPreview, useBadgeAspectRatio } from "./BadgeDesignerPreview";
|
||||
import { BadgeFieldInspector } from "./BadgeFieldInspector";
|
||||
import type { BadgeLayoutElement } from "./badgeLayout";
|
||||
import type { BadgeBackgroundImageLayout, BadgeLayoutElement } from "./badgeLayout";
|
||||
import {
|
||||
BADGE_BACKGROUND_IMAGE_ID,
|
||||
MAX_CUSTOM_IMAGE_FIELDS,
|
||||
MAX_CUSTOM_TEXT_FIELDS,
|
||||
applyElementSize,
|
||||
buildCustomImageElement,
|
||||
clampElementPosition,
|
||||
defaultBackgroundImageLayout,
|
||||
defaultLayoutForFields,
|
||||
heightPctForImageWidth,
|
||||
newCustomImageId,
|
||||
newCustomTextId,
|
||||
readFileImageAspectRatio,
|
||||
squareElementHeightPct,
|
||||
syncLayoutWithFields,
|
||||
} from "./badgeLayout";
|
||||
import { formStateFromBadgeBatch } from "./badgeBatchFormState";
|
||||
|
|
@ -179,9 +182,11 @@ export function CreateBadgePanel({
|
|||
const [includeParticipants, setIncludeParticipants] = useState(true);
|
||||
const [participantScope, setParticipantScope] = useState<"all" | "participant_list">("all");
|
||||
const [participantListId, setParticipantListId] = useState("");
|
||||
const [backgroundMode, setBackgroundMode] = useState<"image" | "color">("color");
|
||||
const [backgroundColor, setBackgroundColor] = useState("#0B4DD1");
|
||||
const [backgroundImageUrl, setBackgroundImageUrl] = useState("");
|
||||
const [backgroundImageLayout, setBackgroundImageLayout] = useState<BadgeBackgroundImageLayout>(
|
||||
defaultBackgroundImageLayout,
|
||||
);
|
||||
const [backgroundPreviewUrl, setBackgroundPreviewUrl] = useState("");
|
||||
const [enabledFields, setEnabledFields] = useState<string[]>(["name", "title", "org", "qr"]);
|
||||
const [layoutElements, setLayoutElements] = useState<BadgeLayoutElement[]>(() =>
|
||||
|
|
@ -199,6 +204,9 @@ export function CreateBadgePanel({
|
|||
const [headerBusy, setHeaderBusy] = useState(false);
|
||||
const [hydrating, setHydrating] = useState(Boolean(duplicateFromBatchId));
|
||||
|
||||
const badgeSurfaceRef = useRef<HTMLDivElement>(null);
|
||||
const badgeInspectorRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const aspectRatio = useBadgeAspectRatio(sizePreset, orientation, customW, customH);
|
||||
const isDuplicate = Boolean(duplicateFromBatchId);
|
||||
const displayBgUrl = backgroundPreviewUrl || backgroundImageUrl;
|
||||
|
|
@ -208,6 +216,18 @@ export function CreateBadgePanel({
|
|||
[layoutElements, selectedElementId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedElementId) return;
|
||||
const dismissSelection = (event: PointerEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (badgeSurfaceRef.current?.contains(target)) return;
|
||||
if (badgeInspectorRef.current?.contains(target)) return;
|
||||
setSelectedElementId(null);
|
||||
};
|
||||
document.addEventListener("pointerdown", dismissSelection);
|
||||
return () => document.removeEventListener("pointerdown", dismissSelection);
|
||||
}, [selectedElementId]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (backgroundPreviewUrl.startsWith("blob:")) URL.revokeObjectURL(backgroundPreviewUrl);
|
||||
|
|
@ -220,14 +240,14 @@ export function CreateBadgePanel({
|
|||
let merged = { ...el };
|
||||
if (el.field === "qr") {
|
||||
const w = el.w ?? 28;
|
||||
merged = { ...merged, w, h: w };
|
||||
merged = { ...merged, w, h: squareElementHeightPct(w, aspectRatio) };
|
||||
} else if (el.field === "photo" && el.w != null) {
|
||||
merged = { ...merged, h: el.w };
|
||||
merged = { ...merged, h: squareElementHeightPct(el.w, aspectRatio) };
|
||||
} else if (el.kind === "custom_image" && el.imageAspectRatio) {
|
||||
const w = el.w ?? 25;
|
||||
merged = { ...merged, h: heightPctForImageWidth(w, el.imageAspectRatio, aspectRatio) };
|
||||
}
|
||||
return { ...merged, ...clampElementPosition(merged, merged.x, merged.y) };
|
||||
return { ...merged, ...clampElementPosition(merged, merged.x, merged.y, aspectRatio) };
|
||||
}),
|
||||
);
|
||||
}, [sizePreset, orientation, customW, customH, aspectRatio]);
|
||||
|
|
@ -258,9 +278,9 @@ export function CreateBadgePanel({
|
|||
setIncludeParticipants(s.includeParticipants);
|
||||
setParticipantScope(s.participantScope);
|
||||
setParticipantListId(s.participantListId);
|
||||
setBackgroundMode(s.backgroundMode);
|
||||
setBackgroundColor(s.backgroundColor);
|
||||
setBackgroundImageUrl(s.backgroundImageUrl);
|
||||
setBackgroundImageLayout(s.backgroundImageLayout);
|
||||
setBackgroundPreviewUrl("");
|
||||
setEnabledFields(s.enabledFields);
|
||||
setLayoutElements(s.layoutElements);
|
||||
|
|
@ -302,7 +322,7 @@ export function CreateBadgePanel({
|
|||
: [...enabledFields, id];
|
||||
setEnabledFields(next);
|
||||
setLayoutElements((prev) =>
|
||||
syncLayoutWithFields(prev, next, customTexts, customImages, backgroundColor, backgroundMode, aspectRatio),
|
||||
syncLayoutWithFields(prev, next, customTexts, customImages, backgroundColor, "color", aspectRatio),
|
||||
);
|
||||
if (!next.includes(id) && selectedElementId === id) setSelectedElementId(null);
|
||||
};
|
||||
|
|
@ -314,11 +334,13 @@ export function CreateBadgePanel({
|
|||
const nextCustom = [...customTexts, { id, text }];
|
||||
setCustomTexts(nextCustom);
|
||||
setLayoutElements((prev) =>
|
||||
syncLayoutWithFields(prev, enabledFields, nextCustom, customImages, backgroundColor, backgroundMode, aspectRatio),
|
||||
syncLayoutWithFields(prev, enabledFields, nextCustom, customImages, backgroundColor, "color", aspectRatio),
|
||||
);
|
||||
setSelectedElementId(id);
|
||||
};
|
||||
|
||||
const badgeMediaEventId = formEventId.trim() || "portfolio";
|
||||
|
||||
const addCustomImage = async (file: File) => {
|
||||
if (customImages.length >= MAX_CUSTOM_IMAGE_FIELDS) return;
|
||||
if (!file.type.startsWith("image/")) {
|
||||
|
|
@ -330,7 +352,7 @@ export function CreateBadgePanel({
|
|||
setErr("");
|
||||
try {
|
||||
const imageAspectRatio = await readFileImageAspectRatio(file);
|
||||
const r = await uploadMediaFile(file, { eventId: "portfolio", module: "badges/fixed-images" });
|
||||
const r = await uploadMediaFile(file, { eventId: badgeMediaEventId, module: "badges/fixed-images" });
|
||||
if (!r.url) throw new Error("Upload failed");
|
||||
const nextImages = [...customImages, { id, imageUrl: r.url }];
|
||||
setCustomImages(nextImages);
|
||||
|
|
@ -398,11 +420,13 @@ export function CreateBadgePanel({
|
|||
setBackgroundPreviewUrl(local);
|
||||
setHeaderBusy(true);
|
||||
setErr("");
|
||||
const hadBgImage = Boolean(backgroundImageUrl.trim());
|
||||
try {
|
||||
const r = await uploadMediaFile(file, { eventId: "portfolio", module: "badges/backgrounds" });
|
||||
const r = await uploadMediaFile(file, { eventId: badgeMediaEventId, module: "badges/backgrounds" });
|
||||
if (r.url) {
|
||||
setBackgroundImageUrl(r.url);
|
||||
setBackgroundMode("image");
|
||||
if (!hadBgImage) setBackgroundImageLayout(defaultBackgroundImageLayout());
|
||||
setSelectedElementId(BADGE_BACKGROUND_IMAGE_ID);
|
||||
}
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : "Upload failed");
|
||||
|
|
@ -434,10 +458,6 @@ export function CreateBadgePanel({
|
|||
setErr("Select a contact list.");
|
||||
return;
|
||||
}
|
||||
if (backgroundMode === "image" && !backgroundImageUrl.trim()) {
|
||||
setErr("Upload a background image or switch to solid colour.");
|
||||
return;
|
||||
}
|
||||
const hasCustom =
|
||||
layoutElements.some((el) => el.kind === "custom_text") ||
|
||||
layoutElements.some((el) => el.kind === "custom_image");
|
||||
|
|
@ -495,8 +515,9 @@ export function CreateBadgePanel({
|
|||
includeParticipants,
|
||||
participantScope,
|
||||
...(includeParticipants && participantScope === "participant_list" ? { participantListId } : {}),
|
||||
backgroundMode,
|
||||
backgroundImageUrl: backgroundMode === "image" ? backgroundImageUrl.trim() : "",
|
||||
backgroundMode: backgroundImageUrl.trim() ? "image" : "color",
|
||||
backgroundImageUrl: backgroundImageUrl.trim(),
|
||||
...(backgroundImageUrl.trim() ? { backgroundImageLayout } : {}),
|
||||
backgroundColor,
|
||||
fields: enabledFields,
|
||||
layout: layoutPayload,
|
||||
|
|
@ -548,21 +569,25 @@ export function CreateBadgePanel({
|
|||
<BadgeDesignerPreview
|
||||
aspectRatio={aspectRatio}
|
||||
badgeAspectRatio={aspectRatio}
|
||||
backgroundMode={backgroundMode}
|
||||
backgroundColor={backgroundColor}
|
||||
backgroundImageUrl={displayBgUrl}
|
||||
backgroundImageLayout={backgroundImageLayout}
|
||||
layoutElements={layoutElements}
|
||||
customImages={customImages}
|
||||
badgeTypeLabel={badgeTypeLabel}
|
||||
selectedId={selectedElementId}
|
||||
onSelect={setSelectedElementId}
|
||||
onLayoutChange={setLayoutElements}
|
||||
onBackgroundImageLayoutChange={setBackgroundImageLayout}
|
||||
surfaceRef={badgeSurfaceRef}
|
||||
/>
|
||||
<BadgeFieldInspector
|
||||
element={selectedElement}
|
||||
<div ref={badgeInspectorRef}>
|
||||
<BadgeFieldInspector
|
||||
element={selectedElementId === BADGE_BACKGROUND_IMAGE_ID ? null : selectedElement}
|
||||
customTextValue={customTexts.find((c) => c.id === selectedElementId)?.text}
|
||||
customImages={customImages}
|
||||
badgeAspectRatio={aspectRatio}
|
||||
mediaEventId={badgeMediaEventId}
|
||||
onChange={updateSelectedElement}
|
||||
onRemoveCustom={
|
||||
selectedElement?.kind === "custom_text"
|
||||
|
|
@ -576,6 +601,7 @@ export function CreateBadgePanel({
|
|||
updateSelectedElement({ imageUrl: url, imageAspectRatio });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -722,34 +748,53 @@ export function CreateBadgePanel({
|
|||
nextLabel="Output"
|
||||
>
|
||||
<Field>
|
||||
<Label text="Background" required />
|
||||
<RadioGroup
|
||||
name="bgMode"
|
||||
value={backgroundMode}
|
||||
onChange={(v) => setBackgroundMode(v as "image" | "color")}
|
||||
options={[
|
||||
{ label: "Solid colour", value: "color" },
|
||||
{ label: "Image", value: "image" },
|
||||
]}
|
||||
/>
|
||||
<Label text="Background colour" required />
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<input type="color" value={backgroundColor} onChange={(e) => setBackgroundColor(e.target.value)} style={{ width: 48, height: 36, border: "none", cursor: "pointer" }} />
|
||||
<input style={{ ...IS, flex: 1 }} value={backgroundColor} onChange={(e) => setBackgroundColor(e.target.value)} onFocus={focusIn} onBlur={focusOut} />
|
||||
</div>
|
||||
<HelperText text="Always applied behind badge fields. Shows through transparent areas of the background image." />
|
||||
</Field>
|
||||
{backgroundMode === "color" && (
|
||||
<Field>
|
||||
<Label text="Colour" />
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<input type="color" value={backgroundColor} onChange={(e) => setBackgroundColor(e.target.value)} style={{ width: 48, height: 36, border: "none", cursor: "pointer" }} />
|
||||
<input style={{ ...IS, flex: 1 }} value={backgroundColor} onChange={(e) => setBackgroundColor(e.target.value)} onFocus={focusIn} onBlur={focusOut} />
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
{backgroundMode === "image" && (
|
||||
<Field>
|
||||
<Label text="Background image" />
|
||||
<input style={IS} value={backgroundImageUrl} onChange={(e) => setBackgroundImageUrl(e.target.value)} placeholder="https://… or upload" onFocus={focusIn} onBlur={focusOut} />
|
||||
<Field>
|
||||
<Label text="Background image (optional)" />
|
||||
<input style={IS} value={backgroundImageUrl} onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setBackgroundImageUrl(v);
|
||||
if (!v.trim()) {
|
||||
setBackgroundPreviewUrl("");
|
||||
if (selectedElementId === BADGE_BACKGROUND_IMAGE_ID) setSelectedElementId(null);
|
||||
}
|
||||
}} placeholder="https://… or upload" onFocus={focusIn} onBlur={focusOut} />
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: 8 }}>
|
||||
<BackgroundUploadButton busy={headerBusy} onFile={(f) => void onHeaderFile(f)} />
|
||||
<HelperText text="Must be reachable by the server when the worker runs (public HTTPS)." />
|
||||
</Field>
|
||||
)}
|
||||
{backgroundImageUrl.trim() ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setBackgroundImageLayout(defaultBackgroundImageLayout());
|
||||
setSelectedElementId(BADGE_BACKGROUND_IMAGE_ID);
|
||||
}}
|
||||
style={{ ...SS, padding: "8px 12px", fontSize: "var(--text-sm)" }}
|
||||
>
|
||||
Fill badge
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setBackgroundImageUrl("");
|
||||
setBackgroundPreviewUrl("");
|
||||
if (selectedElementId === BADGE_BACKGROUND_IMAGE_ID) setSelectedElementId(null);
|
||||
}}
|
||||
style={{ ...SS, padding: "8px 12px", fontSize: "var(--text-sm)" }}
|
||||
>
|
||||
Remove image
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<HelperText text="Covers the badge by default. Drag to pan any part of the image into view; resize to zoom. The image can extend outside the badge — only the visible area is printed." />
|
||||
</Field>
|
||||
<Field>
|
||||
<Label text="Badge fields" />
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ const STATUS_OPTIONS: { label: string; value: string }[] = [
|
|||
{ label: "Confirmed", value: "confirmed" },
|
||||
{ label: "Checked In", value: "checked_in" },
|
||||
{ label: "Cancelled", value: "cancelled" },
|
||||
{ label: "Rejected", value: "rejected" },
|
||||
];
|
||||
|
||||
const emptyForm = (): Form => ({
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
| { id: string; type: "text"; html: string }
|
||||
| { id: string; type: "cta"; label: string; linkKind: LinkKind; formId?: string; micrositeId?: string; customUrl?: string }
|
||||
| { id: string; type: "cta"; label: string; linkKind: LinkKind; formId?: string; formType?: string; micrositeId?: string; customUrl?: string }
|
||||
| { id: string; type: "image"; src: string }
|
||||
| { id: string; type: "divider" };
|
||||
|
||||
|
|
@ -99,17 +99,25 @@ function publicFormHref(formId: string, eventId?: string): string {
|
|||
|
||||
function resolveCtaHref(
|
||||
b: Extract<EmailerContentBlock, { type: "cta" }>,
|
||||
targets: { forms: { id: string; href: string }[]; microsites: { id: string; href: string }[] },
|
||||
targets: { forms: { id: string; href: string; type?: string }[]; microsites: { id: string; href: string }[] },
|
||||
): string {
|
||||
if (b.linkKind === "custom") return String(b.customUrl || "").trim() || "#";
|
||||
if (b.linkKind === "form") {
|
||||
const f = targets.forms.find((x) => x.id === b.formId);
|
||||
const href = (f?.href && f.href.trim()) || (b.formId ? publicFormHref(b.formId) : "");
|
||||
return href || "#";
|
||||
if (b.formId) {
|
||||
const f = targets.forms.find((x) => x.id === b.formId);
|
||||
const href = (f?.href && f.href.trim()) || (b.formId ? publicFormHref(b.formId) : "");
|
||||
if (href) return href;
|
||||
}
|
||||
if (b.formType) {
|
||||
const f = targets.forms.find((x) => x.type === b.formType);
|
||||
if (f?.href?.trim()) return f.href.trim();
|
||||
}
|
||||
return "#";
|
||||
}
|
||||
if (b.linkKind === "microsite") {
|
||||
const m = targets.microsites.find((x) => x.id === b.micrositeId);
|
||||
return (m?.href && m.href.trim()) || "#";
|
||||
if (m?.href?.trim()) return m.href.trim();
|
||||
return targets.microsites[0]?.href?.trim() || "#";
|
||||
}
|
||||
return "#";
|
||||
}
|
||||
|
|
@ -195,6 +203,7 @@ function migrateBlocksFromContent(c: Record<string, unknown>): EmailerContentBlo
|
|||
label: String(b.label ?? b.text ?? "Register"),
|
||||
linkKind: (["form", "microsite", "custom"].includes(String(b.linkKind)) ? b.linkKind : "custom") as LinkKind,
|
||||
formId: typeof b.formId === "string" ? b.formId : undefined,
|
||||
formType: typeof b.formType === "string" ? b.formType : undefined,
|
||||
micrositeId: typeof b.micrositeId === "string" ? b.micrositeId : undefined,
|
||||
customUrl: String(b.customUrl ?? legacyCtaUrl ?? ""),
|
||||
});
|
||||
|
|
@ -250,6 +259,7 @@ export function CreateEmailerPanel({
|
|||
const [campaignEventId, setCampaignEventId] = useState("");
|
||||
const [participantListId, setParticipantListId] = useState("");
|
||||
const [campaignErr, setCampaignErr] = useState("");
|
||||
const [editingGlobalTemplate, setEditingGlobalTemplate] = useState(false);
|
||||
|
||||
const set = useCallback(<K extends keyof EForm>(k: K, v: EForm[K]) => {
|
||||
setForm((p) => ({ ...p, [k]: v }));
|
||||
|
|
@ -285,6 +295,7 @@ export function CreateEmailerPanel({
|
|||
void designTemplatesApi.get(templateId).then((res) => {
|
||||
if (!res.data) return;
|
||||
const t = res.data;
|
||||
setEditingGlobalTemplate(Boolean(t.isSystemTemplate));
|
||||
const c = (t.content || {}) as Record<string, unknown>;
|
||||
const migrated = migrateBlocksFromContent(c);
|
||||
const rawAssoc = (t as { associatedEvent?: string | { _id?: string } }).associatedEvent;
|
||||
|
|
@ -397,6 +408,7 @@ export function CreateEmailerPanel({
|
|||
category: form.emailerType || undefined,
|
||||
content,
|
||||
associatedEvent: form.associatedEvent.trim() || null,
|
||||
organization: listOrgId || undefined,
|
||||
};
|
||||
|
||||
const res = isEdit ? await designTemplatesApi.update(templateId!, payload) : await designTemplatesApi.create(payload);
|
||||
|
|
@ -437,7 +449,11 @@ export function CreateEmailerPanel({
|
|||
onClose={onClose}
|
||||
title={isEdit ? "Edit Emailer" : "Create Emailer"}
|
||||
breadcrumb="Design · Emailers"
|
||||
description="Design a branded HTML emailer and optional draft campaign."
|
||||
description={
|
||||
editingGlobalTemplate
|
||||
? "Global template — saving creates your organization's customized copy."
|
||||
: "Design a branded HTML emailer and optional draft campaign."
|
||||
}
|
||||
completedCount={acc.done.size}
|
||||
totalSections={TOTAL}
|
||||
ctaLabel={isEdit ? "Save changes" : "Save emailer"}
|
||||
|
|
|
|||
|
|
@ -429,18 +429,33 @@ export function CreateEventPanel({
|
|||
disabled={form.registrationRequired !== "Yes"}
|
||||
>
|
||||
<option value="">{form.registrationRequired === "Yes" ? "Select mode" : "N/A"}</option>
|
||||
<option>Open</option><option>Invite Only</option><option>Approval Required</option>
|
||||
<option>Open</option><option>Invite Only</option>
|
||||
</select>
|
||||
</SelectWrap>
|
||||
</Field>
|
||||
</Row>
|
||||
<Row>
|
||||
<Field>
|
||||
<Label text="Registration Approval" required={form.registrationRequired === "Yes"} />
|
||||
<RadioGroup
|
||||
name="registrationApproval"
|
||||
value={form.registrationApproval}
|
||||
onChange={v => f("registrationApproval", v as "auto" | "manual")}
|
||||
options={[
|
||||
{ label: "Auto Confirm", value: "auto" },
|
||||
{ label: "Manual Confirm", value: "manual" },
|
||||
]}
|
||||
disabled={form.registrationRequired !== "Yes"}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<Label text="Verify Email" required />
|
||||
<RadioGroup name="verifyEmail" value={form.verifyEmail}
|
||||
onChange={v => f("verifyEmail", v as "Yes" | "No")}
|
||||
options={[{ label: "Yes", value: "Yes" }, { label: "No", value: "No" }]} />
|
||||
</Field>
|
||||
</Row>
|
||||
<Row>
|
||||
<Field>
|
||||
<Label text="Registration Deadline" />
|
||||
<input type="date" style={IS} value={form.registrationDeadline} onChange={e => f("registrationDeadline", e.target.value)} />
|
||||
|
|
|
|||
|
|
@ -422,6 +422,7 @@ interface MergedForm {
|
|||
virtualLink: string;
|
||||
registrationRequired: "Yes" | "No";
|
||||
registrationMode: string;
|
||||
registrationApproval: "auto" | "manual";
|
||||
verifyEmail: "Yes" | "No";
|
||||
registrationDeadline: string;
|
||||
authenticationType: string;
|
||||
|
|
@ -459,6 +460,7 @@ const INITIAL: MergedForm = {
|
|||
virtualLink: "",
|
||||
registrationRequired: "No",
|
||||
registrationMode: "",
|
||||
registrationApproval: "auto",
|
||||
verifyEmail: "Yes",
|
||||
registrationDeadline: "",
|
||||
authenticationType: "otp_email",
|
||||
|
|
@ -859,6 +861,7 @@ export function CreateEventSessionPanel({
|
|||
virtualLink: form.virtualLink,
|
||||
registrationRequired: form.registrationRequired,
|
||||
registrationMode: form.registrationMode,
|
||||
registrationApproval: form.registrationApproval,
|
||||
verifyEmail: form.verifyEmail,
|
||||
registrationDeadline: form.registrationDeadline,
|
||||
authenticationType: form.authenticationType,
|
||||
|
|
@ -1593,12 +1596,24 @@ export function CreateEventSessionPanel({
|
|||
<option value="">{form.registrationRequired === "Yes" ? "Select mode" : "N/A"}</option>
|
||||
<option>Open</option>
|
||||
<option>Invite Only</option>
|
||||
<option>Approval Required</option>
|
||||
</select>
|
||||
</SelectWrap>
|
||||
</Field>
|
||||
</Row>
|
||||
<Row>
|
||||
<Field>
|
||||
<Label text="Registration approval" required={form.registrationRequired === "Yes"} />
|
||||
<RadioGroup
|
||||
name="registrationApproval"
|
||||
value={form.registrationApproval}
|
||||
onChange={v => f("registrationApproval", v as "auto" | "manual")}
|
||||
options={[
|
||||
{ label: "Auto Confirm", value: "auto" },
|
||||
{ label: "Manual Confirm", value: "manual" },
|
||||
]}
|
||||
disabled={form.registrationRequired !== "Yes"}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<Label text="Verify email" required />
|
||||
<RadioGroup
|
||||
|
|
@ -1611,6 +1626,8 @@ export function CreateEventSessionPanel({
|
|||
]}
|
||||
/>
|
||||
</Field>
|
||||
</Row>
|
||||
<Row>
|
||||
<Field>
|
||||
<Label text="Registration deadline" />
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ import { Plus, Trash2, GripVertical } from "lucide-react";
|
|||
import { PanelShell, AccordionSection, useAccordion, Label, Field, Grid, RadioGroup, HelperText, IS, SS, focusIn, focusOut } from "./PanelShell";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import { usePermission } from "../../hooks/usePermission";
|
||||
import { eventsApi, formsApi, participantListsApi, portfolioListOrganizationId } from "../../../services/api";
|
||||
import type { Form, FormField, ParticipantListRecord } from "../../../services/api";
|
||||
import { eventsApi, formsApi, participantListsApi, designTemplatesApi, portfolioListOrganizationId } from "../../../services/api";
|
||||
import type { Form, FormField, ParticipantListRecord, DesignTemplate } from "../../../services/api";
|
||||
import { assignUniqueFieldNames } from "../../utils/formFieldNames";
|
||||
import {
|
||||
EVENT_HEADER_TOGGLE_LABELS,
|
||||
|
|
@ -44,6 +44,7 @@ function patchForFieldTypeChange(next: string, q: Question): Partial<Question> {
|
|||
nextPatch.options = [];
|
||||
nextPatch.mapsTo = "";
|
||||
nextPatch.customMapsKey = "";
|
||||
nextPatch.convertToTag = false;
|
||||
return nextPatch;
|
||||
}
|
||||
if (OPTION_FIELD_TYPES.has(next)) {
|
||||
|
|
@ -55,12 +56,22 @@ function patchForFieldTypeChange(next: string, q: Question): Partial<Question> {
|
|||
nextPatch.allowOther = false;
|
||||
nextPatch.otherLabel = undefined;
|
||||
}
|
||||
if (!canConvertValueToTag(next)) {
|
||||
nextPatch.convertToTag = false;
|
||||
}
|
||||
return nextPatch;
|
||||
}
|
||||
|
||||
/** Types that need a configurable list of choices (dropdown / MC / radio / multi-checkbox). */
|
||||
const OPTION_FIELD_TYPES = new Set(["Multiple Choice", "Dropdown", "Radio", "Checkboxes"]);
|
||||
|
||||
/** Choice / selectable fields whose submitted labels can become participant tags. */
|
||||
const CONVERT_TO_TAG_FIELD_TYPES = new Set([...OPTION_FIELD_TYPES, "Yes / No"]);
|
||||
|
||||
function canConvertValueToTag(type: string): boolean {
|
||||
return CONVERT_TO_TAG_FIELD_TYPES.has(type);
|
||||
}
|
||||
|
||||
const FORM_Q_DRAG = "application/x-eventbuzz-form-q";
|
||||
|
||||
function reorderList<T>(items: T[], from: number, to: number): T[] {
|
||||
|
|
@ -183,6 +194,8 @@ interface Question {
|
|||
fixed?: boolean;
|
||||
/** Optional note shown below the field on the public form. */
|
||||
remarks?: string;
|
||||
/** Registration: add submitted value(s) as participant tags. */
|
||||
convertToTag?: boolean;
|
||||
}
|
||||
|
||||
function normalizedOptions(q: Question): string[] {
|
||||
|
|
@ -429,6 +442,18 @@ function RegExtraFieldRow({ q, types, onPatch, onRemove, idx, dragIndex, onReord
|
|||
<HelperText text="Stored under Participant.customFields[key]." />
|
||||
</Field>
|
||||
)}
|
||||
{canConvertValueToTag(q.type) ? (
|
||||
<Field span2>
|
||||
<Label text="Convert value to tag" />
|
||||
<RadioGroup
|
||||
name={`tag-${q.id}`}
|
||||
value={q.convertToTag ? "Yes" : "No"}
|
||||
onChange={(v) => onPatch({ convertToTag: v === "Yes" })}
|
||||
options={[{ label: "Yes", value: "Yes" }, { label: "No", value: "No" }]}
|
||||
/>
|
||||
<HelperText text="When Yes, the selected option(s) are added as participant tags." />
|
||||
</Field>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
{!isSection ? (
|
||||
|
|
@ -452,14 +477,25 @@ interface FForm {
|
|||
questions: Question[];
|
||||
showThankYou: "Yes" | "No";
|
||||
thankYouMsg: string;
|
||||
formClosedMessage: string;
|
||||
submitErrorMessage: string;
|
||||
captchaFailedMessage: string;
|
||||
registrationClosedMessage: string;
|
||||
registrationDeadlineMessage: string;
|
||||
registrationFullMessage: string;
|
||||
duplicateEmailMessage: string;
|
||||
duplicatePhoneMessage: string;
|
||||
collectEmail: "Yes" | "No";
|
||||
anonymous: "Yes" | "No";
|
||||
publish: "Draft" | "Published";
|
||||
paymentEnabled: "Yes" | "No";
|
||||
amount: string;
|
||||
confirmationEmail: "Yes" | "No";
|
||||
confirmationEmailTemplateId: string;
|
||||
onSubmitTags: string;
|
||||
onSubmitListId: string;
|
||||
allowDuplicateEmail: "Yes" | "No";
|
||||
allowDuplicatePhone: "Yes" | "No";
|
||||
headerShowLogo: "Yes" | "No";
|
||||
headerShowTitle: "Yes" | "No";
|
||||
headerShowDateRange: "Yes" | "No";
|
||||
|
|
@ -468,6 +504,19 @@ interface FForm {
|
|||
headerShowVenue: "Yes" | "No";
|
||||
}
|
||||
|
||||
const DEFAULT_FORM_MESSAGES = {
|
||||
thankYouFeedback: "Thank you for your feedback!",
|
||||
thankYouResponse: "Thank you for your response!",
|
||||
formClosed: "This form is not accepting responses.",
|
||||
submitError: "Submit failed. Please try again.",
|
||||
captchaFailed: "Captcha verification failed. Please try again.",
|
||||
registrationClosed: "Registration is closed for this event.",
|
||||
registrationDeadline: "Registration deadline has passed.",
|
||||
registrationFull: "This event has reached maximum participants.",
|
||||
duplicateEmail: "This email is already registered for this event.",
|
||||
duplicatePhone: "This phone number is already registered for this event.",
|
||||
} as const;
|
||||
|
||||
const newQ = (types: string[]): Question => {
|
||||
const first = types[0];
|
||||
return {
|
||||
|
|
@ -487,15 +536,26 @@ const initialForm = (qTypes: string[], isFeedback: boolean): FForm => ({
|
|||
description: "",
|
||||
questions: [newQ(qTypes)],
|
||||
showThankYou: "Yes",
|
||||
thankYouMsg: "Thank you for your feedback!",
|
||||
thankYouMsg: isFeedback ? DEFAULT_FORM_MESSAGES.thankYouFeedback : DEFAULT_FORM_MESSAGES.thankYouResponse,
|
||||
formClosedMessage: DEFAULT_FORM_MESSAGES.formClosed,
|
||||
submitErrorMessage: DEFAULT_FORM_MESSAGES.submitError,
|
||||
captchaFailedMessage: DEFAULT_FORM_MESSAGES.captchaFailed,
|
||||
registrationClosedMessage: DEFAULT_FORM_MESSAGES.registrationClosed,
|
||||
registrationDeadlineMessage: DEFAULT_FORM_MESSAGES.registrationDeadline,
|
||||
registrationFullMessage: DEFAULT_FORM_MESSAGES.registrationFull,
|
||||
duplicateEmailMessage: DEFAULT_FORM_MESSAGES.duplicateEmail,
|
||||
duplicatePhoneMessage: DEFAULT_FORM_MESSAGES.duplicatePhone,
|
||||
collectEmail: "No",
|
||||
anonymous: isFeedback ? "Yes" : "No",
|
||||
publish: "Draft",
|
||||
paymentEnabled: "No",
|
||||
amount: "",
|
||||
confirmationEmail: "Yes",
|
||||
confirmationEmail: "No",
|
||||
confirmationEmailTemplateId: "",
|
||||
onSubmitTags: "",
|
||||
onSubmitListId: "",
|
||||
allowDuplicateEmail: "Yes",
|
||||
allowDuplicatePhone: "Yes",
|
||||
headerShowLogo: "Yes",
|
||||
headerShowTitle: "Yes",
|
||||
headerShowDateRange: "Yes",
|
||||
|
|
@ -627,6 +687,28 @@ function toApiField(q: Question, mode: FormMode): FormField {
|
|||
}
|
||||
return withName(withUiType({ label, type: "text", required }, q.type, persistUiType));
|
||||
}
|
||||
// Registration / web-check-in: also absorb feedback-style labels that may linger in legacy forms.
|
||||
if (q.type === "Star Rating") {
|
||||
return withFieldRemarks({ label, name, type: "stars", required, options: ["1", "2", "3", "4", "5"] }, q.remarks);
|
||||
}
|
||||
if (q.type === "NPS (0–10)") {
|
||||
return withFieldRemarks({
|
||||
label,
|
||||
name,
|
||||
type: "select",
|
||||
required,
|
||||
options: Array.from({ length: 11 }, (_, i) => String(i)),
|
||||
}, q.remarks);
|
||||
}
|
||||
if (q.type === "Multiple Choice" || q.type === "Short Text" || q.type === "Long Text") {
|
||||
if (q.type === "Multiple Choice") {
|
||||
return withFieldRemarks({ label, name, type: "select", required, options: choiceOpts }, q.remarks);
|
||||
}
|
||||
if (q.type === "Long Text") {
|
||||
return withFieldRemarks({ label, name, type: "textarea", required }, q.remarks);
|
||||
}
|
||||
return withFieldRemarks({ label, name, type: "text", required }, q.remarks);
|
||||
}
|
||||
const type = (REG_TYPE_TO_API[q.type] ?? "text") as FormField["type"];
|
||||
if (type === "select" || type === "radio" || type === "checkboxes") {
|
||||
const base = { label, name, type, required, options: choiceOpts };
|
||||
|
|
@ -648,6 +730,7 @@ function toApiFieldReg(q: Question): FormField {
|
|||
mapsTo: q.mapsTo || undefined,
|
||||
customMapsKey: q.mapsTo === "custom" && q.customMapsKey?.trim() ? q.customMapsKey.trim() : undefined,
|
||||
isFixed: q.fixed === true,
|
||||
convertToTag: canConvertValueToTag(q.type) && q.convertToTag === true,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -673,6 +756,7 @@ function fromApiFields(fields: FormField[] | undefined, qTypes: string[], mode:
|
|||
customMapsKey: f.customMapsKey,
|
||||
fixed: f.isFixed,
|
||||
remarks: f.remarks ?? "",
|
||||
convertToTag: f.convertToTag === true,
|
||||
};
|
||||
});
|
||||
return assignUniqueFieldNames(questions);
|
||||
|
|
@ -693,6 +777,7 @@ function fromApiFieldSingle(f: FormField, i: number, qTypes: string[], mode: For
|
|||
customMapsKey: f.customMapsKey,
|
||||
fixed: f.isFixed === true,
|
||||
remarks: f.remarks ?? "",
|
||||
convertToTag: f.convertToTag === true,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -730,7 +815,7 @@ export function CreateFormPanel({
|
|||
const isOther = mode === "other";
|
||||
const isWebCheckIn = mode === "web-check-in";
|
||||
const TOTAL = isReg ? 5 : 3;
|
||||
const qTypes = isReg || isWebCheckIn ? OTHER_Q_TYPES : isOther ? OTHER_Q_TYPES : FEEDBACK_Q_TYPES;
|
||||
const qTypes = isReg || isWebCheckIn ? REG_FIELD_TYPES : isOther ? OTHER_Q_TYPES : FEEDBACK_Q_TYPES;
|
||||
const isFeedbackOrOther = isFeedback || isOther || isWebCheckIn;
|
||||
const acc = useAccordion(TOTAL);
|
||||
|
||||
|
|
@ -741,6 +826,7 @@ export function CreateFormPanel({
|
|||
return base;
|
||||
});
|
||||
const [fixedLists, setFixedLists] = useState<ParticipantListRecord[]>([]);
|
||||
const [emailerTemplates, setEmailerTemplates] = useState<DesignTemplate[]>([]);
|
||||
const [sectionErrors, setSectionErrors] = useState<Record<number, string>>({});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [loading, setLoading] = useState(!!formId);
|
||||
|
|
@ -788,6 +874,27 @@ export function CreateFormPanel({
|
|||
}).catch(() => setFixedLists([]));
|
||||
}, [isReg, listOrgId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isReg) return;
|
||||
if (!listOrgId) {
|
||||
setEmailerTemplates([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void designTemplatesApi
|
||||
.list({ type: "emailer", organization: listOrgId, limit: 200 })
|
||||
.then((r) => {
|
||||
if (cancelled) return;
|
||||
setEmailerTemplates(r.data?.templates ?? []);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setEmailerTemplates([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isReg, listOrgId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!formId) return;
|
||||
let cancelled = false;
|
||||
|
|
@ -808,15 +915,30 @@ export function CreateFormPanel({
|
|||
description: data.description ?? "",
|
||||
questions: questions.length ? questions : (data.type === "registration" ? initialRegQuestions() : [newQ(qTypes)]),
|
||||
showThankYou: s.showThankYou === false ? "No" : "Yes",
|
||||
thankYouMsg: s.thankYouMessage?.trim() ? s.thankYouMessage : "Thank you for your feedback!",
|
||||
thankYouMsg: s.thankYouMessage?.trim()
|
||||
? s.thankYouMessage
|
||||
: (isFeedbackOrOther ? DEFAULT_FORM_MESSAGES.thankYouFeedback : DEFAULT_FORM_MESSAGES.thankYouResponse),
|
||||
formClosedMessage: s.formClosedMessage?.trim() || DEFAULT_FORM_MESSAGES.formClosed,
|
||||
submitErrorMessage: s.submitErrorMessage?.trim() || DEFAULT_FORM_MESSAGES.submitError,
|
||||
captchaFailedMessage: s.captchaFailedMessage?.trim() || DEFAULT_FORM_MESSAGES.captchaFailed,
|
||||
registrationClosedMessage: s.registrationClosedMessage?.trim() || DEFAULT_FORM_MESSAGES.registrationClosed,
|
||||
registrationDeadlineMessage: s.registrationDeadlineMessage?.trim() || DEFAULT_FORM_MESSAGES.registrationDeadline,
|
||||
registrationFullMessage: s.registrationFullMessage?.trim() || DEFAULT_FORM_MESSAGES.registrationFull,
|
||||
duplicateEmailMessage: s.duplicateEmailMessage?.trim() || DEFAULT_FORM_MESSAGES.duplicateEmail,
|
||||
duplicatePhoneMessage: s.duplicatePhoneMessage?.trim() || DEFAULT_FORM_MESSAGES.duplicatePhone,
|
||||
collectEmail: s.collectEmail ? "Yes" : "No",
|
||||
anonymous: s.allowAnonymous ? "Yes" : (isFeedbackOrOther ? "Yes" : "No"),
|
||||
publish: s.isActive !== false ? "Published" : "Draft",
|
||||
paymentEnabled: s.paymentEnabled ? "Yes" : "No",
|
||||
amount: s.paymentAmount != null && s.paymentAmount !== "" ? String(s.paymentAmount) : "",
|
||||
confirmationEmail: s.sendConfirmationEmail === false ? "No" : "Yes",
|
||||
confirmationEmail: s.sendConfirmationEmail === true ? "Yes" : "No",
|
||||
confirmationEmailTemplateId: s.confirmationEmailTemplateId
|
||||
? String(s.confirmationEmailTemplateId)
|
||||
: "",
|
||||
onSubmitTags: tags,
|
||||
onSubmitListId: s.onSubmitParticipantListId ?? "",
|
||||
allowDuplicateEmail: s.allowDuplicateEmail === false ? "No" : "Yes",
|
||||
allowDuplicatePhone: s.allowDuplicatePhone === false ? "No" : "Yes",
|
||||
headerShowLogo: header.showLogo,
|
||||
headerShowTitle: header.showTitle,
|
||||
headerShowDateRange: header.showDateRange,
|
||||
|
|
@ -876,7 +998,11 @@ export function CreateFormPanel({
|
|||
return anyLabel && choicesOk;
|
||||
}
|
||||
if (n === 3 && isReg) return true;
|
||||
if (n === 4 && isReg) return true;
|
||||
if (n === 4 && isReg) {
|
||||
if (f.confirmationEmail === "Yes" && !f.confirmationEmailTemplateId.trim()) return false;
|
||||
if (f.paymentEnabled === "Yes" && !f.amount.trim()) return false;
|
||||
return true;
|
||||
}
|
||||
if (n === (isReg ? 5 : 3)) return true;
|
||||
return true;
|
||||
}, [isReg, isWebCheckIn, selectedEventId]);
|
||||
|
|
@ -910,6 +1036,13 @@ export function CreateFormPanel({
|
|||
paymentEnabled: form.paymentEnabled === "Yes",
|
||||
paymentAmount: form.paymentEnabled === "Yes" && form.amount.trim() ? form.amount.trim() : "",
|
||||
sendConfirmationEmail: form.confirmationEmail === "Yes",
|
||||
confirmationEmailTemplateId:
|
||||
form.confirmationEmail === "Yes" && form.confirmationEmailTemplateId.trim()
|
||||
? form.confirmationEmailTemplateId.trim()
|
||||
: null,
|
||||
formClosedMessage: form.formClosedMessage.trim(),
|
||||
submitErrorMessage: form.submitErrorMessage.trim(),
|
||||
captchaFailedMessage: form.captchaFailedMessage.trim(),
|
||||
eventHeader: eventHeaderToApi({
|
||||
showLogo: form.headerShowLogo,
|
||||
showTitle: form.headerShowTitle,
|
||||
|
|
@ -921,7 +1054,14 @@ export function CreateFormPanel({
|
|||
...(isReg
|
||||
? {
|
||||
onSubmitAddTags: tagArr,
|
||||
onSubmitParticipantListId: form.onSubmitListId.trim() || null,
|
||||
onSubmitParticipantListId: form.onSubmitListId?.trim() || null,
|
||||
allowDuplicateEmail: form.allowDuplicateEmail !== "No",
|
||||
allowDuplicatePhone: form.allowDuplicatePhone !== "No",
|
||||
registrationClosedMessage: form.registrationClosedMessage.trim(),
|
||||
registrationDeadlineMessage: form.registrationDeadlineMessage.trim(),
|
||||
registrationFullMessage: form.registrationFullMessage.trim(),
|
||||
duplicateEmailMessage: form.duplicateEmailMessage.trim(),
|
||||
duplicatePhoneMessage: form.duplicatePhoneMessage.trim(),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
|
|
@ -944,8 +1084,9 @@ export function CreateFormPanel({
|
|||
}
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch {
|
||||
setSectionErrors(prev => ({ ...prev, 1: "Failed to save. Please try again." }));
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error && e.message.trim() ? e.message.trim() : "Failed to save. Please try again.";
|
||||
setSectionErrors((prev) => ({ ...prev, 1: msg }));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
|
@ -1118,7 +1259,7 @@ export function CreateFormPanel({
|
|||
<Field>
|
||||
<Label text="Add tags on submit" />
|
||||
<input style={IS} placeholder="e.g. VIP, Registered — comma separated" value={form.onSubmitTags} onChange={e => set("onSubmitTags", e.target.value)} onFocus={focusIn} onBlur={focusOut} />
|
||||
<HelperText text="Merged onto the participant record (deduplicated)." />
|
||||
<HelperText text="Merged onto the participant record (deduplicated). Selectable fields (Dropdown, Radio, Checkboxes, etc.) can also add tags via “Convert value to tag”." />
|
||||
</Field>
|
||||
<Field>
|
||||
<Label text="Add to fixed participant list" />
|
||||
|
|
@ -1132,6 +1273,28 @@ export function CreateFormPanel({
|
|||
</div>
|
||||
<HelperText text="Only fixed (manual) lists in your organization are shown." />
|
||||
</Field>
|
||||
<Grid cols={2}>
|
||||
<Field>
|
||||
<Label text="Allow duplicate email" required />
|
||||
<RadioGroup
|
||||
name="allowDupEmail"
|
||||
value={form.allowDuplicateEmail}
|
||||
onChange={v => set("allowDuplicateEmail", v as FForm["allowDuplicateEmail"])}
|
||||
options={[{ label: "Yes", value: "Yes" }, { label: "No", value: "No" }]}
|
||||
/>
|
||||
<HelperText text="Case-insensitive. When No, emails already registered for this event are rejected." />
|
||||
</Field>
|
||||
<Field>
|
||||
<Label text="Allow duplicate phone" required />
|
||||
<RadioGroup
|
||||
name="allowDupPhone"
|
||||
value={form.allowDuplicatePhone}
|
||||
onChange={v => set("allowDuplicatePhone", v as FForm["allowDuplicatePhone"])}
|
||||
options={[{ label: "Yes", value: "Yes" }, { label: "No", value: "No" }]}
|
||||
/>
|
||||
<HelperText text="Compares the last 10 digits only (ignores +, spaces, country code)." />
|
||||
</Field>
|
||||
</Grid>
|
||||
</AccordionSection>
|
||||
)}
|
||||
|
||||
|
|
@ -1160,9 +1323,50 @@ export function CreateFormPanel({
|
|||
)}
|
||||
<Field>
|
||||
<Label text="Confirmation Email" required />
|
||||
<RadioGroup name="confEmail" value={form.confirmationEmail} onChange={v => set("confirmationEmail", v as FForm["confirmationEmail"])} options={[{ label: "Yes", value: "Yes" }, { label: "No", value: "No" }]} />
|
||||
<HelperText text="Send a confirmation email after registration." />
|
||||
<RadioGroup
|
||||
name="confEmail"
|
||||
value={form.confirmationEmail}
|
||||
onChange={(v) => {
|
||||
const next = v as FForm["confirmationEmail"];
|
||||
if (next === "Yes") {
|
||||
const preferred =
|
||||
emailerTemplates.find((t) => t.systemTemplateKey === "register_success")?._id
|
||||
|| emailerTemplates.find((t) => t.systemTemplateKey === "register_confirmation")?._id
|
||||
|| emailerTemplates[0]?._id
|
||||
|| "";
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
confirmationEmail: next,
|
||||
confirmationEmailTemplateId: prev.confirmationEmailTemplateId || preferred,
|
||||
}));
|
||||
} else {
|
||||
set("confirmationEmail", next);
|
||||
}
|
||||
}}
|
||||
options={[{ label: "Yes", value: "Yes" }, { label: "No", value: "No" }]}
|
||||
/>
|
||||
<HelperText text="When Yes, send an emailer template after a successful registration." />
|
||||
</Field>
|
||||
{form.confirmationEmail === "Yes" && (
|
||||
<Field span2>
|
||||
<Label text="Confirmation email template" required />
|
||||
<select
|
||||
style={SS}
|
||||
value={form.confirmationEmailTemplateId}
|
||||
onChange={(e) => set("confirmationEmailTemplateId", e.target.value)}
|
||||
onFocus={focusIn}
|
||||
onBlur={focusOut}
|
||||
>
|
||||
<option value="">Select an email template…</option>
|
||||
{emailerTemplates.map((t) => (
|
||||
<option key={t._id} value={t._id}>
|
||||
{t.name}{t.isSystemTemplate || t.isGlobalTemplate ? " (system)" : ""}{t.category ? ` · ${t.category}` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<HelperText text={emailerTemplates.length ? "Choose from Design → Emailers (system and organisation templates)." : "No emailer templates found. Create one under Design → Emailers."} />
|
||||
</Field>
|
||||
)}
|
||||
</Grid>
|
||||
</AccordionSection>
|
||||
)}
|
||||
|
|
@ -1170,7 +1374,7 @@ export function CreateFormPanel({
|
|||
<AccordionSection
|
||||
n={isReg ? 5 : 3}
|
||||
title="Publish Settings"
|
||||
subtitle="Set the form status and thank-you message"
|
||||
subtitle="Status and respondent-facing messages"
|
||||
open={acc.open === (isReg ? 5 : 3)}
|
||||
completed={acc.done.has(isReg ? 5 : 3)}
|
||||
onToggle={() => acc.toggle(isReg ? 5 : 3)}
|
||||
|
|
@ -1188,12 +1392,129 @@ export function CreateFormPanel({
|
|||
</select>
|
||||
</div>
|
||||
</Field>
|
||||
<Field>
|
||||
<Label text="Show thank-you screen" required />
|
||||
<RadioGroup
|
||||
name="showThankYou"
|
||||
value={form.showThankYou}
|
||||
onChange={(v) => set("showThankYou", v as FForm["showThankYou"])}
|
||||
options={[{ label: "Yes", value: "Yes" }, { label: "No", value: "No" }]}
|
||||
/>
|
||||
</Field>
|
||||
<Field span2>
|
||||
<Label text="Thank You Message" />
|
||||
<input style={IS} placeholder="Thank you for your response!" value={form.thankYouMsg} onChange={e => set("thankYouMsg", e.target.value)} onFocus={focusIn} onBlur={focusOut} />
|
||||
<HelperText text="Shown to respondents after they submit the form." />
|
||||
<Label text="Success / thank-you message" />
|
||||
<textarea
|
||||
style={{ ...IS, resize: "vertical", minHeight: "64px" }}
|
||||
placeholder={DEFAULT_FORM_MESSAGES.thankYouResponse}
|
||||
value={form.thankYouMsg}
|
||||
onChange={e => set("thankYouMsg", e.target.value)}
|
||||
onFocus={focusIn}
|
||||
onBlur={focusOut}
|
||||
/>
|
||||
<HelperText text="Shown after a successful submission." />
|
||||
</Field>
|
||||
<Field span2>
|
||||
<Label text="Form closed message" />
|
||||
<textarea
|
||||
style={{ ...IS, resize: "vertical", minHeight: "56px" }}
|
||||
placeholder={DEFAULT_FORM_MESSAGES.formClosed}
|
||||
value={form.formClosedMessage}
|
||||
onChange={e => set("formClosedMessage", e.target.value)}
|
||||
onFocus={focusIn}
|
||||
onBlur={focusOut}
|
||||
/>
|
||||
<HelperText text="Shown when the form is Draft / not accepting responses." />
|
||||
</Field>
|
||||
<Field span2>
|
||||
<Label text="Generic error message" />
|
||||
<textarea
|
||||
style={{ ...IS, resize: "vertical", minHeight: "56px" }}
|
||||
placeholder={DEFAULT_FORM_MESSAGES.submitError}
|
||||
value={form.submitErrorMessage}
|
||||
onChange={e => set("submitErrorMessage", e.target.value)}
|
||||
onFocus={focusIn}
|
||||
onBlur={focusOut}
|
||||
/>
|
||||
<HelperText text="Fallback when submit fails without a more specific API message." />
|
||||
</Field>
|
||||
<Field span2>
|
||||
<Label text="Captcha failed message" />
|
||||
<textarea
|
||||
style={{ ...IS, resize: "vertical", minHeight: "56px" }}
|
||||
placeholder={DEFAULT_FORM_MESSAGES.captchaFailed}
|
||||
value={form.captchaFailedMessage}
|
||||
onChange={e => set("captchaFailedMessage", e.target.value)}
|
||||
onFocus={focusIn}
|
||||
onBlur={focusOut}
|
||||
/>
|
||||
</Field>
|
||||
</Grid>
|
||||
|
||||
{isReg ? (
|
||||
<div style={{ marginTop: 12, display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<p style={{ margin: 0, fontSize: "var(--text-xs)", fontWeight: 700, letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--muted)" }}>
|
||||
Registration messages
|
||||
</p>
|
||||
<Field>
|
||||
<Label text="Registration closed" />
|
||||
<textarea
|
||||
style={{ ...IS, resize: "vertical", minHeight: "56px" }}
|
||||
placeholder={DEFAULT_FORM_MESSAGES.registrationClosed}
|
||||
value={form.registrationClosedMessage}
|
||||
onChange={e => set("registrationClosedMessage", e.target.value)}
|
||||
onFocus={focusIn}
|
||||
onBlur={focusOut}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<Label text="Registration deadline passed" />
|
||||
<textarea
|
||||
style={{ ...IS, resize: "vertical", minHeight: "56px" }}
|
||||
placeholder={DEFAULT_FORM_MESSAGES.registrationDeadline}
|
||||
value={form.registrationDeadlineMessage}
|
||||
onChange={e => set("registrationDeadlineMessage", e.target.value)}
|
||||
onFocus={focusIn}
|
||||
onBlur={focusOut}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<Label text="Registration full" />
|
||||
<textarea
|
||||
style={{ ...IS, resize: "vertical", minHeight: "56px" }}
|
||||
placeholder={DEFAULT_FORM_MESSAGES.registrationFull}
|
||||
value={form.registrationFullMessage}
|
||||
onChange={e => set("registrationFullMessage", e.target.value)}
|
||||
onFocus={focusIn}
|
||||
onBlur={focusOut}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<Label text="Duplicate email" />
|
||||
<textarea
|
||||
style={{ ...IS, resize: "vertical", minHeight: "56px" }}
|
||||
placeholder={DEFAULT_FORM_MESSAGES.duplicateEmail}
|
||||
value={form.duplicateEmailMessage}
|
||||
onChange={e => set("duplicateEmailMessage", e.target.value)}
|
||||
onFocus={focusIn}
|
||||
onBlur={focusOut}
|
||||
/>
|
||||
<HelperText text="Used when “Allow duplicate email” is No." />
|
||||
</Field>
|
||||
<Field>
|
||||
<Label text="Duplicate phone" />
|
||||
<textarea
|
||||
style={{ ...IS, resize: "vertical", minHeight: "56px" }}
|
||||
placeholder={DEFAULT_FORM_MESSAGES.duplicatePhone}
|
||||
value={form.duplicatePhoneMessage}
|
||||
onChange={e => set("duplicatePhoneMessage", e.target.value)}
|
||||
onFocus={focusIn}
|
||||
onBlur={focusOut}
|
||||
/>
|
||||
<HelperText text="Used when “Allow duplicate phone” is No." />
|
||||
</Field>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Field>
|
||||
<Label text="Event header on public form" />
|
||||
<HelperText text="Choose which linked event details appear above the form on the public page." />
|
||||
|
|
|
|||
|
|
@ -121,6 +121,18 @@ export function FormSubmissionsPanel({ formId, formName, onClose }: Props) {
|
|||
setPage(1);
|
||||
};
|
||||
|
||||
const hasActiveFilters = Boolean(q.trim() || dateFrom || dateTo || appliedQ || appliedFrom || appliedTo);
|
||||
|
||||
const clearFilters = () => {
|
||||
setQ("");
|
||||
setDateFrom("");
|
||||
setDateTo("");
|
||||
setAppliedQ("");
|
||||
setAppliedFrom("");
|
||||
setAppliedTo("");
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const onExport = async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
|
|
@ -383,6 +395,25 @@ export function FormSubmissionsPanel({ formId, formName, onClose }: Props) {
|
|||
>
|
||||
Apply
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearFilters}
|
||||
disabled={!hasActiveFilters}
|
||||
style={{
|
||||
padding: "8px 14px",
|
||||
borderRadius: "var(--radius-button)",
|
||||
border: "1px solid var(--border)",
|
||||
background: "var(--card)",
|
||||
color: "var(--muted)",
|
||||
fontSize: "var(--text-sm)",
|
||||
fontFamily: "var(--font-noto-sans)",
|
||||
fontWeight: 600,
|
||||
cursor: hasActiveFilters ? "pointer" : "not-allowed",
|
||||
opacity: hasActiveFilters ? 1 : 0.5,
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onExport()}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
import type { BadgeBatchRecord, BadgeLayoutElementPayload } from "../../../services/api/badgeBatches";
|
||||
import type { BadgeLayoutElement } from "./badgeLayout";
|
||||
import { defaultLayoutForFields } from "./badgeLayout";
|
||||
import type { BadgeBackgroundImageLayout, BadgeLayoutElement } from "./badgeLayout";
|
||||
import { defaultBackgroundImageLayout, defaultLayoutForFields, parseBackgroundImageLayout } from "./badgeLayout";
|
||||
|
||||
export type BadgeFormState = {
|
||||
formName: string;
|
||||
|
|
@ -19,6 +19,7 @@ export type BadgeFormState = {
|
|||
backgroundMode: "image" | "color";
|
||||
backgroundColor: string;
|
||||
backgroundImageUrl: string;
|
||||
backgroundImageLayout: BadgeBackgroundImageLayout;
|
||||
enabledFields: string[];
|
||||
layoutElements: BadgeLayoutElement[];
|
||||
customTexts: { id: string; text: string }[];
|
||||
|
|
@ -98,8 +99,12 @@ function customImagesFromConfig(c: Record<string, unknown>, layout: BadgeLayoutE
|
|||
|
||||
export function formStateFromBadgeBatch(batch: BadgeBatchRecord): BadgeFormState {
|
||||
const c = asConfig(batch);
|
||||
const bgMode = str(c.backgroundMode, "color") === "image" ? "image" : "color";
|
||||
const bgColor = str(c.backgroundColor, "#0B4DD1");
|
||||
const bgImageUrl = str(c.backgroundImageUrl, "");
|
||||
const bgMode = bgImageUrl.trim() ? "image" : str(c.backgroundMode, "color") === "image" ? "image" : "color";
|
||||
const backgroundImageLayout =
|
||||
parseBackgroundImageLayout(c.backgroundImageLayout) ??
|
||||
(bgImageUrl.trim() ? defaultBackgroundImageLayout() : defaultBackgroundImageLayout());
|
||||
const fields = Array.isArray(c.fields) ? c.fields.map((f) => String(f)) : ["name", "title", "org", "qr"];
|
||||
let layoutElements = layoutFromConfig(c);
|
||||
if (!layoutElements.length && fields.length) {
|
||||
|
|
@ -125,7 +130,8 @@ export function formStateFromBadgeBatch(batch: BadgeBatchRecord): BadgeFormState
|
|||
participantListId: c.participantListId != null ? String(c.participantListId) : "",
|
||||
backgroundMode: bgMode,
|
||||
backgroundColor: bgColor,
|
||||
backgroundImageUrl: str(c.backgroundImageUrl, ""),
|
||||
backgroundImageUrl: bgImageUrl,
|
||||
backgroundImageLayout,
|
||||
enabledFields: fields,
|
||||
layoutElements,
|
||||
customTexts,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,59 @@ export type BadgeTextAlign = "left" | "center" | "right";
|
|||
|
||||
export type BadgeLayoutKind = "field" | "custom_text" | "custom_image";
|
||||
|
||||
export type BadgeBackgroundImageLayout = {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
};
|
||||
|
||||
/** Preview selection id for the optional background image layer. */
|
||||
export const BADGE_BACKGROUND_IMAGE_ID = "__badge_bg__";
|
||||
|
||||
const BG_IMAGE_MIN_SIZE = 10;
|
||||
const BG_IMAGE_MAX_SIZE = 500;
|
||||
const BG_IMAGE_MIN_POS = -400;
|
||||
const BG_IMAGE_MAX_POS = 400;
|
||||
|
||||
export function defaultBackgroundImageLayout(): BadgeBackgroundImageLayout {
|
||||
return { x: 0, y: 0, w: 100, h: 100 };
|
||||
}
|
||||
|
||||
export function parseBackgroundImageLayout(raw: unknown): BadgeBackgroundImageLayout | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const o = raw as Record<string, unknown>;
|
||||
if (
|
||||
typeof o.x !== "number" ||
|
||||
typeof o.y !== "number" ||
|
||||
typeof o.w !== "number" ||
|
||||
typeof o.h !== "number"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return applyBackgroundImageLayoutPatch({ x: o.x, y: o.y, w: o.w, h: o.h });
|
||||
}
|
||||
|
||||
/** Allow bleed outside the badge — only soft sanity limits for save/drag. */
|
||||
export function applyBackgroundImageLayoutPatch(
|
||||
layout: BadgeBackgroundImageLayout,
|
||||
patch: Partial<BadgeBackgroundImageLayout> = {},
|
||||
): BadgeBackgroundImageLayout {
|
||||
const w = Math.min(BG_IMAGE_MAX_SIZE, Math.max(BG_IMAGE_MIN_SIZE, patch.w ?? layout.w));
|
||||
const h = Math.min(BG_IMAGE_MAX_SIZE, Math.max(BG_IMAGE_MIN_SIZE, patch.h ?? layout.h));
|
||||
const x = Math.min(BG_IMAGE_MAX_POS, Math.max(BG_IMAGE_MIN_POS, patch.x ?? layout.x));
|
||||
const y = Math.min(BG_IMAGE_MAX_POS, Math.max(BG_IMAGE_MIN_POS, patch.y ?? layout.y));
|
||||
return { x, y, w, h };
|
||||
}
|
||||
|
||||
/** @deprecated Use applyBackgroundImageLayoutPatch */
|
||||
export function clampBackgroundImageLayout(
|
||||
layout: BadgeBackgroundImageLayout,
|
||||
patch: Partial<BadgeBackgroundImageLayout> = {},
|
||||
): BadgeBackgroundImageLayout {
|
||||
return applyBackgroundImageLayoutPatch(layout, patch);
|
||||
}
|
||||
|
||||
export type BadgeLayoutElement = {
|
||||
id: string;
|
||||
kind: BadgeLayoutKind;
|
||||
|
|
@ -65,6 +118,12 @@ export function elementSizePct(el: BadgeLayoutElement): number {
|
|||
return el.w ?? (el.field === "qr" ? 28 : el.kind === "custom_image" ? 25 : 18);
|
||||
}
|
||||
|
||||
/** Square elements sized by width % of badge — height as % of badge height */
|
||||
export function squareElementHeightPct(widthPct: number, badgeAspectRatio: number): number {
|
||||
const ar = badgeAspectRatio > 0 ? badgeAspectRatio : 1;
|
||||
return widthPct * ar;
|
||||
}
|
||||
|
||||
export function clampElementSize(el: BadgeLayoutElement, w: number): number {
|
||||
return Math.min(RESIZABLE_SIZE_MAX, Math.max(RESIZABLE_SIZE_MIN, w));
|
||||
}
|
||||
|
|
@ -106,28 +165,24 @@ export function applyElementSize(
|
|||
const size = clampElementSize(el, w);
|
||||
const patch: Partial<BadgeLayoutElement> = { w: size };
|
||||
if (isSquareResizable(el) || el.field === "photo") {
|
||||
patch.h = size;
|
||||
patch.h = squareElementHeightPct(size, badgeAspectRatio);
|
||||
} else if (el.kind === "custom_image") {
|
||||
const ar = el.imageAspectRatio ?? 1;
|
||||
patch.h = heightPctForImageWidth(size, ar, badgeAspectRatio);
|
||||
}
|
||||
const merged = { ...el, ...patch };
|
||||
return { ...patch, ...clampElementPosition(merged, merged.x, merged.y) };
|
||||
return { ...patch, ...clampElementPosition(merged, merged.x, merged.y, badgeAspectRatio) };
|
||||
}
|
||||
|
||||
/** Size in % used for drag clamping (w=0 → no horizontal clamp) */
|
||||
export function elementBoundsPct(el: BadgeLayoutElement): { w: number; h: number } {
|
||||
if (el.field === "qr") {
|
||||
export function elementBoundsPct(el: BadgeLayoutElement, badgeAspectRatio = 1): { w: number; h: number } {
|
||||
if (el.field === "qr" || el.field === "photo") {
|
||||
const s = elementSizePct(el);
|
||||
return { w: s, h: s };
|
||||
return { w: s, h: squareElementHeightPct(s, badgeAspectRatio) };
|
||||
}
|
||||
if (el.kind === "custom_image") {
|
||||
return { w: el.w ?? 25, h: el.h ?? 25 };
|
||||
}
|
||||
if (el.field === "photo") {
|
||||
const s = elementSizePct(el);
|
||||
return { w: s, h: s };
|
||||
}
|
||||
if (isTextElement(el)) {
|
||||
if (usesFullWidthCenter(el)) {
|
||||
return { w: 0, h: Math.max(4, (el.fontSizePct ?? 5) * 2) };
|
||||
|
|
@ -141,8 +196,9 @@ export function clampElementPosition(
|
|||
el: BadgeLayoutElement,
|
||||
x: number,
|
||||
y: number,
|
||||
badgeAspectRatio = 1,
|
||||
): { x: number; y: number } {
|
||||
const bounds = elementBoundsPct(el);
|
||||
const bounds = elementBoundsPct(el, badgeAspectRatio);
|
||||
const maxX = bounds.w > 0 ? Math.max(0, 100 - bounds.w) : 100;
|
||||
const maxY = bounds.h > 0 ? Math.max(0, 100 - bounds.h) : 100;
|
||||
let cx = Math.min(maxX, Math.max(0, x));
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ export interface EventCreateFormInput {
|
|||
virtualLink: string;
|
||||
registrationRequired: "Yes" | "No";
|
||||
registrationMode: string;
|
||||
/** Auto Confirm → requiresApproval false; Manual Confirm → requiresApproval true. */
|
||||
registrationApproval?: "auto" | "manual";
|
||||
verifyEmail: "Yes" | "No";
|
||||
registrationDeadline: string;
|
||||
authenticationType: string;
|
||||
|
|
@ -310,12 +312,14 @@ export interface BuildEventCreatePayloadOpts {
|
|||
/** Body shape for POST/PUT /events (matches backend Joi + dashboard usage). */
|
||||
export function buildEventCreatePayload(form: EventCreateFormInput, opts?: BuildEventCreatePayloadOpts): Record<string, unknown> {
|
||||
const regReq = form.registrationRequired === "Yes";
|
||||
const mode = form.registrationMode;
|
||||
let mode = form.registrationMode;
|
||||
// Legacy value — never persist again; approval is a separate control
|
||||
if (mode === "Approval Required") mode = "Open";
|
||||
let registrationOpen = true;
|
||||
let requiresApproval = false;
|
||||
if (regReq) {
|
||||
if (mode === "Invite Only") registrationOpen = false;
|
||||
if (mode === "Approval Required") requiresApproval = true;
|
||||
requiresApproval = form.registrationApproval === "manual";
|
||||
} else {
|
||||
registrationOpen = false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,14 +62,16 @@ export function RadioGroup({
|
|||
value,
|
||||
onChange,
|
||||
options,
|
||||
disabled,
|
||||
}: {
|
||||
name: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
options: { label: string; value: string }[];
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: "flex", gap: "20px", alignItems: "center", flexWrap: "wrap" }}>
|
||||
<div style={{ display: "flex", gap: "20px", alignItems: "center", flexWrap: "wrap", opacity: disabled ? 0.55 : 1 }}>
|
||||
{options.map((o) => (
|
||||
<label
|
||||
key={o.value}
|
||||
|
|
@ -77,7 +79,7 @@ export function RadioGroup({
|
|||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
cursor: "pointer",
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
fontSize: "var(--text-sm)",
|
||||
fontFamily: "var(--font-noto-sans)",
|
||||
color: "var(--card-foreground)",
|
||||
|
|
@ -88,8 +90,9 @@ export function RadioGroup({
|
|||
name={name}
|
||||
value={o.value}
|
||||
checked={value === o.value}
|
||||
disabled={disabled}
|
||||
onChange={() => onChange(o.value)}
|
||||
style={{ accentColor: "var(--primary)", width: "16px", height: "16px", cursor: "pointer" }}
|
||||
style={{ accentColor: "var(--primary)", width: "16px", height: "16px", cursor: disabled ? "not-allowed" : "pointer" }}
|
||||
/>
|
||||
{o.label}
|
||||
</label>
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@ export interface EventForm {
|
|||
endDate: string;
|
||||
registrationRequired: "Yes" | "No";
|
||||
registrationMode: string;
|
||||
/** Auto Confirm → confirmed; Manual Confirm → pending until approved. */
|
||||
registrationApproval: "auto" | "manual";
|
||||
verifyEmail: "Yes" | "No";
|
||||
registrationDeadline: string;
|
||||
authenticationType: string;
|
||||
|
|
@ -91,6 +93,7 @@ export const INITIAL: EventForm = {
|
|||
endDate: "",
|
||||
registrationRequired: "No",
|
||||
registrationMode: "",
|
||||
registrationApproval: "auto",
|
||||
verifyEmail: "Yes",
|
||||
registrationDeadline: "",
|
||||
authenticationType: "otp_email",
|
||||
|
|
@ -155,12 +158,16 @@ export function apiEventToForm(ev: Record<string, unknown>): EventForm {
|
|||
let registrationRequired: "Yes" | "No" = "No";
|
||||
let registrationMode = "";
|
||||
const mode = st.registrationMode as string | undefined;
|
||||
const registrationApproval: "auto" | "manual" =
|
||||
st.requiresApproval === true || mode === "Approval Required" ? "manual" : "auto";
|
||||
|
||||
if (mode === "Invite Only") {
|
||||
registrationRequired = "Yes";
|
||||
registrationMode = "Invite Only";
|
||||
} else if (mode === "Approval Required" || st.requiresApproval === true) {
|
||||
} else if (mode === "Approval Required") {
|
||||
// Legacy: approval moved to registrationApproval; normalize mode to Open
|
||||
registrationRequired = "Yes";
|
||||
registrationMode = "Approval Required";
|
||||
registrationMode = "Open";
|
||||
} else if (mode === "Open" || st.registrationOpen === true) {
|
||||
registrationRequired = "Yes";
|
||||
registrationMode = "Open";
|
||||
|
|
@ -168,7 +175,7 @@ export function apiEventToForm(ev: Record<string, unknown>): EventForm {
|
|||
registrationRequired = "No";
|
||||
} else if (st.registrationOpen || st.requiresApproval) {
|
||||
registrationRequired = "Yes";
|
||||
registrationMode = st.requiresApproval ? "Approval Required" : "Open";
|
||||
registrationMode = "Open";
|
||||
}
|
||||
|
||||
const meta = (ev.metadata as Record<string, unknown>) || {};
|
||||
|
|
@ -210,6 +217,7 @@ export function apiEventToForm(ev: Record<string, unknown>): EventForm {
|
|||
endDate: toDateInput(ev.endDate as string),
|
||||
registrationRequired,
|
||||
registrationMode,
|
||||
registrationApproval,
|
||||
verifyEmail: st.verifyEmail === false ? "No" : "Yes",
|
||||
registrationDeadline: st.registrationDeadline ? toDateInput(st.registrationDeadline as string) : "",
|
||||
authenticationType: apiAuthenticationTypeToForm(st.authenticationType),
|
||||
|
|
@ -242,6 +250,7 @@ export function mergeActivitySettingsFormWithStored(form: EventForm, storedEvent
|
|||
endDate: t(form.endDate) || d.endDate,
|
||||
registrationRequired: form.registrationRequired,
|
||||
registrationMode: form.registrationMode || d.registrationMode,
|
||||
registrationApproval: form.registrationApproval || d.registrationApproval,
|
||||
verifyEmail: form.verifyEmail,
|
||||
registrationDeadline: form.registrationDeadline,
|
||||
authenticationType: form.authenticationType || d.authenticationType,
|
||||
|
|
|
|||
|
|
@ -18,11 +18,11 @@ export function ParticipantBulkActionBar({
|
|||
selectedCount: number;
|
||||
busy?: boolean;
|
||||
variant: ParticipantBulkVariant;
|
||||
onAddTags: () => void;
|
||||
onRemoveTags: () => void;
|
||||
onAddTags?: () => void;
|
||||
onRemoveTags?: () => void;
|
||||
onAssignEvents?: () => void;
|
||||
onNewList: () => void;
|
||||
onAddToList: () => void;
|
||||
onNewList?: () => void;
|
||||
onAddToList?: () => void;
|
||||
onDeleteSelected?: () => void;
|
||||
onRemoveFromEvent?: () => void;
|
||||
onClearSelection: () => void;
|
||||
|
|
@ -55,13 +55,13 @@ export function ParticipantBulkActionBar({
|
|||
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>
|
||||
{selectedCount} selected
|
||||
</span>
|
||||
<button type="button" disabled={busy} onClick={onAddTags} style={btnStyle}>Add tags</button>
|
||||
<button type="button" disabled={busy} onClick={onRemoveTags} style={btnStyle}>Remove tags</button>
|
||||
{onAddTags ? <button type="button" disabled={busy} onClick={onAddTags} style={btnStyle}>Add tags</button> : null}
|
||||
{onRemoveTags ? <button type="button" disabled={busy} onClick={onRemoveTags} style={btnStyle}>Remove tags</button> : null}
|
||||
{variant === "portfolio" && onAssignEvents ? (
|
||||
<button type="button" disabled={busy} onClick={onAssignEvents} style={btnStyle}>Assign to event(s)</button>
|
||||
) : null}
|
||||
<button type="button" disabled={busy} onClick={onNewList} style={btnStyle}>New custom list</button>
|
||||
<button type="button" disabled={busy} onClick={onAddToList} style={btnStyle}>Add to list</button>
|
||||
{onNewList ? <button type="button" disabled={busy} onClick={onNewList} style={btnStyle}>New custom list</button> : null}
|
||||
{onAddToList ? <button type="button" disabled={busy} onClick={onAddToList} style={btnStyle}>Add to list</button> : null}
|
||||
{variant === "portfolio" && onDeleteSelected ? (
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export function ParticipantBulkDialogs({
|
|||
onToggleTag,
|
||||
availableTags,
|
||||
removeTagOptions,
|
||||
tagScope = "global",
|
||||
newListName,
|
||||
onNewListNameChange,
|
||||
newListDesc,
|
||||
|
|
@ -37,6 +38,8 @@ export function ParticipantBulkDialogs({
|
|||
onToggleTag: (tag: string) => void;
|
||||
availableTags: string[];
|
||||
removeTagOptions: string[];
|
||||
/** Event page = tags stored on this event membership; global = org-wide root tags. */
|
||||
tagScope?: "event" | "global";
|
||||
newListName: string;
|
||||
onNewListNameChange: (v: string) => void;
|
||||
newListDesc: string;
|
||||
|
|
@ -99,12 +102,26 @@ export function ParticipantBulkDialogs({
|
|||
|
||||
if (bulkDialog === "addTags" || bulkDialog === "removeTags") {
|
||||
const tagOptions = bulkDialog === "removeTags" ? removeTagOptions : availableTags;
|
||||
const emptyMessage = bulkDialog === "removeTags" ? "No tags are currently assigned to the selected participants." : "No tags are available yet for this event.";
|
||||
const emptyMessage =
|
||||
bulkDialog === "removeTags"
|
||||
? "No tags are currently assigned to the selected participants."
|
||||
: tagScope === "event"
|
||||
? "No tags are available yet for this event."
|
||||
: "No tags are available yet for this organisation.";
|
||||
const scopeHint =
|
||||
tagScope === "event"
|
||||
? bulkDialog === "addTags"
|
||||
? "Tags apply to this event only (also shown on the person’s global record, not on other events)."
|
||||
: "Removes tags from this event only. Organisation-wide tags of the same name stay visible here."
|
||||
: bulkDialog === "addTags"
|
||||
? "Tags apply organisation-wide and appear on every event for these people."
|
||||
: "Removes the tag from the person entirely (organisation-wide and any event-specific copies).";
|
||||
|
||||
return overlay(
|
||||
<>
|
||||
<h3 style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-base)", margin: "0 0 8px" }}>{bulkDialog === "addTags" ? "Add tags" : "Remove tags"}</h3>
|
||||
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: "0 0 12px", fontFamily: "var(--font-noto-sans)" }}>Type a tag or select one below to apply to {selectedCount} selected participant(s).</p>
|
||||
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: "0 0 8px", fontFamily: "var(--font-noto-sans)" }}>Type a tag or select one below to apply to {selectedCount} selected participant(s).</p>
|
||||
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: "0 0 12px", fontFamily: "var(--font-noto-sans)" }}>{scopeHint}</p>
|
||||
<input
|
||||
value={manualTagInput}
|
||||
onChange={(e) => setManualTagInput(e.target.value)}
|
||||
|
|
|
|||
|
|
@ -2,11 +2,12 @@ import React, { useState, useRef, useEffect, useCallback } from "react";
|
|||
import { useBreakpoint } from "../../hooks/useBreakpoint";
|
||||
import { useAppNav } from "../../contexts/AppNavContext";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import { usePermission } from "../../contexts/PermissionContext";
|
||||
import {
|
||||
Search, Plus, Download, Upload, Eye, Pencil, Trash2, Check, X,
|
||||
Phone, Mail, Calendar, User, Filter, UserCheck,
|
||||
AlertCircle, List, LayoutGrid, ChevronDown, CheckCircle2, Clock,
|
||||
Users, UserX, ChevronLeft, ChevronRight, QrCode,
|
||||
Users, ChevronLeft, ChevronRight, QrCode,
|
||||
} from "lucide-react";
|
||||
import { participantsApi } from "../../../services/api";
|
||||
import type { Participant as ApiParticipant } from "../../../services/api/types";
|
||||
|
|
@ -42,7 +43,7 @@ interface ParticipantDisplay {
|
|||
function toDisplay(p: ApiParticipant): ParticipantDisplay {
|
||||
const name = [p.firstName, p.lastName].filter(Boolean).join(" ").trim() || "—";
|
||||
const regDate = p.registrationDate ? new Date(p.registrationDate).toLocaleDateString("en-IN", { day: "numeric", month: "short", year: "numeric" }) : null;
|
||||
const statusLabel = { pending: "Pending", confirmed: "Confirmed", checked_in: "Checked In", cancelled: "Cancelled" }[p.status] ?? p.status;
|
||||
const statusLabel = { pending: "Pending", confirmed: "Confirmed", checked_in: "Checked In", cancelled: "Cancelled", rejected: "Rejected" }[p.status] ?? p.status;
|
||||
return {
|
||||
id: p._id,
|
||||
name,
|
||||
|
|
@ -142,8 +143,18 @@ function CheckInBadge({ checked }: { checked: boolean }) {
|
|||
}
|
||||
|
||||
function StatusDot({ status }: { status: string }) {
|
||||
const color = status === "Cancelled" ? "var(--muted)" : status === "Checked In" ? "#15803d" : status === "Confirmed" ? "#2563eb" : "var(--card-foreground)";
|
||||
const dotColor = status === "Cancelled" ? "#9ca3af" : status === "Checked In" ? "#16a34a" : status === "Confirmed" ? "#2563eb" : "#94a3b8";
|
||||
const color =
|
||||
status === "Rejected" ? "var(--destructive)"
|
||||
: status === "Cancelled" ? "var(--muted)"
|
||||
: status === "Checked In" ? "#15803d"
|
||||
: status === "Confirmed" ? "#2563eb"
|
||||
: "var(--card-foreground)";
|
||||
const dotColor =
|
||||
status === "Rejected" ? "#dc2626"
|
||||
: status === "Cancelled" ? "#9ca3af"
|
||||
: status === "Checked In" ? "#16a34a"
|
||||
: status === "Confirmed" ? "#2563eb"
|
||||
: "#94a3b8";
|
||||
return (
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: "4px", fontSize: "11px", fontFamily: "var(--font-noto-sans)", color }}>
|
||||
<span style={{ width: "6px", height: "6px", borderRadius: "50%", background: dotColor, display: "block" }} />
|
||||
|
|
@ -152,6 +163,20 @@ function StatusDot({ status }: { status: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
const iconBtnStyle: React.CSSProperties = {
|
||||
width: "28px",
|
||||
height: "28px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 0,
|
||||
borderRadius: "var(--radius-button)",
|
||||
background: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
color: "var(--card-foreground)",
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
/* ─────────────── Filter Chip ─────────────── */
|
||||
|
||||
function FilterSelect({ label, value, options, onChange }: { label: string; value: string; options: string[]; onChange: (v: string) => void }) {
|
||||
|
|
@ -184,16 +209,19 @@ interface RowProps {
|
|||
onView: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onToggleCheckIn: () => void;
|
||||
onConfirm: () => void;
|
||||
onReject: () => void;
|
||||
onQr: () => void;
|
||||
canEdit?: boolean;
|
||||
canDelete?: boolean;
|
||||
selectable?: boolean;
|
||||
selected?: boolean;
|
||||
onToggleSelect?: () => void;
|
||||
}
|
||||
|
||||
function ParticipantRow({ p, onView, onEdit, onDelete, onToggleCheckIn, onQr, canEdit, selectable, selected, onToggleSelect }: RowProps) {
|
||||
function ParticipantRow({ p, onView, onEdit, onDelete, onConfirm, onReject, onQr, canEdit, canDelete, selectable, selected, onToggleSelect }: RowProps) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const isPending = p.status === "Pending";
|
||||
|
||||
return (
|
||||
<tr
|
||||
|
|
@ -260,39 +288,46 @@ function ParticipantRow({ p, onView, onEdit, onDelete, onToggleCheckIn, onQr, ca
|
|||
{/* Actions — always visible */}
|
||||
<td style={{ padding: "12px 16px 12px 8px", whiteSpace: "nowrap" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
|
||||
{/* Quick check-in */}
|
||||
<button
|
||||
onClick={onToggleCheckIn}
|
||||
title={p.checkedIn ? "Undo check-in" : "Mark checked in"}
|
||||
style={{
|
||||
display: "flex", alignItems: "center", gap: "4px",
|
||||
padding: "4px 10px", borderRadius: "var(--radius-button)",
|
||||
background: p.checkedIn ? "var(--secondary)" : "#dcfce7",
|
||||
border: `1px solid ${p.checkedIn ? "var(--border)" : "#86efac"}`,
|
||||
color: p.checkedIn ? "var(--muted)" : "#15803d",
|
||||
fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)",
|
||||
cursor: "pointer", fontWeight: "var(--font-weight-medium)",
|
||||
}}
|
||||
>
|
||||
{p.checkedIn ? <X size={11} /> : <Check size={11} />}
|
||||
{p.checkedIn ? "Undo" : "Check In"}
|
||||
</button>
|
||||
{isPending && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
title="Confirm registration"
|
||||
aria-label="Confirm registration"
|
||||
style={{
|
||||
display: "flex", alignItems: "center", gap: "4px",
|
||||
padding: "4px 10px", borderRadius: "var(--radius-button)",
|
||||
background: "#dcfce7", border: "1px solid #86efac", color: "#15803d",
|
||||
fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)",
|
||||
cursor: "pointer", fontWeight: "var(--font-weight-medium)",
|
||||
}}
|
||||
>
|
||||
<Check size={11} /> Confirm
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReject}
|
||||
title="Reject registration"
|
||||
aria-label="Reject registration"
|
||||
style={{
|
||||
display: "flex", alignItems: "center", gap: "4px",
|
||||
padding: "4px 10px", borderRadius: "var(--radius-button)",
|
||||
background: "#fef2f2", border: "1px solid #fecaca", color: "#b91c1c",
|
||||
fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)",
|
||||
cursor: "pointer", fontWeight: "var(--font-weight-medium)",
|
||||
}}
|
||||
>
|
||||
<X size={11} /> Reject
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onQr}
|
||||
title="Check-in QR code (EventBuzz Checkins app)"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
padding: "4px 10px",
|
||||
borderRadius: "var(--radius-button)",
|
||||
background: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
color: "var(--card-foreground)",
|
||||
fontSize: "var(--text-xs)",
|
||||
fontFamily: "var(--font-noto-sans)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
aria-label="Check-in QR code"
|
||||
style={iconBtnStyle}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.borderColor = "var(--primary)";
|
||||
(e.currentTarget as HTMLElement).style.color = "var(--primary)";
|
||||
|
|
@ -302,32 +337,45 @@ function ParticipantRow({ p, onView, onEdit, onDelete, onToggleCheckIn, onQr, ca
|
|||
(e.currentTarget as HTMLElement).style.color = "var(--card-foreground)";
|
||||
}}
|
||||
>
|
||||
<QrCode size={11} /> QR
|
||||
<QrCode size={13} />
|
||||
</button>
|
||||
{/* View */}
|
||||
<button onClick={onView} title="View profile"
|
||||
style={{ display: "flex", alignItems: "center", gap: "4px", padding: "4px 10px", borderRadius: "var(--radius-button)", background: "var(--card)", border: "1px solid var(--border)", color: "var(--card-foreground)", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", cursor: "pointer" }}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onView}
|
||||
title="View profile"
|
||||
aria-label="View profile"
|
||||
style={iconBtnStyle}
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--card-foreground)"; }}
|
||||
>
|
||||
<Eye size={11} /> View
|
||||
<Eye size={13} />
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button onClick={onEdit} title="Edit participant"
|
||||
style={{ display: "flex", alignItems: "center", gap: "4px", padding: "4px 10px", borderRadius: "var(--radius-button)", background: "var(--card)", border: "1px solid var(--border)", color: "var(--card-foreground)", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", cursor: "pointer" }}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
title="Edit participant"
|
||||
aria-label="Edit participant"
|
||||
style={iconBtnStyle}
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--card-foreground)"; }}
|
||||
>
|
||||
<Pencil size={11} /> Edit
|
||||
<Pencil size={13} />
|
||||
</button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
title="Delete participant"
|
||||
aria-label="Delete participant"
|
||||
style={{ ...iconBtnStyle, color: "var(--destructive)" }}
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = "#fef2f2"; (e.currentTarget as HTMLElement).style.borderColor = "var(--destructive)"; }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = "var(--card)"; (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; }}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={onDelete} title="Delete participant"
|
||||
style={{ display: "flex", alignItems: "center", gap: "4px", padding: "4px 10px", borderRadius: "var(--radius-button)", background: "var(--card)", border: "1px solid var(--border)", color: "var(--destructive)", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", cursor: "pointer" }}
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = "#fef2f2"; (e.currentTarget as HTMLElement).style.borderColor = "var(--destructive)"; }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = "var(--card)"; (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; }}
|
||||
>
|
||||
<Trash2 size={11} /> Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -336,8 +384,9 @@ function ParticipantRow({ p, onView, onEdit, onDelete, onToggleCheckIn, onQr, ca
|
|||
|
||||
/* ─────────────── Participant Card ─────────────── */
|
||||
|
||||
function ParticipantCard({ p, onView, onEdit, onDelete, onToggleCheckIn, onQr, canEdit, selectable, selected, onToggleSelect }: RowProps) {
|
||||
function ParticipantCard({ p, onView, onEdit, onDelete, onConfirm, onReject, onQr, canEdit, canDelete, selectable, selected, onToggleSelect }: RowProps) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const isPending = p.status === "Pending";
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -371,22 +420,6 @@ function ParticipantCard({ p, onView, onEdit, onDelete, onToggleCheckIn, onQr, c
|
|||
<StatusDot status={p.status} />
|
||||
</div>
|
||||
</div>
|
||||
{/* Check-in quick toggle — always visible */}
|
||||
<button
|
||||
onClick={onToggleCheckIn}
|
||||
title={p.checkedIn ? "Undo check-in" : "Mark as checked in"}
|
||||
style={{
|
||||
width: "28px", height: "28px", flexShrink: 0,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
borderRadius: "50%",
|
||||
background: p.checkedIn ? "#dcfce7" : "var(--secondary)",
|
||||
border: `1px solid ${p.checkedIn ? "#86efac" : "var(--border)"}`,
|
||||
color: p.checkedIn ? "#15803d" : "var(--muted)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<Check size={13} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Contact details */}
|
||||
|
|
@ -407,13 +440,48 @@ function ParticipantCard({ p, onView, onEdit, onDelete, onToggleCheckIn, onQr, c
|
|||
)}
|
||||
</div>
|
||||
|
||||
{isPending && (
|
||||
<div style={{ display: "flex", gap: "6px" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
title="Confirm registration"
|
||||
style={{
|
||||
flex: 1, display: "flex", alignItems: "center", justifyContent: "center", gap: "4px",
|
||||
padding: "6px 8px", borderRadius: "var(--radius-button)",
|
||||
background: "#dcfce7", border: "1px solid #86efac", color: "#15803d",
|
||||
fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)",
|
||||
cursor: "pointer", fontWeight: "var(--font-weight-medium)",
|
||||
}}
|
||||
>
|
||||
<Check size={11} /> Confirm
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReject}
|
||||
title="Reject registration"
|
||||
style={{
|
||||
flex: 1, display: "flex", alignItems: "center", justifyContent: "center", gap: "4px",
|
||||
padding: "6px 8px", borderRadius: "var(--radius-button)",
|
||||
background: "#fef2f2", border: "1px solid #fecaca", color: "#b91c1c",
|
||||
fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)",
|
||||
cursor: "pointer", fontWeight: "var(--font-weight-medium)",
|
||||
}}
|
||||
>
|
||||
<X size={11} /> Reject
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer: check-in badge + actions */}
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", paddingTop: "10px", borderTop: "1px solid var(--border)" }}>
|
||||
<CheckInBadge checked={p.checkedIn} />
|
||||
<div style={{ display: "flex", gap: "4px" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onQr}
|
||||
title="Check-in QR code"
|
||||
aria-label="Check-in QR code"
|
||||
style={{
|
||||
width: "26px",
|
||||
height: "26px",
|
||||
|
|
@ -435,26 +503,28 @@ function ParticipantCard({ p, onView, onEdit, onDelete, onToggleCheckIn, onQr, c
|
|||
>
|
||||
<QrCode size={12} />
|
||||
</button>
|
||||
<button onClick={onView} title="View" style={{ width: "26px", height: "26px", display: "flex", alignItems: "center", justifyContent: "center", background: "var(--secondary)", border: "1px solid var(--border)", borderRadius: "var(--radius-button)", cursor: "pointer", color: "var(--muted)" }}
|
||||
<button type="button" onClick={onView} title="View" aria-label="View profile" style={{ width: "26px", height: "26px", display: "flex", alignItems: "center", justifyContent: "center", background: "var(--secondary)", border: "1px solid var(--border)", borderRadius: "var(--radius-button)", cursor: "pointer", color: "var(--muted)" }}
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}
|
||||
>
|
||||
<Eye size={12} />
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button onClick={onEdit} title="Edit" style={{ width: "26px", height: "26px", display: "flex", alignItems: "center", justifyContent: "center", background: "var(--secondary)", border: "1px solid var(--border)", borderRadius: "var(--radius-button)", cursor: "pointer", color: "var(--muted)" }}
|
||||
<button type="button" onClick={onEdit} title="Edit" aria-label="Edit participant" style={{ width: "26px", height: "26px", display: "flex", alignItems: "center", justifyContent: "center", background: "var(--secondary)", border: "1px solid var(--border)", borderRadius: "var(--radius-button)", cursor: "pointer", color: "var(--muted)" }}
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}
|
||||
>
|
||||
<Pencil size={12} />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={onDelete} title="Delete" style={{ width: "26px", height: "26px", display: "flex", alignItems: "center", justifyContent: "center", background: "var(--secondary)", border: "1px solid var(--border)", borderRadius: "var(--radius-button)", cursor: "pointer", color: "var(--muted)" }}
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.color = "var(--destructive)"; }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
{canDelete && (
|
||||
<button type="button" onClick={onDelete} title="Delete" aria-label="Delete participant" style={{ width: "26px", height: "26px", display: "flex", alignItems: "center", justifyContent: "center", background: "var(--secondary)", border: "1px solid var(--border)", borderRadius: "var(--radius-button)", cursor: "pointer", color: "var(--muted)" }}
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.color = "var(--destructive)"; }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -515,8 +585,8 @@ function ViewModal({ p, onClose, onEdit, canEdit }: { p: ParticipantDisplay; onC
|
|||
);
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS = ["Pending", "Confirmed", "Checked In", "Cancelled"];
|
||||
const STATUS_API_MAP: Record<string, string> = { "Pending": "pending", "Confirmed": "confirmed", "Checked In": "checked_in", "Cancelled": "cancelled" };
|
||||
const STATUS_OPTIONS = ["Pending", "Confirmed", "Checked In", "Cancelled", "Rejected"];
|
||||
const STATUS_API_MAP: Record<string, string> = { "Pending": "pending", "Confirmed": "confirmed", "Checked In": "checked_in", "Cancelled": "cancelled", "Rejected": "rejected" };
|
||||
const DESIGNATION_OPTIONS = ["Delegate", "Speaker", "Moderator", "Panelist"];
|
||||
|
||||
/* ─────────────── Confirm Modal ─────────────── */
|
||||
|
|
@ -553,10 +623,14 @@ export type ParticipantsPageProps = {
|
|||
export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProps = {}) {
|
||||
const { selectedEventId, setActiveNav } = useAppNav();
|
||||
const { user } = useAuth();
|
||||
const { canModule } = usePermission();
|
||||
const canWriteParticipants = canModule("participants", "write");
|
||||
const canDeleteParticipants = canModule("participants", "delete");
|
||||
const listOrgId = portfolioListOrganizationId(user);
|
||||
const bp = useBreakpoint();
|
||||
const isMobile = bp === "mobile";
|
||||
const isSuperAdmin = user?.role === "super_admin";
|
||||
const canEditParticipants = canWriteParticipants || isSuperAdmin;
|
||||
const enableBulk = !compactChrome;
|
||||
|
||||
const [list, setList] = useState<ParticipantDisplay[]>([]);
|
||||
|
|
@ -678,7 +752,6 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
|
|||
|
||||
const totalPages = Math.max(1, Math.ceil(total / limit));
|
||||
const checkedIn = list.filter(p => p.checkedIn).length;
|
||||
const speakers = list.filter(p => ["Speaker", "Moderator", "Panelist"].includes(p.role)).length;
|
||||
|
||||
const deleteParticipant = (id: string) => {
|
||||
const p = list.find(x => x.id === id);
|
||||
|
|
@ -691,9 +764,20 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
|
|||
});
|
||||
};
|
||||
|
||||
const toggleCheckIn = (id: string) => {
|
||||
const confirmRegistration = (id: string) => {
|
||||
if (!selectedEventId) return;
|
||||
participantsApi.checkIn(id, selectedEventId).then(() => fetchList());
|
||||
participantsApi.confirm(id, selectedEventId).then((res) => {
|
||||
if (res.error) setPageMsg(res.error);
|
||||
else fetchList();
|
||||
});
|
||||
};
|
||||
|
||||
const rejectRegistration = (id: string) => {
|
||||
if (!selectedEventId) return;
|
||||
participantsApi.reject(id, selectedEventId).then((res) => {
|
||||
if (res.error) setPageMsg(res.error);
|
||||
else fetchList();
|
||||
});
|
||||
};
|
||||
|
||||
const exportAllCsv = async () => {
|
||||
|
|
@ -810,27 +894,6 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Stats strip ── */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: isMobile ? "repeat(2, 1fr)" : "repeat(4, 1fr)", gap: isMobile ? "10px" : "12px" }}>
|
||||
{[
|
||||
{ icon: <Users size={14} />, label: "Total Registered", value: total, accent: "var(--chart-1)" },
|
||||
{ icon: <UserCheck size={14} />, label: "Checked In (this page)", value: checkedIn, accent: "var(--chart-2)", sub: list.length ? `${Math.round((checkedIn / list.length) * 100)}%` : "" },
|
||||
{ icon: <UserX size={14} />, label: "Not Checked In (this page)", value: list.length - checkedIn, accent: "var(--chart-3)" },
|
||||
{ icon: <User size={14} />, label: "Speakers & Staff (this page)", value: speakers, accent: "var(--chart-4)" },
|
||||
].map(stat => (
|
||||
<div key={stat.label} style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: "var(--radius-card)", padding: "14px 16px", display: "flex", alignItems: "center", gap: "12px", borderLeft: `3px solid ${stat.accent}` }}>
|
||||
<div style={{ width: "32px", height: "32px", borderRadius: "var(--radius-button)", background: stat.accent + "15", display: "flex", alignItems: "center", justifyContent: "center", color: stat.accent, flexShrink: 0 }}>
|
||||
{stat.icon}
|
||||
</div>
|
||||
<div>
|
||||
<p style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-2xl)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", lineHeight: "1.1" }}>{stat.value}</p>
|
||||
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginTop: "2px" }}>{stat.label}</p>
|
||||
{stat.sub && <p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "#16a34a", marginTop: "3px" }}>{stat.sub}</p>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Filter + search bar ── */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
||||
{/* Top row: search + filter toggle + view toggle */}
|
||||
|
|
@ -906,9 +969,13 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
|
|||
onRemoveTags={() => void bulk.openBulkDialog("removeTags")}
|
||||
onNewList={() => void bulk.openBulkDialog("newList")}
|
||||
onAddToList={() => void bulk.openBulkDialog("addToList")}
|
||||
onRemoveFromEvent={() => {
|
||||
if (window.confirm(`Remove ${selectedIds.length} participant(s) from this event?`)) void bulk.runBulkRemoveFromEvent();
|
||||
}}
|
||||
onRemoveFromEvent={
|
||||
canDeleteParticipants
|
||||
? () => {
|
||||
if (window.confirm(`Remove ${selectedIds.length} participant(s) from this event?`)) void bulk.runBulkRemoveFromEvent();
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onClearSelection={clearSelection}
|
||||
/>
|
||||
) : null}
|
||||
|
|
@ -963,9 +1030,11 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
|
|||
onView={() => setViewModal(p)}
|
||||
onEdit={() => { setEditId(p.id); setShowAddPanel(true); }}
|
||||
onDelete={() => deleteParticipant(p.id)}
|
||||
onToggleCheckIn={() => toggleCheckIn(p.id)}
|
||||
onConfirm={() => confirmRegistration(p.id)}
|
||||
onReject={() => rejectRegistration(p.id)}
|
||||
onQr={() => setQrParticipant(p)}
|
||||
canEdit={isSuperAdmin}
|
||||
canEdit={canEditParticipants}
|
||||
canDelete={canDeleteParticipants}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
|
|
@ -1001,9 +1070,11 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
|
|||
onView={() => setViewModal(p)}
|
||||
onEdit={() => { setEditId(p.id); setShowAddPanel(true); }}
|
||||
onDelete={() => deleteParticipant(p.id)}
|
||||
onToggleCheckIn={() => toggleCheckIn(p.id)}
|
||||
onConfirm={() => confirmRegistration(p.id)}
|
||||
onReject={() => rejectRegistration(p.id)}
|
||||
onQr={() => setQrParticipant(p)}
|
||||
canEdit={isSuperAdmin}
|
||||
canEdit={canEditParticipants}
|
||||
canDelete={canDeleteParticipants}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -1041,7 +1112,7 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
|
|||
p={viewModal}
|
||||
onClose={() => setViewModal(null)}
|
||||
onEdit={() => { setEditId(viewModal.id); setViewModal(null); setShowAddPanel(true); }}
|
||||
canEdit={isSuperAdmin}
|
||||
canEdit={canEditParticipants}
|
||||
/>
|
||||
)}
|
||||
{qrParticipant && (
|
||||
|
|
@ -1097,6 +1168,7 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
|
|||
onToggleTag={bulk.toggleSelectedTag}
|
||||
availableTags={tagOptions}
|
||||
removeTagOptions={Array.from(new Set(list.filter((participant) => selectedIds.includes(participant.id)).flatMap((participant) => participant.tags)))}
|
||||
tagScope={selectedEventId ? "event" : "global"}
|
||||
newListName={bulk.newListName}
|
||||
onNewListNameChange={bulk.setNewListName}
|
||||
newListDesc={bulk.newListDesc}
|
||||
|
|
|
|||
|
|
@ -70,7 +70,12 @@ export function useParticipantBulkActions(options: {
|
|||
const tags = (overrideTags?.length ? overrideTags : selectedTagValues).filter(Boolean);
|
||||
if (!tags.length) return;
|
||||
setBulkBusy(true);
|
||||
const res = await participantsApi.bulk({ action, participantIds: selectedIds, tags });
|
||||
const res = await participantsApi.bulk({
|
||||
action,
|
||||
participantIds: selectedIds,
|
||||
tags,
|
||||
...(eventId ? { eventId } : {}),
|
||||
});
|
||||
setBulkBusy(false);
|
||||
if (res.error) onMessage(res.error);
|
||||
else {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,18 @@
|
|||
import React, { useState, useMemo } from "react";
|
||||
import React, { useState, useMemo, useEffect } from "react";
|
||||
import {
|
||||
Search, X, ArrowRight, MapPin, Calendar, Users,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
doSearch, ALL_EVENTS, ALL_PAGES, ALL_ACTIONS,
|
||||
doSearch, ALL_PAGES, ALL_ACTIONS,
|
||||
STATUS_BADGE, LOC_ICON,
|
||||
mapApiEventToSearchEvent,
|
||||
SearchEvent, SearchPage, SearchAction,
|
||||
} from "./searchIndex";
|
||||
import { useBreakpoint } from "../../hooks/useBreakpoint";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import { eventsApi } from "../../../services/api";
|
||||
import { portfolioListOrganizationId } from "../../../services/api/scopedOrganization";
|
||||
|
||||
/* ─────────────────────────────────────────
|
||||
Props
|
||||
|
|
@ -230,8 +234,36 @@ export function SearchResultsPage({ query, onQueryChange, onNavigate, onEventSel
|
|||
const [activeTab, setActiveTab] = useState<FilterTab>("all");
|
||||
const bp = useBreakpoint();
|
||||
const isMobile = bp === "mobile";
|
||||
const { user } = useAuth();
|
||||
const listOrgId = portfolioListOrganizationId(user);
|
||||
const [orgSearchEvents, setOrgSearchEvents] = useState<SearchEvent[]>([]);
|
||||
|
||||
const results = useMemo(() => doSearch(query), [query]);
|
||||
useEffect(() => {
|
||||
if (!listOrgId) {
|
||||
setOrgSearchEvents([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const res = await eventsApi.list({
|
||||
organization: listOrgId,
|
||||
recordKind: "event",
|
||||
page: 1,
|
||||
limit: 200,
|
||||
});
|
||||
if (cancelled) return;
|
||||
if (res.error || !res.data?.events) {
|
||||
setOrgSearchEvents([]);
|
||||
return;
|
||||
}
|
||||
setOrgSearchEvents(res.data.events.map(mapApiEventToSearchEvent));
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [listOrgId]);
|
||||
|
||||
const results = useMemo(() => doSearch(query, orgSearchEvents), [query, orgSearchEvents]);
|
||||
const trimmed = query.trim();
|
||||
|
||||
/* Filter tab data */
|
||||
|
|
@ -312,9 +344,14 @@ export function SearchResultsPage({ query, onQueryChange, onNavigate, onEventSel
|
|||
Recent Events
|
||||
</p>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
|
||||
{ALL_EVENTS.slice(0, 5).map(e => (
|
||||
{orgSearchEvents.slice(0, 5).map(e => (
|
||||
<EventCard key={e.id} event={e} query="" onClick={() => handleEventClick(e)} isActive={false} />
|
||||
))}
|
||||
{orgSearchEvents.length === 0 && (
|
||||
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", margin: 0 }}>
|
||||
No events in this organisation yet.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -44,21 +44,58 @@ export const LOC_ICON: Record<LocationType, React.ReactNode> = {
|
|||
Hybrid: <Zap size={10} />,
|
||||
};
|
||||
|
||||
/* ─── Events ─── */
|
||||
export const ALL_EVENTS: SearchEvent[] = [
|
||||
{ id: "e1", title: "Napcon Jaipur 2026", subtitle: "27th National Conference on Pulmonary Diseases", status: "Live", type: "Conference", location: "Birla Auditorium, Jaipur", locationType: "Physical", startDate: "Nov 23, 2025", endDate: "Nov 26, 2025", participants: 26, sessions: 12 },
|
||||
{ id: "e2", title: "Napcon Mumbai 2025", subtitle: "26th National Conference on Pulmonary Diseases", status: "Completed", type: "Conference", location: "NSCI Dome, Mumbai", locationType: "Physical", startDate: "Oct 12, 2025", endDate: "Oct 15, 2025", participants: 342, sessions: 28 },
|
||||
{ id: "e3", title: "CHEST India 2026", subtitle: "Annual Indian CHEST Conference", status: "Upcoming", type: "Conference", location: "Taj Palace, New Delhi", locationType: "Physical", startDate: "Mar 15, 2026", endDate: "Mar 18, 2026", participants: 0, sessions: 0 },
|
||||
{ id: "e4", title: "ICS Annual Conference 2026", subtitle: "Indian Chest Society Annual Scientific Conference", status: "Upcoming", type: "Conference", location: "Hotel Lalit, Bengaluru", locationType: "Hybrid", startDate: "Jun 10, 2026", endDate: "Jun 12, 2026", participants: 0, sessions: 0 },
|
||||
{ id: "e5", title: "COPD Management Workshop", subtitle: "Evidence-based COPD Treatment Protocols", status: "Completed", type: "Workshop", location: "AIIMS, New Delhi", locationType: "Physical", startDate: "Sep 05, 2025", endDate: "Sep 05, 2025", participants: 48, sessions: 6 },
|
||||
{ id: "e6", title: "Interventional Pulmonology", subtitle: "Advanced Bronchoscopy & Intervention Training", status: "Completed", type: "Workshop", location: "PGI Chandigarh", locationType: "Physical", startDate: "Nov 28, 2025", endDate: "Nov 29, 2025", participants: 22, sessions: 8 },
|
||||
{ id: "e7", title: "Respiratory Physiology Lab", subtitle: "Masterclass: PFT & Interpretation", status: "Upcoming", type: "Workshop", location: "Online", locationType: "Virtual", startDate: "Apr 10, 2026", endDate: "Apr 10, 2026", participants: 0, sessions: 0 },
|
||||
{ id: "e8", title: "IMA Annual Summit 2025", subtitle: "Indian Medical Association National Congress", status: "Completed", type: "Summit", location: "Vigyan Bhavan, New Delhi", locationType: "Physical", startDate: "Aug 20, 2025", endDate: "Aug 22, 2025", participants: 520, sessions: 18 },
|
||||
{ id: "e9", title: "AIIMS Research Conclave 2026",subtitle: "National Medical Research & Innovation Summit", status: "Upcoming", type: "Summit", location: "AIIMS Auditorium, New Delhi", locationType: "Physical", startDate: "Jul 15, 2026", endDate: "Jul 17, 2026", participants: 0, sessions: 0 },
|
||||
{ id: "e10", title: "Monthly CME – December 2025", subtitle: "Asthma & Allergy Management Update", status: "Upcoming", type: "CME", location: "Online", locationType: "Virtual", startDate: "Dec 20, 2025", endDate: "Dec 20, 2025", participants: 0, sessions: 0 },
|
||||
{ id: "e11", title: "Monthly CME – November 2025", subtitle: "COPD Exacerbations: Case Studies", status: "Completed", type: "CME", location: "Online", locationType: "Virtual", startDate: "Nov 20, 2025", endDate: "Nov 20, 2025", participants: 65, sessions: 4 },
|
||||
{ id: "e12", title: "Monthly CME – October 2025", subtitle: "Sleep Apnoea: Diagnosis & Management", status: "Completed", type: "CME", location: "Online", locationType: "Virtual", startDate: "Oct 18, 2025", endDate: "Oct 18, 2025", participants: 72, sessions: 4 },
|
||||
];
|
||||
/* ─── Events (populated at runtime from current-org API; no cross-org demo data) ─── */
|
||||
export const ALL_EVENTS: SearchEvent[] = [];
|
||||
|
||||
function formatShortDate(iso: string | undefined): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
return d.toLocaleDateString("en-IN", { day: "numeric", month: "short", year: "numeric" });
|
||||
}
|
||||
|
||||
function toEventStatus(raw: string | undefined): EventStatus {
|
||||
const k = String(raw || "draft").toLowerCase();
|
||||
if (k === "live") return "Live";
|
||||
if (k === "upcoming") return "Upcoming";
|
||||
if (k === "completed") return "Completed";
|
||||
return "Draft";
|
||||
}
|
||||
|
||||
function toLocationType(raw: string | undefined): LocationType {
|
||||
const k = String(raw || "physical").toLowerCase();
|
||||
if (k === "virtual") return "Virtual";
|
||||
if (k === "hybrid") return "Hybrid";
|
||||
return "Physical";
|
||||
}
|
||||
|
||||
/** Map a dashboard Event API record into the search index shape. */
|
||||
export function mapApiEventToSearchEvent(ev: {
|
||||
_id: string;
|
||||
name?: string;
|
||||
subtitle?: string;
|
||||
description?: string;
|
||||
status?: string;
|
||||
type?: string;
|
||||
location?: string;
|
||||
locationType?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}): SearchEvent {
|
||||
return {
|
||||
id: String(ev._id),
|
||||
title: String(ev.name || "Untitled event"),
|
||||
subtitle: String(ev.subtitle || ev.description || "").slice(0, 160),
|
||||
status: toEventStatus(ev.status),
|
||||
type: String(ev.type || "Conference"),
|
||||
location: String(ev.location || (ev.locationType === "virtual" ? "Online" : "")),
|
||||
locationType: toLocationType(ev.locationType),
|
||||
startDate: formatShortDate(ev.startDate),
|
||||
endDate: formatShortDate(ev.endDate),
|
||||
participants: 0,
|
||||
sessions: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/* ─── Pages ─── */
|
||||
export const ALL_PAGES: SearchPage[] = [
|
||||
|
|
@ -113,13 +150,13 @@ export const SUGGESTED_SHORTCUTS: { label: string; navId: string; icon: React.Re
|
|||
];
|
||||
|
||||
/* ─── Search function ─── */
|
||||
export function doSearch(query: string): SearchResults {
|
||||
export function doSearch(query: string, orgEvents: SearchEvent[] = ALL_EVENTS): SearchResults {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return { events: [], pages: [], actions: [], total: 0 };
|
||||
|
||||
const match = (...strs: string[]) => strs.some(s => s.toLowerCase().includes(q));
|
||||
|
||||
const events = ALL_EVENTS.filter(e =>
|
||||
const events = orgEvents.filter(e =>
|
||||
match(e.title, e.subtitle, e.location, e.type, e.status));
|
||||
const pages = ALL_PAGES.filter(p =>
|
||||
match(p.title, p.section, p.description));
|
||||
|
|
|
|||
|
|
@ -30,6 +30,106 @@ function parseDatetimeLocal(s: string): Date | undefined {
|
|||
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||
}
|
||||
|
||||
function parseDateOnly(s: string): Date | undefined {
|
||||
const raw = s.trim();
|
||||
if (!raw) return undefined;
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
|
||||
const [y, m, d] = raw.split("-").map(Number);
|
||||
const date = new Date(y, m - 1, d);
|
||||
return Number.isNaN(date.getTime()) ? undefined : date;
|
||||
}
|
||||
const d = new Date(raw);
|
||||
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||
}
|
||||
|
||||
/** Local calendar date as `YYYY-MM-DD`. */
|
||||
export function toDateOnlyValue(isoOrDate: string | Date | undefined): string {
|
||||
if (!isoOrDate) return "";
|
||||
const d = typeof isoOrDate === "string" ? parseDateOnly(isoOrDate) : isoOrDate;
|
||||
if (!d || Number.isNaN(d.getTime())) return "";
|
||||
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
||||
}
|
||||
|
||||
export interface FlatpickrDateInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
style?: React.CSSProperties;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
hasError?: boolean;
|
||||
}
|
||||
|
||||
/** Date-only picker (Flatpickr) that reads/writes `YYYY-MM-DD`. */
|
||||
export function FlatpickrDateInput({
|
||||
value,
|
||||
onChange,
|
||||
style,
|
||||
disabled,
|
||||
placeholder = "Select date…",
|
||||
hasError,
|
||||
}: FlatpickrDateInputProps) {
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const fpRef = React.useRef<FlatpickrHandle | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const el = inputRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const fp = flatpickr(el, {
|
||||
enableTime: false,
|
||||
dateFormat: "Y-m-d",
|
||||
allowInput: true,
|
||||
defaultDate: parseDateOnly(value),
|
||||
onChange: dates => {
|
||||
const d = dates[0];
|
||||
onChange(d ? toDateOnlyValue(d) : "");
|
||||
},
|
||||
}) as FlatpickrHandle;
|
||||
fpRef.current = fp;
|
||||
|
||||
return () => {
|
||||
fp.destroy();
|
||||
fpRef.current = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- init once
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const fp = fpRef.current;
|
||||
if (!fp) return;
|
||||
|
||||
const selected = fp.selectedDates[0];
|
||||
const cur = selected ? toDateOnlyValue(selected) : "";
|
||||
if (value === cur) return;
|
||||
|
||||
const d = parseDateOnly(value);
|
||||
if (d) fp.setDate(d, false);
|
||||
else fp.clear(false);
|
||||
}, [value]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const fp = fpRef.current;
|
||||
if (!fp) return;
|
||||
const input = fp.input as HTMLInputElement;
|
||||
input.disabled = !!disabled;
|
||||
fp.set("clickOpens", !disabled);
|
||||
}, [disabled]);
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder={placeholder}
|
||||
readOnly={false}
|
||||
autoComplete="off"
|
||||
style={{
|
||||
...style,
|
||||
...(hasError ? { borderColor: "var(--destructive)" } : {}),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export interface FlatpickrDatetimeInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
|
|
|
|||
|
|
@ -12,11 +12,16 @@ interface PermissionContextValue {
|
|||
loading: boolean;
|
||||
}
|
||||
|
||||
const ACTION_IMPLIES: Record<AclAction, AclAction[]> = {
|
||||
read: ["read"],
|
||||
write: ["read", "write"],
|
||||
delete: ["read", "write", "delete"],
|
||||
};
|
||||
/** Higher privileges grant lower ones: delete ⇒ write ⇒ read (matches backend actionsInclude). */
|
||||
function actionsInclude(entryActions: string[], action: AclAction): boolean {
|
||||
if (!Array.isArray(entryActions)) return false;
|
||||
if (entryActions.includes(action)) return true;
|
||||
if (action === "read" && (entryActions.includes("write") || entryActions.includes("delete"))) {
|
||||
return true;
|
||||
}
|
||||
if (action === "write" && entryActions.includes("delete")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
const PermissionContext = createContext<PermissionContextValue | null>(null);
|
||||
|
||||
|
|
@ -29,7 +34,7 @@ export function PermissionProvider({ children }: { children: React.ReactNode })
|
|||
if (!permissions) return user?.role === "super_admin";
|
||||
if (permissions.isOrgSuperAdmin) return true;
|
||||
const actions = permissions.modules?.[moduleKey] || [];
|
||||
return (ACTION_IMPLIES[action] || [action]).some((a) => actions.includes(a));
|
||||
return actionsInclude(actions, action);
|
||||
},
|
||||
[permissions, user?.role],
|
||||
);
|
||||
|
|
@ -72,6 +77,8 @@ export const NAV_MODULE_MAP: Record<string, string> = {
|
|||
activities: "live_polls",
|
||||
faculty: "speaker_management",
|
||||
"all-participants": "participants",
|
||||
"participant-lists": "participants",
|
||||
attendance: "attendance",
|
||||
forms: "forms",
|
||||
"forms-feedback": "forms",
|
||||
"forms-registration": "forms",
|
||||
|
|
@ -85,7 +92,6 @@ export const NAV_MODULE_MAP: Record<string, string> = {
|
|||
payment: "billing",
|
||||
"user-management": "user_management",
|
||||
"roles-management": "user_management",
|
||||
"participant-lists": "participants",
|
||||
"messaging-whatsapp-chatbots": "custom_template_for_wa_messaging",
|
||||
settings: "settings",
|
||||
design: "custom_badge_and_print_file_creation",
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
const [rows, setRows] = useState<OrganizationRecord[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
|
@ -220,7 +226,7 @@ export function SuperAdminClientsPage() {
|
|||
<input
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="Search by name or email"
|
||||
placeholder="Search by org name, email, or admin user"
|
||||
style={{ ...inputStyle, flex: "1 1 220px", maxWidth: "360px" }}
|
||||
/>
|
||||
<button
|
||||
|
|
@ -252,7 +258,8 @@ export function SuperAdminClientsPage() {
|
|||
<thead>
|
||||
<tr style={{ background: "var(--accent)", textAlign: "left" }}>
|
||||
<th style={{ padding: "10px 12px", fontWeight: 600 }}>Name</th>
|
||||
<th style={{ padding: "10px 12px", fontWeight: 600 }}>Email</th>
|
||||
<th style={{ padding: "10px 12px", fontWeight: 600 }}>Org email</th>
|
||||
<th style={{ padding: "10px 12px", fontWeight: 600 }}>Associated user</th>
|
||||
<th style={{ padding: "10px 12px", fontWeight: 600 }}>Plan</th>
|
||||
<th style={{ padding: "10px 12px", fontWeight: 600 }}>Status</th>
|
||||
<th style={{ padding: "10px 12px", fontWeight: 600 }}>Users</th>
|
||||
|
|
@ -263,40 +270,63 @@ export function SuperAdminClientsPage() {
|
|||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} style={{ padding: "24px 16px", color: "var(--muted)", textAlign: "center" }}>
|
||||
<td colSpan={8} style={{ padding: "24px 16px", color: "var(--muted)", textAlign: "center" }}>
|
||||
{search
|
||||
? "No organizations match your search."
|
||||
: "No organizations yet. Use “Add organization” to create a tenant and its first super admin user."}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((org) => (
|
||||
<tr key={org._id} style={{ borderTop: "1px solid var(--border)" }}>
|
||||
<td style={{ padding: "10px 12px", color: "var(--foreground)" }}>{org.name}</td>
|
||||
<td style={{ padding: "10px 12px", color: "var(--muted)" }}>{org.email || "—"}</td>
|
||||
<td style={{ padding: "10px 12px" }}>{org.subscription?.plan ?? "free"}</td>
|
||||
<td style={{ padding: "10px 12px" }}>{org.subscription?.status ?? "—"}</td>
|
||||
<td style={{ padding: "10px 12px" }}>{org.userCount ?? "—"}</td>
|
||||
<td style={{ padding: "10px 12px", color: "var(--muted)" }}>{formatDate(org.createdAt)}</td>
|
||||
<td style={{ padding: "10px 12px" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEdit(org)}
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--radius-button)",
|
||||
padding: "4px 10px",
|
||||
fontSize: "var(--text-xs)",
|
||||
cursor: "pointer",
|
||||
color: "var(--primary)",
|
||||
}}
|
||||
>
|
||||
Edit sub
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
rows.map((org) => {
|
||||
const admin = org.primaryAdmin;
|
||||
const adminName = formatAdminName(admin);
|
||||
return (
|
||||
<tr key={org._id} style={{ borderTop: "1px solid var(--border)" }}>
|
||||
<td style={{ padding: "10px 12px", color: "var(--foreground)" }}>{org.name}</td>
|
||||
<td style={{ padding: "10px 12px", color: "var(--muted)" }}>{org.email || "—"}</td>
|
||||
<td style={{ padding: "10px 12px", verticalAlign: "top" }}>
|
||||
{admin ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "2px" }}>
|
||||
<span style={{ color: "var(--foreground)", fontWeight: 500 }}>
|
||||
{adminName || "—"}
|
||||
</span>
|
||||
<span style={{ color: "var(--muted)", fontSize: "var(--text-xs)" }}>
|
||||
{admin.email || "—"}
|
||||
</span>
|
||||
{admin.phone ? (
|
||||
<span style={{ color: "var(--muted)", fontSize: "var(--text-xs)" }}>
|
||||
{admin.phone}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ color: "var(--muted)" }}>—</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: "10px 12px" }}>{org.subscription?.plan ?? "free"}</td>
|
||||
<td style={{ padding: "10px 12px" }}>{org.subscription?.status ?? "—"}</td>
|
||||
<td style={{ padding: "10px 12px" }}>{org.userCount ?? "—"}</td>
|
||||
<td style={{ padding: "10px 12px", color: "var(--muted)" }}>{formatDate(org.createdAt)}</td>
|
||||
<td style={{ padding: "10px 12px" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEdit(org)}
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--radius-button)",
|
||||
padding: "4px 10px",
|
||||
fontSize: "var(--text-xs)",
|
||||
cursor: "pointer",
|
||||
color: "var(--primary)",
|
||||
}}
|
||||
>
|
||||
Edit sub
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export const STATUS_STYLES: Record<string, { bg: string; color: string }> = {
|
|||
pending: { bg: '#fef3c7', color: '#b45309' },
|
||||
confirmed: { bg: '#dcfce7', color: '#16a34a' },
|
||||
checked_in: { bg: '#dbeafe', color: '#2563eb' },
|
||||
rejected: { bg: '#fee2e2', color: '#dc2626' },
|
||||
};
|
||||
|
||||
export const ROLE_STYLES: Record<string, { bg: string; color: string }> = {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export type EventType = (typeof EVENT_TYPES)[number];
|
|||
export const EVENT_STATUS = ['draft', 'upcoming', 'live', 'completed'] as const;
|
||||
export type EventStatus = (typeof EVENT_STATUS)[number];
|
||||
|
||||
export const PARTICIPANT_STATUS = ['pending', 'confirmed', 'checked_in', 'cancelled'] as const;
|
||||
export const PARTICIPANT_STATUS = ['pending', 'confirmed', 'checked_in', 'cancelled', 'rejected'] as const;
|
||||
export type ParticipantStatus = (typeof PARTICIPANT_STATUS)[number];
|
||||
|
||||
export const SESSION_TYPES = ['keynote', 'workshop', 'panel', 'breakout', 'networking'] as const;
|
||||
|
|
|
|||
|
|
@ -47,6 +47,13 @@ export interface BadgeCustomImagePayload {
|
|||
imageUrl: string;
|
||||
}
|
||||
|
||||
export interface BadgeBackgroundImageLayout {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface CreateBadgeBatchPayload {
|
||||
name: string;
|
||||
organization?: string;
|
||||
|
|
@ -61,6 +68,7 @@ export interface CreateBadgeBatchPayload {
|
|||
participantListId?: string;
|
||||
backgroundMode: 'image' | 'color';
|
||||
backgroundImageUrl?: string;
|
||||
backgroundImageLayout?: BadgeBackgroundImageLayout;
|
||||
backgroundColor?: string;
|
||||
fields: string[];
|
||||
layout?: BadgeLayoutElementPayload[];
|
||||
|
|
|
|||
|
|
@ -29,8 +29,9 @@ export const eventsApi = {
|
|||
);
|
||||
},
|
||||
|
||||
get(id: string) {
|
||||
return api.get<Event>(`/events/${id}`);
|
||||
get(id: string, opts?: { enrich?: boolean }) {
|
||||
const q = opts?.enrich ? '?enrich=1' : '';
|
||||
return api.get<Event>(`/events/${id}${q}`);
|
||||
},
|
||||
|
||||
create(payload: Partial<Event>) {
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ export { formsApi } from './forms';
|
|||
export { rolesApi } from './roles';
|
||||
export { usersApi } from './users';
|
||||
export { tracksApi, type ApiTrack, type TrackPayload } from './tracks';
|
||||
export { sessionsApi, type ApiSession, type SessionPayload } from './sessions';
|
||||
export { sessionsApi, type ApiSession, type SessionPayload, type SessionTopic } from './sessions';
|
||||
export { eventStaffApi, type EventStaffMember, type AgendaTrack } from './eventStaff';
|
||||
export { quizPollsApi, type QuizPoll, type QuizPollPayload, type QuizPollQuestion } from './quizPolls';
|
||||
export {
|
||||
|
|
@ -79,6 +79,7 @@ export {
|
|||
type MessagingCampaign,
|
||||
type CampaignStatus,
|
||||
type CampaignContent,
|
||||
type CampaignRecipients,
|
||||
type WhatsAppQueueJobSummary,
|
||||
} from './campaigns';
|
||||
export { subscriptionApi, loadRazorpayScript } from './subscription';
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ interface ListAllParams {
|
|||
limit?: number;
|
||||
q?: string;
|
||||
tag?: string;
|
||||
/** When true, list soft-deleted participants only. */
|
||||
deleted?: boolean;
|
||||
}
|
||||
|
||||
export const participantsApi = {
|
||||
|
|
@ -37,6 +39,7 @@ export const participantsApi = {
|
|||
sp.set('limit', String(params?.limit ?? 20));
|
||||
if (params?.q?.trim()) sp.set('q', params.q.trim());
|
||||
if (params?.tag?.trim()) sp.set('tag', params.tag.trim());
|
||||
if (params?.deleted) sp.set('deleted', 'true');
|
||||
const query = sp.toString();
|
||||
return api.get<{ participants: ParticipantWithEvent[]; total: number; page: number; limit: number }>(
|
||||
`/participants${query ? `?${query}` : ''}`
|
||||
|
|
@ -127,23 +130,39 @@ export const participantsApi = {
|
|||
return api.put<Participant>(`/participants/${id}`, payload);
|
||||
},
|
||||
|
||||
/** Soft-delete participant (or remove from event via eventId — last membership soft-deletes). */
|
||||
remove(id: string, eventId: string) {
|
||||
const q = new URLSearchParams({ event_id: eventId });
|
||||
return api.delete<unknown>(`/participants/${id}?${q.toString()}`);
|
||||
},
|
||||
|
||||
/** Remove participant record entirely (all events). */
|
||||
/** Soft-delete participant record (all events). */
|
||||
deleteOne(id: string) {
|
||||
return api.delete<unknown>(`/participants/${id}`);
|
||||
},
|
||||
|
||||
/** Restore a soft-deleted participant. */
|
||||
restore(id: string) {
|
||||
return api.post<Participant>(`/participants/${id}/restore`, {});
|
||||
},
|
||||
|
||||
checkIn(id: string, eventId: string) {
|
||||
const q = new URLSearchParams({ event_id: eventId });
|
||||
return api.post<Participant>(`/participants/${id}/check-in?${q.toString()}`, {});
|
||||
},
|
||||
|
||||
confirm(id: string, eventId: string) {
|
||||
const q = new URLSearchParams({ event_id: eventId });
|
||||
return api.post<Participant>(`/participants/${id}/confirm?${q.toString()}`, {});
|
||||
},
|
||||
|
||||
reject(id: string, eventId: string) {
|
||||
const q = new URLSearchParams({ event_id: eventId });
|
||||
return api.post<Participant>(`/participants/${id}/reject?${q.toString()}`, {});
|
||||
},
|
||||
|
||||
/** Full event export (all participants). Triggers browser download. */
|
||||
async downloadExport(eventId: string, fileName = 'participants-export.csv') {
|
||||
async downloadExport(eventId: string, fileName?: string) {
|
||||
const res = await fetchWithAuth(`/events/${eventId}/participants/export`, { method: 'GET' });
|
||||
if (!res.ok) {
|
||||
let msg = res.statusText;
|
||||
|
|
@ -155,11 +174,12 @@ export const participantsApi = {
|
|||
}
|
||||
return { ok: false as const, error: msg };
|
||||
}
|
||||
const fromHeader = parseContentDispositionFilename(res.headers.get('Content-Disposition'));
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
a.download = fileName || fromHeader || 'Event - Participants.csv';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
return { ok: true as const };
|
||||
|
|
@ -224,3 +244,20 @@ export const participantsApi = {
|
|||
}>('/participants/import', payload);
|
||||
},
|
||||
};
|
||||
|
||||
function parseContentDispositionFilename(header: string | null): string | null {
|
||||
if (!header) return null;
|
||||
const star = /filename\*=UTF-8''([^;\n]+)/i.exec(header);
|
||||
if (star?.[1]) {
|
||||
try {
|
||||
return decodeURIComponent(star[1].trim());
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
const quoted = /filename="([^"]+)"/i.exec(header);
|
||||
if (quoted?.[1]) return quoted[1];
|
||||
const bare = /filename=([^;\n]+)/i.exec(header);
|
||||
if (bare?.[1]) return bare[1].trim().replace(/^"|"$/g, '');
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,9 +32,15 @@ export interface ApiSession {
|
|||
enableQnA?: boolean;
|
||||
/** When false, hide Quiz & Polls tab on activity detail (default true for normal events). */
|
||||
enableQnP?: boolean;
|
||||
/** Mongo id of portfolio feedback form, or omit/null for default attendee UI */
|
||||
audienceFeedbackForm?: string | null;
|
||||
audienceRatingsForm?: string | null;
|
||||
/** Nested session topics for hierarchical programmes */
|
||||
topics?: SessionTopic[];
|
||||
}
|
||||
|
||||
export interface SessionTopic {
|
||||
type?: string;
|
||||
duration?: string;
|
||||
title?: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export type SessionPayload = {
|
||||
|
|
@ -59,6 +65,7 @@ export type SessionPayload = {
|
|||
audienceRatingsForm?: string | null;
|
||||
enableQnA?: boolean;
|
||||
enableQnP?: boolean;
|
||||
topics?: SessionTopic[];
|
||||
};
|
||||
|
||||
export const sessionsApi = {
|
||||
|
|
|
|||
|
|
@ -75,11 +75,23 @@ export interface User {
|
|||
assignedEvents?: string[];
|
||||
}
|
||||
|
||||
/** Primary org admin (super_admin) on platform-admin org list/detail */
|
||||
export interface OrganizationPrimaryAdmin {
|
||||
_id: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export interface OrganizationRecord {
|
||||
_id: string;
|
||||
name: string;
|
||||
/** Populated by platform-admin org list/detail */
|
||||
userCount?: number;
|
||||
/** First org super_admin user associated with this tenant */
|
||||
primaryAdmin?: OrganizationPrimaryAdmin | null;
|
||||
description?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
|
|
@ -273,6 +285,15 @@ export interface EventMicrosite {
|
|||
eventName?: string | null;
|
||||
}
|
||||
|
||||
/** SPA microsite theme presets (`website.theme` on public microsite payload). */
|
||||
export type WebsiteThemeMode = 'simple' | 'gradient';
|
||||
|
||||
export interface WebsiteThemeConfig {
|
||||
theme: WebsiteThemeMode;
|
||||
color: string;
|
||||
font?: string | null;
|
||||
}
|
||||
|
||||
export interface EventWebsiteSettings {
|
||||
enabled?: boolean;
|
||||
folder?: string;
|
||||
|
|
@ -293,6 +314,8 @@ export interface EventWebsiteSettings {
|
|||
seoDescription?: string;
|
||||
/** Visual editor markup; canonical with publish (also mirrored legacy on Home EventPage). */
|
||||
micrositeHtml?: string;
|
||||
/** Luma-style theme: simple/gradient + color + font */
|
||||
theme?: WebsiteThemeConfig | null;
|
||||
}
|
||||
|
||||
/** Event/session/quiz loyalty overrides (omit or null = inherit / unused at layer). */
|
||||
|
|
@ -313,6 +336,12 @@ export interface ActivityStats {
|
|||
participantCount: number;
|
||||
quizPollCount: number;
|
||||
responseCount: number;
|
||||
/** Average star rating across all event feedback/ratings forms */
|
||||
eventRatingAverage?: number;
|
||||
eventRatingCount?: number;
|
||||
/** Average star rating for the activity's primary session ratings form */
|
||||
sessionRatingAverage?: number;
|
||||
sessionRatingCount?: number;
|
||||
}
|
||||
|
||||
export interface Event {
|
||||
|
|
@ -478,6 +507,13 @@ export interface DesignTemplate {
|
|||
variables?: DesignTemplateVariable[];
|
||||
organization?: string;
|
||||
settings?: { isPublic?: boolean; isActive?: boolean; canDuplicate?: boolean };
|
||||
isSystemTemplate?: boolean;
|
||||
isGlobalTemplate?: boolean;
|
||||
isOrgOverride?: boolean;
|
||||
globalTemplateKey?: string;
|
||||
systemTemplateKey?: string;
|
||||
derivedFromTemplate?: string;
|
||||
canDelete?: boolean;
|
||||
usedBy?: string[];
|
||||
createdBy?: string;
|
||||
createdAt?: string;
|
||||
|
|
@ -527,6 +563,8 @@ export interface FormField {
|
|||
mapsTo?: string;
|
||||
customMapsKey?: string;
|
||||
isFixed?: boolean;
|
||||
/** Registration: add submitted value(s) as participant tags. */
|
||||
convertToTag?: boolean;
|
||||
}
|
||||
|
||||
export interface FormSettings {
|
||||
|
|
@ -541,8 +579,20 @@ export interface FormSettings {
|
|||
paymentEnabled?: boolean;
|
||||
paymentAmount?: string | number;
|
||||
sendConfirmationEmail?: boolean;
|
||||
/** Emailer DesignTemplate id used for registration confirmation. */
|
||||
confirmationEmailTemplateId?: string | null;
|
||||
onSubmitAddTags?: string[];
|
||||
onSubmitParticipantListId?: string;
|
||||
allowDuplicateEmail?: boolean;
|
||||
allowDuplicatePhone?: boolean;
|
||||
formClosedMessage?: string;
|
||||
submitErrorMessage?: string;
|
||||
captchaFailedMessage?: string;
|
||||
registrationClosedMessage?: string;
|
||||
registrationDeadlineMessage?: string;
|
||||
registrationFullMessage?: string;
|
||||
duplicateEmailMessage?: string;
|
||||
duplicatePhoneMessage?: string;
|
||||
eventHeader?: {
|
||||
showLogo?: boolean;
|
||||
showTitle?: boolean;
|
||||
|
|
@ -601,6 +651,14 @@ export interface PublicFormDefinition {
|
|||
showThankYou?: boolean;
|
||||
collectEmail?: boolean;
|
||||
allowAnonymous?: boolean;
|
||||
formClosedMessage?: string;
|
||||
submitErrorMessage?: string;
|
||||
captchaFailedMessage?: string;
|
||||
registrationClosedMessage?: string;
|
||||
registrationDeadlineMessage?: string;
|
||||
registrationFullMessage?: string;
|
||||
duplicateEmailMessage?: string;
|
||||
duplicatePhoneMessage?: string;
|
||||
eventHeader?: FormSettings['eventHeader'];
|
||||
};
|
||||
}
|
||||
|
|
@ -795,7 +853,9 @@ export type MicrositeV2FieldType =
|
|||
| 'image'
|
||||
| 'video'
|
||||
| 'select'
|
||||
| 'array';
|
||||
| 'array'
|
||||
| 'date'
|
||||
| 'time';
|
||||
|
||||
export interface MicrositeV2FieldShowIf {
|
||||
field: string;
|
||||
|
|
|
|||
|
|
@ -355,7 +355,11 @@ export function PublicFormView() {
|
|||
await formsApi.submit(formId, { data });
|
||||
setDone(true);
|
||||
} catch (e) {
|
||||
setSubmitError(e instanceof Error ? e.message : "Submit failed.");
|
||||
setSubmitError(
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: form.settings?.submitErrorMessage?.trim() || "Submit failed.",
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
|
@ -398,6 +402,7 @@ export function PublicFormView() {
|
|||
}
|
||||
|
||||
if (done) {
|
||||
const showThanks = form.settings?.showThankYou !== false;
|
||||
const msg = form.settings?.thankYouMessage?.trim() || "Thank you for your response.";
|
||||
const ev = form.event;
|
||||
return (
|
||||
|
|
@ -436,6 +441,7 @@ export function PublicFormView() {
|
|||
>
|
||||
{form.name}
|
||||
</h1>
|
||||
{showThanks ? (
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
|
|
@ -451,6 +457,7 @@ export function PublicFormView() {
|
|||
>
|
||||
{msg}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@
|
|||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue