Expand website theme settings and related console updates.

Allow editing full website.theme palette/pattern options from Event Home, plus accompanying dashboard and API client changes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ravi Gulhane 2026-08-06 13:05:03 +05:30
parent c803b9d253
commit 24f8edc05d
56 changed files with 4786 additions and 524 deletions

View File

@ -2,13 +2,22 @@
# Backend API base URL (used by services/api/client.ts)
VITE_API_URL=http://localhost:3001/api
# Virtual Concierge public API (website embed snippet). Must end with /api.
# VITE_CONCIERGE_URL=http://localhost:3010/api
# Production: https://concierge.eventbuzz.live/api
# Optional: public base URL for published custom microsites (no trailing slash). Links to {base}/{folder}/index.html
# VITE_MICROSITE_PUBLIC_BASE=https://web.eventbuzz.live
# Public web app for default microsites, forms, and registration (no trailing slash)
# VITE_PUBLIC_WEB_BASE=https://web.eventbuzz.live
# Participant SPA base (join codes /code/{code}, quiz-present). Not microsites.
# Production: https://join.eventbuzz.live — local eventbuzz Vite often :5174
# VITE_EVENTBUZZ_PUBLIC_URL=https://join.eventbuzz.live
# GrapesJS microsite builder (Vite app in /website_builder). Iframe src + postMessage target origin.
VITE_WEBSITE_BUILDER_URL=http://localhost:5173
# Optional: exact origin for postMessage (defaults to origin of VITE_WEBSITE_BUILDER_URL)
# VITE_WEBSITE_BUILDER_ORIGIN=http://localhost:5173

4
dist/index.html vendored
View File

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

BIN
public/fonts/Swiss921BT.ttf Normal file

Binary file not shown.

View File

@ -0,0 +1,47 @@
import type { CSSProperties } from "react";
import logoUrl from "../../assets/logo.png";
import logoSmallUrl from "../../assets/logo-small.png";
type EventBuzzBrandLogoProps = {
/** `full` = wordmark (`logo.png`); `small` = mark (`logo-small.png`). */
variant?: "full" | "small";
/** Visual height in px (width scales with image aspect). */
height?: number;
/** Optional max width clamp. */
maxWidth?: number;
className?: string;
style?: CSSProperties;
alt?: string;
};
/**
* Product logo from `src/assets/logo.png` / `logo-small.png`.
*/
export function EventBuzzBrandLogo({
variant = "full",
height = 28,
maxWidth,
className,
style,
alt = "EventBuzz",
}: EventBuzzBrandLogoProps) {
const src = variant === "small" ? logoSmallUrl : logoUrl;
return (
<img
src={src}
alt={alt}
className={className}
style={{
height,
width: "auto",
maxWidth: maxWidth ?? "100%",
display: "block",
objectFit: "contain",
flexShrink: 0,
...style,
}}
/>
);
}
export { logoUrl as eventBuzzLogoUrl, logoSmallUrl as eventBuzzLogoSmallUrl };

View File

@ -38,6 +38,9 @@ const EVENT_TABS: EventTab[] = [
{ id: "analytics", label: "Analytics", shortLabel: "Analytics", icon: <LineChart size={13} /> },
];
const EVENT_NAV_TEXT = "#484B57";
const EVENT_NAV_TEXT_MUTED = "rgba(72, 75, 87, 0.72)";
/** Event sub-routes that keep the event NavBar visible (includes hidden tabs like the website builder). */
export const EVENT_NAV_IDS = new Set([...EVENT_TABS.map((t) => t.id), "website", "permissions", "ticketing", "questions", "quiz"]);
@ -59,7 +62,6 @@ export function NavBar({ activeItem, onNavigate }: NavBarProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const bp = useBreakpoint();
const isMobile = bp === "mobile";
const isTablet = bp === "tablet";
// H1: Track scroll position to show fade indicator for overflow tabs
const [canScrollRight, setCanScrollRight] = useState(false);
@ -136,12 +138,12 @@ export function NavBar({ activeItem, onNavigate }: NavBarProps) {
borderRightColor: "transparent",
borderBottomWidth: "2px",
borderBottomStyle: "solid",
borderBottomColor: isActive ? "var(--primary)" : "transparent",
borderBottomColor: isActive ? EVENT_NAV_TEXT : "transparent",
cursor: "pointer",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
fontWeight: isActive ? "var(--font-weight-medium)" : "var(--font-weight-normal)",
color: isActive ? "var(--primary)" : "var(--card-foreground)",
color: isActive ? EVENT_NAV_TEXT : EVENT_NAV_TEXT_MUTED,
whiteSpace: "nowrap",
flexShrink: 0,
transition: "color 0.12s, border-color 0.12s, background 0.12s",
@ -149,17 +151,17 @@ export function NavBar({ activeItem, onNavigate }: NavBarProps) {
onMouseEnter={e => {
if (!isActive) {
(e.currentTarget as HTMLElement).style.background = "var(--secondary)";
(e.currentTarget as HTMLElement).style.color = "var(--foreground)";
(e.currentTarget as HTMLElement).style.color = EVENT_NAV_TEXT;
}
}}
onMouseLeave={e => {
if (!isActive) {
(e.currentTarget as HTMLElement).style.background = "transparent";
(e.currentTarget as HTMLElement).style.color = "var(--card-foreground)";
(e.currentTarget as HTMLElement).style.color = EVENT_NAV_TEXT_MUTED;
}
}}
>
<span style={{ color: isActive ? "var(--primary)" : "var(--muted)", display: "flex", flexShrink: 0 }}>
<span style={{ color: isActive ? EVENT_NAV_TEXT : EVENT_NAV_TEXT_MUTED, display: "flex", flexShrink: 0 }}>
{tab.icon}
</span>
{/* On mobile show short labels */}

View File

@ -9,6 +9,7 @@ import { useNavigate } from "react-router-dom";
import { useTheme } from "../contexts/ThemeContext";
import { useAuth } from "../contexts/AuthContext";
import { navItemAllowed, usePermission } from "../contexts/PermissionContext";
import { EventBuzzBrandLogo } from "./EventBuzzBrandLogo";
/* ─────────────────────────────────────
Types
@ -267,7 +268,7 @@ function NavList({
onMouseLeave={e => { if (!cActive) (e.currentTarget as HTMLElement).style.background = "transparent"; }}
>
{child.isAction && <Plus size={11} style={{ color: "var(--primary)", flexShrink: 0 }} />}
<span style={{ fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-xs)", color: cActive ? "var(--sidebar-accent-foreground)" : child.isAction ? "var(--primary)" : "var(--card-foreground)" }}>
<span style={{ fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-xs)", color: cActive ? "var(--sidebar-accent-foreground)" : child.isAction ? "var(--primary)" : "var(--sidebar-foreground)" }}>
{child.label}
</span>
</button>
@ -686,12 +687,7 @@ export function Sidebar({
}}>
{/* Header */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "14px 14px 14px 16px", borderBottom: "1px solid var(--sidebar-border)", flexShrink: 0, minHeight: "52px" }}>
<div style={{ display: "flex", alignItems: "center", gap: "9px" }}>
<div style={{ width: "28px", height: "28px", borderRadius: "6px", background: "var(--primary)", display: "flex", alignItems: "center", justifyContent: "center" }}>
<span style={{ color: "#fff", fontSize: "11px", fontFamily: "var(--font-inter)", fontWeight: "var(--font-weight-medium)" }}>EB</span>
</div>
<span style={{ fontFamily: "var(--font-inter)", fontWeight: "var(--font-weight-medium)", fontSize: "var(--text-base)", color: "var(--sidebar-foreground)" }}>EventBuzz</span>
</div>
<EventBuzzBrandLogo height={28} maxWidth={150} />
<button onClick={onDrawerClose}
style={{ width: "28px", height: "28px", display: "flex", alignItems: "center", justifyContent: "center", background: "none", border: "none", cursor: "pointer", borderRadius: "var(--radius-button)", color: "var(--muted)" }}
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.background = "var(--accent)"; }}
@ -735,11 +731,12 @@ export function Sidebar({
borderBottom: "1px solid var(--sidebar-border)",
flexShrink: 0, minHeight: "52px",
}}>
<div style={{ display: "flex", alignItems: "center", gap: "9px", overflow: "hidden" }}>
<div style={{ width: "28px", height: "28px", borderRadius: "6px", background: "var(--primary)", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
<span style={{ color: "#fff", fontSize: "11px", fontFamily: "var(--font-inter)", fontWeight: "var(--font-weight-medium)" }}>EB</span>
</div>
{!collapsed && <span style={{ fontFamily: "var(--font-inter)", fontWeight: "var(--font-weight-medium)", fontSize: "var(--text-base)", color: "var(--sidebar-foreground)", whiteSpace: "nowrap", overflow: "hidden" }}>EventBuzz</span>}
<div style={{ display: "flex", alignItems: "center", gap: "9px", overflow: "hidden", minWidth: 0, flex: collapsed ? undefined : 1 }}>
{collapsed ? (
<EventBuzzBrandLogo variant="small" height={28} maxWidth={36} />
) : (
<EventBuzzBrandLogo height={28} maxWidth={140} />
)}
</div>
{/* H3: Always show collapse/expand button — critical for user control */}
<button onClick={onToggleCollapse} title={collapsed ? "Expand sidebar" : "Collapse sidebar"}

View File

@ -13,6 +13,7 @@ import {
mapApiEventToSearchEvent,
SearchEvent, SearchPage, SearchAction, SearchResults,
} from "./search/searchIndex";
import { EventBuzzBrandLogo } from "./EventBuzzBrandLogo";
/* ─────────────────────────────────────────
Props
@ -676,14 +677,7 @@ export function TopBar({ onMenuClick, onNavigate, onEventSelect, onOpenSearchPag
)}
{/* Logo — mobile only */}
{isMobile && (
<div style={{ display: "flex", alignItems: "center", gap: "7px" }}>
<div style={{ width: "26px", height: "26px", borderRadius: "6px", background: "var(--primary)", display: "flex", alignItems: "center", justifyContent: "center" }}>
<span style={{ color: "#fff", fontSize: "10px", fontFamily: "var(--font-inter)", fontWeight: "var(--font-weight-medium)" }}>EB</span>
</div>
<span style={{ fontFamily: "var(--font-inter)", fontWeight: "var(--font-weight-medium)", fontSize: "var(--text-sm)", color: "var(--foreground)" }}>EventBuzz</span>
</div>
)}
{isMobile && <EventBuzzBrandLogo height={24} maxWidth={130} />}
{/* ── Desktop search with dropdown ── */}
{!isMobile && (

View File

@ -812,6 +812,19 @@ export function AgendaPage() {
const dupSp = (orig.speakers || []).map((sp) =>
typeof sp === "object" && sp && "_id" in sp ? String((sp as { _id: string })._id) : String(sp)
);
const dupKeyPersons =
Array.isArray(orig.keyPersons) && orig.keyPersons.length
? orig.keyPersons
.map((kp) => {
const speakerId =
typeof kp.speaker === "object" && kp.speaker && "_id" in kp.speaker
? String(kp.speaker._id)
: String(kp.speaker || "");
if (!speakerId) return null;
return { speaker: speakerId, role: String(kp.role || "Speaker").trim() || "Speaker" };
})
.filter((x): x is { speaker: string; role: string } => Boolean(x))
: dupSp.filter(Boolean).map((speaker) => ({ speaker, role: "Speaker" }));
await sessionsApi.create(selectedEventId, {
title: `${orig.title} (Copy)`,
description: orig.description,
@ -822,6 +835,7 @@ export function AgendaPage() {
status: orig.status,
track: newTrack._id,
speakers: dupSp.filter(Boolean),
keyPersons: dupKeyPersons,
});
}
await loadAgenda();
@ -872,6 +886,19 @@ export function AgendaPage() {
const spIds = (orig.speakers || []).map((sp) =>
typeof sp === "object" && sp && "_id" in sp ? String((sp as { _id: string })._id) : String(sp)
);
const keyPersons =
Array.isArray(orig.keyPersons) && orig.keyPersons.length
? orig.keyPersons
.map((kp) => {
const speakerId =
typeof kp.speaker === "object" && kp.speaker && "_id" in kp.speaker
? String(kp.speaker._id)
: String(kp.speaker || "");
if (!speakerId) return null;
return { speaker: speakerId, role: String(kp.role || "Speaker").trim() || "Speaker" };
})
.filter((x): x is { speaker: string; role: string } => Boolean(x))
: spIds.filter(Boolean).map((speaker) => ({ speaker, role: "Speaker" }));
await sessionsApi.create(selectedEventId, {
title: `${orig.title} (Copy)`,
description: orig.description,
@ -882,6 +909,7 @@ export function AgendaPage() {
status: orig.status,
track: tid ? String(tid) : null,
speakers: spIds.filter(Boolean),
keyPersons,
audienceFeedbackEnabled: orig.audienceFeedbackEnabled,
audienceRatingsEnabled: orig.audienceRatingsEnabled,
audienceKeyPersonsEnabled: orig.audienceKeyPersonsEnabled,

View File

@ -13,10 +13,18 @@ import {
} from "../../../services/api";
import type { FormWithSubmissionCount } from "../../../services/api/types";
import { FlatpickrDatetimeInput } from "../ui/FlatpickrDatetimeInput";
import {
DEFAULT_KEY_PERSON_ROLE,
KEY_PERSON_ROLES,
normalizeKeyPersonRole,
type KeyPersonRole,
} from "../../constants/keyPersonRoles";
export const UNASSIGNED_TRACK_ID = "__unassigned__";
const NEW_TRACK_VALUE = "__new__";
type KeyPersonRow = { speakerId: string; role: KeyPersonRole };
type SessionType = "Presentation" | "Panel" | "Keynote" | "Workshop" | "Qna" | "Demo" | "Debate" | "Symposium" | "Other";
type SessionStatus = "Scheduled" | "Live" | "Completed" | "Cancelled";
@ -165,6 +173,27 @@ function extractSpeakerIds(s?: ApiSession | null): string[] {
);
}
function extractKeyPersons(s?: ApiSession | null): KeyPersonRow[] {
if (s?.keyPersons?.length) {
const rows: KeyPersonRow[] = [];
const seen = new Set<string>();
for (const kp of s.keyPersons) {
const speakerId =
typeof kp.speaker === "object" && kp.speaker && "_id" in kp.speaker
? String(kp.speaker._id)
: String(kp.speaker || "");
if (!speakerId || seen.has(speakerId)) continue;
seen.add(speakerId);
rows.push({ speakerId, role: normalizeKeyPersonRole(kp.role) });
}
if (rows.length) return rows;
}
return extractSpeakerIds(s).map((speakerId) => ({
speakerId,
role: DEFAULT_KEY_PERSON_ROLE,
}));
}
function sessionLinkedFormId(s: ApiSession | null | undefined, key: "audienceFeedbackForm" | "audienceRatingsForm"): string {
if (!s) return "";
const v = s[key];
@ -263,7 +292,8 @@ export function AgendaSessionPanel({
const [speakerCatalog, setSpeakerCatalog] = useState<Speaker[]>([]);
const [speakersLoading, setSpeakersLoading] = useState(true);
const [selectedSpeakerIds, setSelectedSpeakerIds] = useState<string[]>(() => extractSpeakerIds(session));
const [keyPersons, setKeyPersons] = useState<KeyPersonRow[]>(() => extractKeyPersons(session));
const selectedSpeakerIds = useMemo(() => keyPersons.map((k) => k.speakerId), [keyPersons]);
const [speakerSearch, setSpeakerSearch] = useState("");
const [showNewSpeaker, setShowNewSpeaker] = useState(false);
const [nsFirst, setNsFirst] = useState("");
@ -271,6 +301,7 @@ export function AgendaSessionPanel({
const [nsEmail, setNsEmail] = useState("");
const [nsPhone, setNsPhone] = useState("");
const [nsTitle, setNsTitle] = useState("");
const [nsRole, setNsRole] = useState<KeyPersonRole>(DEFAULT_KEY_PERSON_ROLE);
const [nsErr, setNsErr] = useState<SectionErr>({});
const [nsSaving, setNsSaving] = useState(false);
@ -330,12 +361,18 @@ export function AgendaSessionPanel({
[speakerCatalog]
);
const addSpeakerId = (id: string) => {
const addSpeakerId = (id: string, role: KeyPersonRole = DEFAULT_KEY_PERSON_ROLE) => {
if (!id || selectedSpeakerIds.includes(id)) return;
setSelectedSpeakerIds((p) => [...p, id]);
setKeyPersons((p) => [...p, { speakerId: id, role: normalizeKeyPersonRole(role) }]);
};
const removeSpeakerId = (id: string) => setSelectedSpeakerIds((p) => p.filter((x) => x !== id));
const removeSpeakerId = (id: string) => setKeyPersons((p) => p.filter((x) => x.speakerId !== id));
const setKeyPersonRole = (id: string, role: KeyPersonRole) => {
setKeyPersons((p) =>
p.map((row) => (row.speakerId === id ? { ...row, role: normalizeKeyPersonRole(role) } : row)),
);
};
const createSpeakerAndAttach = async () => {
const e: SectionErr = {};
@ -361,12 +398,13 @@ export function AgendaSessionPanel({
}
const created = res.data as Speaker;
setSpeakerCatalog((prev) => [created, ...prev.filter((x) => x._id !== created._id)]);
addSpeakerId(created._id);
addSpeakerId(created._id, nsRole);
setNsFirst("");
setNsLast("");
setNsEmail("");
setNsPhone("");
setNsTitle("");
setNsRole(DEFAULT_KEY_PERSON_ROLE);
setNsErr({});
setShowNewSpeaker(false);
};
@ -489,6 +527,10 @@ export function AgendaSessionPanel({
status: UI_TO_API_STATUS[status],
track: trackId === UNASSIGNED_TRACK_ID ? null : trackId,
speakers: selectedSpeakerIds,
keyPersons: keyPersons.map((k) => ({
speaker: k.speakerId,
role: normalizeKeyPersonRole(k.role),
})),
topics: normalizedTopics.length ? normalizedTopics : [],
audienceFeedbackEnabled,
audienceRatingsEnabled,
@ -517,7 +559,7 @@ export function AgendaSessionPanel({
{ n: 2, title: "Session details", subtitle: "Title, format, and status." },
{ n: 3, title: "Schedule", subtitle: "Start and end date/time." },
{ 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." },
{ n: 5, title: "Key Persons & notes", subtitle: "Faculty from your organization; assign a role for each person." },
];
const SaveBtn = () => (
@ -908,34 +950,56 @@ export function AgendaSessionPanel({
{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>
<label style={{ display: "block", fontSize: "var(--text-sm)", fontWeight: "var(--font-weight-medium)", marginBottom: "6px", fontFamily: "var(--font-noto-sans)" }}>Key Persons</label>
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: "0 0 8px", fontFamily: "var(--font-noto-sans)" }}>
Select from your organization&apos;s faculty directory or add someone new.
Select from your organization&apos;s faculty directory or add someone new. Assign a role for each person (defaults to Speaker).
</p>
{selectedSpeakerIds.length > 0 && (
<div style={{ display: "flex", flexWrap: "wrap", gap: "6px", marginBottom: "10px" }}>
{selectedSpeakerIds.map((id) => (
<span
key={id}
{keyPersons.length > 0 && (
<div style={{ display: "flex", flexDirection: "column", gap: "8px", marginBottom: "10px" }}>
{keyPersons.map((row) => (
<div
key={row.speakerId}
style={{
display: "inline-flex",
display: "grid",
gridTemplateColumns: "minmax(0, 1fr) minmax(140px, 200px) auto",
gap: "8px",
alignItems: "center",
gap: "6px",
padding: "4px 8px 4px 10px",
padding: "8px 10px",
borderRadius: "var(--radius-button)",
background: "var(--secondary)",
border: "1px solid var(--border)",
fontSize: "var(--text-xs)",
fontFamily: "var(--font-noto-sans)",
}}
>
{speakerLabel(id)}
<span
style={{
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
fontWeight: "var(--font-weight-medium)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{speakerLabel(row.speakerId)}
</span>
<select
value={row.role}
onChange={(e) => setKeyPersonRole(row.speakerId, e.target.value as KeyPersonRole)}
aria-label={`Role for ${speakerLabel(row.speakerId)}`}
style={{ ...IS, padding: "6px 8px", fontSize: "var(--text-xs)" }}
>
{KEY_PERSON_ROLES.map((role) => (
<option key={role} value={role}>
{role}
</option>
))}
</select>
<button
type="button"
onClick={() => removeSpeakerId(id)}
onClick={() => removeSpeakerId(row.speakerId)}
style={{
display: "flex",
padding: 0,
padding: "4px",
border: "none",
background: "none",
cursor: "pointer",
@ -943,9 +1007,9 @@ export function AgendaSessionPanel({
}}
aria-label="Remove"
>
<X size={12} />
<X size={14} />
</button>
</span>
</div>
))}
</div>
)}
@ -984,14 +1048,14 @@ export function AgendaSessionPanel({
if (speakerCatalog.length === 0) {
return (
<p style={{ padding: "12px", fontSize: "var(--text-xs)", color: "var(--muted)", margin: 0, fontFamily: "var(--font-noto-sans)" }}>
No speakers in the directory yet. Add one below.
No faculty in the directory yet. Add one below.
</p>
);
}
if (available.length === 0) {
return (
<p style={{ padding: "12px", fontSize: "var(--text-xs)", color: "var(--muted)", margin: 0, fontFamily: "var(--font-noto-sans)" }}>
Everyone listed is already on this session. Add a new speaker or remove someone above.
Everyone listed is already on this session. Add a new key person or remove someone above.
</p>
);
}
@ -1060,7 +1124,7 @@ export function AgendaSessionPanel({
color: "var(--primary)",
}}
>
<UserPlus size={14} /> {showNewSpeaker ? "Hide new speaker form" : "Add new speaker"}
<UserPlus size={14} /> {showNewSpeaker ? "Hide new person form" : "Add new key person"}
</button>
{showNewSpeaker && (
<div
@ -1101,10 +1165,24 @@ export function AgendaSessionPanel({
<input value={nsPhone} onChange={(e) => setNsPhone(e.target.value)} style={IS} />
</div>
<div>
<label style={{ fontSize: "var(--text-xs)", display: "block", marginBottom: "4px", fontFamily: "var(--font-noto-sans)" }}>Title / role</label>
<label style={{ fontSize: "var(--text-xs)", display: "block", marginBottom: "4px", fontFamily: "var(--font-noto-sans)" }}>Title</label>
<input value={nsTitle} onChange={(e) => setNsTitle(e.target.value)} placeholder="e.g. Prof. of Medicine" style={IS} />
</div>
</div>
<div>
<label style={{ fontSize: "var(--text-xs)", display: "block", marginBottom: "4px", fontFamily: "var(--font-noto-sans)" }}>Session role</label>
<select
value={nsRole}
onChange={(e) => setNsRole(e.target.value as KeyPersonRole)}
style={IS}
>
{KEY_PERSON_ROLES.map((role) => (
<option key={role} value={role}>
{role}
</option>
))}
</select>
</div>
<button
type="button"
disabled={nsSaving}

View File

@ -1,4 +1,8 @@
/** Public landing URL for QR codes: session, track, or event `joinCode` (resolved in that order server-side). */
export function eventBuzzUrl(code: string): string {
return `https://eventbuzz.live/code/${code}`;
const base =
(typeof import.meta.env.VITE_EVENTBUZZ_PUBLIC_URL === "string" &&
import.meta.env.VITE_EVENTBUZZ_PUBLIC_URL.trim()) ||
"https://join.eventbuzz.live";
return `${base.replace(/\/$/, "")}/code/${code}`;
}

View File

@ -15,8 +15,11 @@ import {
} from "lucide-react";
import { useAppNav } from "../../contexts/AppNavContext";
import { usePermission } from "../../contexts/PermissionContext";
import { attendancesApi } from "../../../services/api";
import { attendancesApi, participantsApi, participantListsApi } from "../../../services/api";
import type { ParticipantListRecord } from "../../../services/api/participantLists";
import type {
AttendanceAudienceMode,
AttendanceMembershipStatus,
AttendanceRecord,
AttendanceSearchParticipant,
AttendanceCheckInRow,
@ -38,11 +41,55 @@ function formatDt(iso?: string | null) {
});
}
const MEMBERSHIP_STATUS_OPTIONS: { value: AttendanceMembershipStatus; label: string }[] = [
{ value: "pending", label: "Pending" },
{ value: "confirmed", label: "Confirmed" },
{ value: "checked_in", label: "Checked in" },
{ value: "cancelled", label: "Cancelled" },
{ value: "rejected", label: "Rejected" },
];
const AUDIENCE_MODE_OPTIONS: { value: AttendanceAudienceMode; label: string; hint: string }[] = [
{ value: "all", label: "All participants", hint: "Everyone registered for this event" },
{ value: "statuses", label: "By status", hint: "Only selected membership statuses" },
{ value: "tags", label: "By tags", hint: "Participants with any selected tag" },
{
value: "participant_lists",
label: "By contact list",
hint: "Members of selected contact lists (also on this event)",
},
];
function audienceSummary(s: AttendanceRecord): string {
const mode = s.settings?.audience?.mode || "all";
if (mode === "all") return "All participants";
if (mode === "statuses") {
const statuses = s.settings?.audience?.statuses || [];
if (!statuses.length) return "By status";
return `Status: ${statuses
.map((st) => MEMBERSHIP_STATUS_OPTIONS.find((o) => o.value === st)?.label || st)
.join(", ")}`;
}
if (mode === "tags") {
const tags = s.settings?.audience?.tags || [];
return tags.length ? `Tags: ${tags.join(", ")}` : "By tags";
}
if (mode === "participant_lists") {
const n = s.settings?.audience?.participantListIds?.length || 0;
return n ? `${n} contact list${n === 1 ? "" : "s"}` : "By contact list";
}
return "All participants";
}
interface FormState {
name: string;
description: string;
allowDuplicateCheckIn: boolean;
publicLinkEnabled: boolean;
audienceMode: AttendanceAudienceMode;
includeStatuses: AttendanceMembershipStatus[];
includeTags: string[];
includeParticipantListIds: string[];
}
const emptyForm: FormState = {
@ -50,6 +97,10 @@ const emptyForm: FormState = {
description: "",
allowDuplicateCheckIn: false,
publicLinkEnabled: true,
audienceMode: "all",
includeStatuses: ["confirmed", "checked_in"],
includeTags: [],
includeParticipantListIds: [],
};
export function AttendancePage() {
@ -76,9 +127,28 @@ export function AttendancePage() {
const [checkIns, setCheckIns] = useState<AttendanceCheckInRow[]>([]);
const [checkInsTotal, setCheckInsTotal] = useState(0);
const [checkInsLoading, setCheckInsLoading] = useState(false);
const [eventTags, setEventTags] = useState<string[]>([]);
const [contactLists, setContactLists] = useState<ParticipantListRecord[]>([]);
const selected = sessions.find((s) => s._id === selectedId) ?? null;
useEffect(() => {
if (!selectedEventId || !panelOpen) return;
let cancelled = false;
(async () => {
const [tagsRes, listsRes] = await Promise.all([
participantsApi.listDistinctTagsForEvent(selectedEventId),
participantListsApi.list(),
]);
if (cancelled) return;
if (tagsRes.data?.tags) setEventTags(tagsRes.data.tags);
if (listsRes.data?.lists) setContactLists(listsRes.data.lists);
})();
return () => {
cancelled = true;
};
}, [selectedEventId, panelOpen]);
const loadSessions = useCallback(async () => {
if (!selectedEventId || !canReadAttendance) return;
setLoading(true);
@ -134,17 +204,71 @@ export function AttendancePage() {
const openEdit = (s: AttendanceRecord) => {
setEditingId(s._id);
const audience = s.settings?.audience;
setForm({
name: s.name,
description: s.description || "",
allowDuplicateCheckIn: Boolean(s.settings?.allowDuplicateCheckIn),
publicLinkEnabled: s.settings?.publicLinkEnabled !== false,
audienceMode: audience?.mode || "all",
includeStatuses:
audience?.statuses && audience.statuses.length
? audience.statuses
: ["confirmed", "checked_in"],
includeTags: audience?.tags || [],
includeParticipantListIds: (audience?.participantListIds || []).map(String),
});
setPanelOpen(true);
};
const toggleStatus = (status: AttendanceMembershipStatus) => {
setForm((f) => {
const has = f.includeStatuses.includes(status);
return {
...f,
includeStatuses: has
? f.includeStatuses.filter((s) => s !== status)
: [...f.includeStatuses, status],
};
});
};
const toggleTag = (tag: string) => {
setForm((f) => {
const has = f.includeTags.includes(tag);
return {
...f,
includeTags: has ? f.includeTags.filter((t) => t !== tag) : [...f.includeTags, tag],
};
});
};
const toggleList = (listId: string) => {
setForm((f) => {
const has = f.includeParticipantListIds.includes(listId);
return {
...f,
includeParticipantListIds: has
? f.includeParticipantListIds.filter((id) => id !== listId)
: [...f.includeParticipantListIds, listId],
};
});
};
const saveSession = async () => {
if (!selectedEventId || !form.name.trim()) return;
if (form.audienceMode === "statuses" && !form.includeStatuses.length) {
setError("Select at least one status for the attendance list.");
return;
}
if (form.audienceMode === "tags" && !form.includeTags.length) {
setError("Select at least one tag for the attendance list.");
return;
}
if (form.audienceMode === "participant_lists" && !form.includeParticipantListIds.length) {
setError("Select at least one contact list for the attendance list.");
return;
}
setSaving(true);
const payload = {
name: form.name.trim(),
@ -152,6 +276,13 @@ export function AttendancePage() {
settings: {
allowDuplicateCheckIn: form.allowDuplicateCheckIn,
publicLinkEnabled: form.publicLinkEnabled,
audience: {
mode: form.audienceMode,
statuses: form.audienceMode === "statuses" ? form.includeStatuses : [],
tags: form.audienceMode === "tags" ? form.includeTags : [],
participantListIds:
form.audienceMode === "participant_lists" ? form.includeParticipantListIds : [],
},
},
};
const res = editingId
@ -348,6 +479,9 @@ export function AttendancePage() {
{s.totalCheckedIn ?? 0} check-in{(s.totalCheckedIn ?? 0) === 1 ? "" : "s"} ·{" "}
{s.uniqueParticipants ?? 0} people
</div>
<div style={{ fontSize: "11px", color: "var(--muted)", marginTop: 4 }}>
Includes: {audienceSummary(s)}
</div>
{s.status === "archived" && (
<span style={{ fontSize: "10px", color: "var(--muted)" }}>Archived</span>
)}
@ -383,6 +517,9 @@ export function AttendancePage() {
{selected.description}
</p>
)}
<p style={{ margin: "6px 0 0", fontSize: "var(--text-sm)", color: "var(--muted)" }}>
Includes: {audienceSummary(selected)}
</p>
</div>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
{canWriteAttendance && (
@ -565,7 +702,9 @@ export function AttendancePage() {
background: "var(--card)",
borderRadius: "var(--radius-card)",
width: "100%",
maxWidth: 440,
maxWidth: 520,
maxHeight: "90vh",
overflow: "auto",
boxShadow: "var(--elevation-sm)",
}}
onClick={(e) => e.stopPropagation()}
@ -593,6 +732,163 @@ export function AttendancePage() {
style={{ ...inputStyle, minHeight: 72, resize: "vertical" }}
/>
</label>
<div>
<div style={{ ...labelStyle, marginBottom: 8 }}>Who to include in attendance list</div>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{AUDIENCE_MODE_OPTIONS.map((opt) => (
<label
key={opt.value}
style={{
display: "flex",
alignItems: "flex-start",
gap: 8,
fontSize: "var(--text-sm)",
cursor: "pointer",
padding: "8px 10px",
borderRadius: "var(--radius-button)",
border:
form.audienceMode === opt.value
? "1px solid var(--primary)"
: "1px solid var(--border)",
background:
form.audienceMode === opt.value ? "var(--secondary)" : "transparent",
}}
>
<input
type="radio"
name="audienceMode"
checked={form.audienceMode === opt.value}
onChange={() => setForm((f) => ({ ...f, audienceMode: opt.value }))}
style={{ marginTop: 2 }}
/>
<span>
<span style={{ fontWeight: 600 }}>{opt.label}</span>
<span style={{ display: "block", fontSize: "11px", color: "var(--muted)" }}>
{opt.hint}
</span>
</span>
</label>
))}
</div>
</div>
{form.audienceMode === "statuses" && (
<div>
<div style={{ ...labelStyle, marginBottom: 8 }}>Statuses</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
{MEMBERSHIP_STATUS_OPTIONS.map((opt) => (
<label
key={opt.value}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
fontSize: "var(--text-sm)",
cursor: "pointer",
padding: "6px 10px",
border: "1px solid var(--border)",
borderRadius: "var(--radius-button)",
background: form.includeStatuses.includes(opt.value)
? "var(--secondary)"
: "var(--card)",
}}
>
<input
type="checkbox"
checked={form.includeStatuses.includes(opt.value)}
onChange={() => toggleStatus(opt.value)}
/>
{opt.label}
</label>
))}
</div>
</div>
)}
{form.audienceMode === "tags" && (
<div>
<div style={{ ...labelStyle, marginBottom: 8 }}>Tags</div>
{eventTags.length === 0 ? (
<p style={{ margin: 0, fontSize: "var(--text-sm)", color: "var(--muted)" }}>
No tags found for this event yet.
</p>
) : (
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, maxHeight: 160, overflow: "auto" }}>
{eventTags.map((tag) => (
<label
key={tag}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
fontSize: "var(--text-sm)",
cursor: "pointer",
padding: "6px 10px",
border: "1px solid var(--border)",
borderRadius: "var(--radius-button)",
background: form.includeTags.includes(tag)
? "var(--secondary)"
: "var(--card)",
}}
>
<input
type="checkbox"
checked={form.includeTags.includes(tag)}
onChange={() => toggleTag(tag)}
/>
{tag}
</label>
))}
</div>
)}
</div>
)}
{form.audienceMode === "participant_lists" && (
<div>
<div style={{ ...labelStyle, marginBottom: 8 }}>Contact lists</div>
{contactLists.length === 0 ? (
<p style={{ margin: 0, fontSize: "var(--text-sm)", color: "var(--muted)" }}>
No contact lists available.
</p>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 6, maxHeight: 180, overflow: "auto" }}>
{contactLists.map((list) => (
<label
key={list._id}
style={{
display: "flex",
alignItems: "center",
gap: 8,
fontSize: "var(--text-sm)",
cursor: "pointer",
padding: "8px 10px",
border: "1px solid var(--border)",
borderRadius: "var(--radius-button)",
background: form.includeParticipantListIds.includes(list._id)
? "var(--secondary)"
: "var(--card)",
}}
>
<input
type="checkbox"
checked={form.includeParticipantListIds.includes(list._id)}
onChange={() => toggleList(list._id)}
/>
<span>
<span style={{ fontWeight: 500 }}>{list.name}</span>
<span style={{ color: "var(--muted)", marginLeft: 6, fontSize: "11px" }}>
{list.type === "fixed" ? "Fixed" : "Rule-based"}
</span>
</span>
</label>
))}
</div>
)}
</div>
)}
<label style={{ display: "flex", alignItems: "center", gap: 8, fontSize: "var(--text-sm)", cursor: "pointer" }}>
<input
type="checkbox"

View File

@ -1,100 +1,17 @@
import React, { useState, useRef, useEffect } from "react";
import type { LoginResult } from "../../contexts/AuthContext";
import svgPaths from "../../../imports/svg-n2hu9pt0vm";
import { Eye, EyeOff, Calendar, Users, BarChart2 } from "lucide-react";
import { EventBuzzBrandLogo } from "../EventBuzzBrandLogo";
/* ─────────────────────────────────────────────────────────────────
BRAND CONSTANTS (kept only for gradient / orange-buzz – not for
BRAND CONSTANTS (kept only for gradient – not for
anything that the design system already covers via CSS vars)
───────────────────────────────────────────────────────────────── */
const BRAND_GRADIENT =
"linear-gradient(135deg, rgb(11,77,209) 0%, rgb(29,102,239) 45%, rgb(107,53,210) 100%)";
const BUZZ_ORANGE = "#EF8D1D"; // buzz wordmark – brand constant, no CSS var
/* ─────────────────────────────────────────────────────────────────
SHARED: EventBuzz Logo
variant="light" → Inter/event wordmark white, buzz orange, icon white+orange
variant="color" → Inter/event wordmark in --foreground (#0F3072), buzz orange
───────────────────────────────────────────────────────────────── */
function EventBuzzLogo({
variant = "color",
scale = 1,
}: {
variant?: "light" | "color";
scale?: number;
}) {
const wc = variant === "light" ? "white" : "#0F3072"; /* --foreground value */
const w = Math.round(320 * scale);
const h = Math.round(60 * scale);
return (
<div style={{ position: "relative", width: w, height: h, flexShrink: 0 }}>
{/* ── icon cluster ── */}
<svg
style={{ position: "absolute", left: 0, top: 0 }}
fill="none"
preserveAspectRatio="none"
viewBox="0 0 79.3139 60.1372"
width={Math.round(79 * scale)}
height={Math.round(60 * scale)}
>
<path d={svgPaths.pd494300} fill={BUZZ_ORANGE} />
<path d={svgPaths.p33b7c800} fill={BUZZ_ORANGE} />
<path d={svgPaths.p142b6380} fill={wc} />
<path d={svgPaths.p2c694f80} fill={wc} />
<circle cx="39.8711" cy="7.36343" r="6.01221" fill={wc} transform="rotate(75 39.8711 7.36343)" />
<circle cx="17.7246" cy="12.6819" r="5.28744" fill={BUZZ_ORANGE} transform="rotate(75 17.7246 12.6819)" />
<circle cx="5.28744" cy="5.28744" r="5.28744" fill={BUZZ_ORANGE}
transform="matrix(-0.258819 0.965926 0.965926 0.258819 58.2047 6.20616)" />
<path d={svgPaths.p1674a600} fill={wc} />
<path d={svgPaths.p6970200} fill={wc} />
<path d={svgPaths.p37f9b00} fill={wc} />
</svg>
{/* ── "event" wordmark ── */}
<svg
style={{ position: "absolute", left: Math.round(91 * scale), top: Math.round(13 * scale) }}
fill="none"
preserveAspectRatio="none"
viewBox="0 0 106.275 33.7343"
width={Math.round(106 * scale)}
height={Math.round(34 * scale)}
>
<path d={svgPaths.pdd63b00} fill={wc} />
<path d={svgPaths.p1945f480} fill={wc} />
<path d={svgPaths.p48e8300} fill={wc} />
<path d={svgPaths.p3763d600} fill={wc} />
<path d={svgPaths.p6535700} fill={wc} />
</svg>
{/* ── "buzz" wordmark ── */}
<svg
style={{ position: "absolute", left: Math.round(197 * scale), top: Math.round(8 * scale) }}
fill="none"
preserveAspectRatio="none"
viewBox="0 0 105.33 38.8275"
width={Math.round(106 * scale)}
height={Math.round(39 * scale)}
>
<path d={svgPaths.p30ec5580} fill={BUZZ_ORANGE} />
<path d={svgPaths.p2fc98c00} fill={BUZZ_ORANGE} />
<path d={svgPaths.p10df6080} fill={BUZZ_ORANGE} />
<path d={svgPaths.p24dd2380} fill={BUZZ_ORANGE} />
</svg>
{/* ── wifi arcs ── */}
<svg
style={{ position: "absolute", left: Math.round(298 * scale), top: Math.round(9 * scale) }}
fill="none"
preserveAspectRatio="none"
viewBox="0 0 21.5276 21.5276"
width={Math.round(22 * scale)}
height={Math.round(22 * scale)}
>
<path d={svgPaths.p1e5a9f00} fill={BUZZ_ORANGE} />
<path d={svgPaths.p5358df0} fill={BUZZ_ORANGE} />
</svg>
</div>
);
function EventBuzzLogo({ scale = 1 }: { variant?: "light" | "color"; scale?: number }) {
return <EventBuzzBrandLogo height={Math.round(48 * scale)} maxWidth={Math.round(240 * scale)} />;
}
/* ─────────────────────────────────────────────────────────────────

View File

@ -1,6 +1,8 @@
import type { BrochurePdfContext } from "./generateBrochurePdf";
import type { ApiSession } from "../../../services/api/sessions";
import type { Speaker } from "../../../services/api/types";
import { eventBuzzUrl } from "../agenda/eventBuzzCode";
export interface BrochureAgendaItem {
time: string;
@ -139,7 +141,8 @@ export function buildBrochurePreviewData(ctx: BrochurePdfContext): BrochurePrevi
registrationBlurb,
contactEmail: "info@event.example",
contactPhone: "—",
website: regUrl || (e?.joinCode ? `eventbuzz.live/code/${e.joinCode}` : "—"),
website: regUrl || (e?.joinCode ? eventBuzzUrl(e.joinCode).replace(/^https?:\/\//, "") : "—"),
sections: [...content.sections],
};
}

View File

@ -35,6 +35,7 @@ export function EventBadgesPanel({
const [badges, setBadges] = useState<BadgeBatchRecord[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [downloadingId, setDownloadingId] = useState<string | null>(null);
const loadBadges = useCallback(async () => {
setLoading(true);
@ -297,10 +298,26 @@ export function EventBadgesPanel({
</button>
) : null}
{b.status === "ready" && b.zipUrl ? (
<a
href={b.zipUrl}
target="_blank"
rel="noopener noreferrer"
<button
type="button"
disabled={downloadingId === b._id}
onClick={async () => {
setDownloadingId(b._id);
setError("");
try {
const org =
typeof b.organization === "string"
? b.organization
: undefined;
await badgeBatchesApi.downloadZip(b, org);
} catch (e) {
setError(
e instanceof Error ? e.message : "Download failed.",
);
} finally {
setDownloadingId(null);
}
}}
style={{
display: "inline-flex",
alignItems: "center",
@ -309,14 +326,17 @@ export function EventBadgesPanel({
borderRadius: "var(--radius-button)",
background: "var(--primary)",
color: "var(--primary-foreground)",
textDecoration: "none",
border: "none",
fontSize: "var(--text-xs)",
fontFamily: "var(--font-noto-sans)",
fontWeight: "var(--font-weight-medium)",
cursor: downloadingId === b._id ? "wait" : "pointer",
opacity: downloadingId === b._id ? 0.7 : 1,
}}
>
<Download size={13} /> Download zip
</a>
<Download size={13} />{" "}
{downloadingId === b._id ? "Downloading…" : "Download zip"}
</button>
) : (
<button
type="button"

View File

@ -0,0 +1,367 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Pencil, Plus, Search, Store, Trash2 } from "lucide-react";
import { exhibitorsApi, type ApiExhibitor } from "../../../services/api";
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: "color-mix(in srgb, var(--destructive) 45%, var(--border))",
};
function statusLabel(status?: ApiExhibitor["status"]): string {
if (status === "draft") return "Draft";
if (status === "hidden") return "Hidden";
return "Published";
}
export function EventExhibitorsPanel({
eventId,
eventName,
canWrite,
onClose,
onAdd,
onEdit,
onChanged,
}: {
eventId: string;
eventName?: string;
canWrite?: boolean;
onClose: () => void;
onAdd: () => void;
onEdit: (exhibitor: ApiExhibitor) => void;
onChanged?: () => void;
}) {
const [exhibitors, setExhibitors] = useState<ApiExhibitor[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [search, setSearch] = useState("");
const [deletingId, setDeletingId] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError("");
try {
const res = await exhibitorsApi.list(eventId);
if (res.error) {
setError(typeof res.error === "string" ? res.error : "Failed to load exhibitors.");
setExhibitors([]);
} else {
setExhibitors(res.data?.exhibitors ?? []);
}
} catch {
setError("Failed to load exhibitors.");
setExhibitors([]);
} finally {
setLoading(false);
}
}, [eventId]);
useEffect(() => {
void load();
}, [load]);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return exhibitors;
return exhibitors.filter((ex) => {
const hay = [
ex.name,
ex.category,
ex.boothNumber,
ex.hall,
ex.contactName,
ex.contactEmail,
ex.status,
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return hay.includes(q);
});
}, [exhibitors, search]);
const remove = async (ex: ApiExhibitor) => {
if (!window.confirm(`Delete exhibitor "${ex.name}"?`)) return;
setDeletingId(ex._id);
const res = await exhibitorsApi.remove(ex._id);
setDeletingId(null);
if (res.error) {
setError(typeof res.error === "string" ? res.error : "Could not delete exhibitor.");
return;
}
await load();
onChanged?.();
};
return (
<PanelShell
onClose={onClose}
title="Exhibitors"
breadcrumb={eventName ? `Event Home · ${eventName}` : "Event Home · Exhibitors"}
description="Manage companies exhibiting at this event."
completedCount={0}
totalSections={1}
ctaLabel="Add exhibitor"
ctaDisabled={!canWrite}
onCtaClick={canWrite ? onAdd : undefined}
showProgress={false}
hidePrimaryCta={!canWrite}
panelWidth="min(780px, 100vw)"
>
<div style={{ padding: "16px 20px", display: "flex", flexDirection: "column", gap: 14 }}>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<div style={{ position: "relative", flex: 1 }}>
<Search
size={14}
style={{
position: "absolute",
left: 10,
top: "50%",
transform: "translateY(-50%)",
color: "var(--muted)",
}}
/>
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search by name, booth, category…"
style={{
width: "100%",
padding: "8px 12px 8px 32px",
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",
}}
/>
</div>
{canWrite ? (
<button
type="button"
onClick={onAdd}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "8px 12px",
borderRadius: "var(--radius-button)",
border: "none",
background: "var(--primary)",
color: "var(--primary-foreground)",
fontSize: "var(--text-xs)",
fontFamily: "var(--font-noto-sans)",
fontWeight: "var(--font-weight-medium)",
cursor: "pointer",
whiteSpace: "nowrap",
}}
>
<Plus size={14} /> Add exhibitor
</button>
) : null}
</div>
{loading ? (
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", margin: 0 }}>
Loading exhibitors…
</p>
) : null}
{error ? (
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--destructive)", margin: 0 }}>
{error}
</p>
) : null}
{!loading && !error && filtered.length === 0 ? (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
padding: "48px 24px",
gap: 12,
}}
>
<Store size={36} style={{ color: "var(--border)" }} />
<p
style={{
fontFamily: "var(--font-inter)",
fontSize: "var(--text-base)",
fontWeight: "var(--font-weight-medium)",
color: "var(--foreground)",
margin: 0,
}}
>
{exhibitors.length === 0 ? "No exhibitors yet" : "No matches"}
</p>
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", margin: 0 }}>
{exhibitors.length === 0
? "Add companies exhibiting at this event to build the exhibitor directory."
: "Try another search."}
</p>
{canWrite && exhibitors.length === 0 ? (
<button
type="button"
onClick={onAdd}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
marginTop: 4,
padding: "8px 14px",
borderRadius: "var(--radius-button)",
border: "none",
background: "var(--primary)",
color: "var(--primary-foreground)",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
cursor: "pointer",
}}
>
<Plus size={14} /> Add exhibitor
</button>
) : null}
</div>
) : null}
{!loading && filtered.length > 0 ? (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{filtered.map((ex) => (
<div
key={ex._id}
style={{
display: "grid",
gridTemplateColumns: "minmax(0, 1fr) auto",
gap: 12,
alignItems: "center",
padding: "12px 14px",
border: "1px solid var(--border)",
borderRadius: "var(--radius-card)",
background: "var(--card)",
}}
>
<div style={{ minWidth: 0, display: "flex", gap: 12, alignItems: "center" }}>
{ex.logo ? (
<img
src={ex.logo}
alt=""
style={{
width: 40,
height: 40,
objectFit: "contain",
borderRadius: "var(--radius-button)",
border: "1px solid var(--border)",
background: "var(--secondary)",
flexShrink: 0,
}}
/>
) : (
<div
style={{
width: 40,
height: 40,
borderRadius: "var(--radius-button)",
background: "color-mix(in srgb, var(--primary) 12%, transparent)",
color: "var(--primary)",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<Store size={18} />
</div>
)}
<div style={{ minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
<p
style={{
margin: 0,
fontFamily: "var(--font-inter)",
fontSize: "var(--text-sm)",
fontWeight: "var(--font-weight-medium)",
color: "var(--foreground)",
}}
>
{ex.name}
{ex.isFeatured ? (
<span style={{ marginLeft: 6, fontSize: "var(--text-xs)", color: "var(--primary)" }}>
Featured
</span>
) : null}
</p>
<span
style={{
fontSize: "11px",
fontFamily: "var(--font-noto-sans)",
padding: "2px 6px",
borderRadius: 999,
background: "var(--secondary)",
color: "var(--muted)",
border: "1px solid var(--border)",
}}
>
{statusLabel(ex.status)}
</span>
</div>
<p
style={{
margin: "4px 0 0",
fontSize: "var(--text-xs)",
fontFamily: "var(--font-noto-sans)",
color: "var(--muted)",
}}
>
{[
ex.boothNumber ? `Booth ${ex.boothNumber}` : null,
ex.hall || null,
ex.category || null,
]
.filter(Boolean)
.join(" · ") || "No booth details yet"}
</p>
</div>
</div>
{canWrite ? (
<div style={{ display: "flex", gap: 6, flexShrink: 0 }}>
<button type="button" style={btnSecondary} onClick={() => onEdit(ex)}>
<Pencil size={12} /> Edit
</button>
<button
type="button"
style={btnDanger}
disabled={deletingId === ex._id}
onClick={() => void remove(ex)}
>
<Trash2 size={12} /> {deletingId === ex._id ? "…" : "Delete"}
</button>
</div>
) : null}
</div>
))}
</div>
) : null}
</div>
</PanelShell>
);
}

View File

@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useState } from "react";
import { BarChart2, CalendarDays, Check, FileText, Globe, Link2, Mail, Plus, Share2, ShieldCheck, Tag, Ticket, Trophy, Upload } from "lucide-react";
import { BarChart2, CalendarDays, Check, FileText, Globe, Link2, Mail, Plus, Share2, ShieldCheck, Store, Tag, Ticket, Trophy, Upload } from "lucide-react";
import { useBreakpoint } from "../../hooks/useBreakpoint";
import { useAppNav } from "../../contexts/AppNavContext";
import { useAuth } from "../../contexts/AuthContext";
@ -20,11 +20,21 @@ import { CreateBadgePanel } from "../panels/CreateBadgePanel";
import { EventBadgesPanel } from "./EventBadgesPanel";
import { EventDocumentsPanel } from "./EventDocumentsPanel";
import { EventEmailCampaignsPanel } from "./EventEmailCampaignsPanel";
import { EventExhibitorsPanel } from "./EventExhibitorsPanel";
import { AddExhibitorPanel } from "../panels/AddExhibitorPanel";
import { AddQuizPollModal, buildQuizPollSettingsPayload } from "../quiz/AddQuizPollModal";
import type { QuizPollModalForm } from "../quiz/AddQuizPollModal";
import type { Question } from "../quiz/AddQuestionModal";
import { modalQuestionToApi } from "../quiz/quizQuestionMappers";
import { sessionsApi, tracksApi, formsApi, staticMicrositeApi, staticMicrositePublicUrl } from "../../../services/api";
import {
sessionsApi,
tracksApi,
formsApi,
staticMicrositeApi,
staticMicrositePublicUrl,
exhibitorsApi,
type ApiExhibitor,
} from "../../../services/api";
import type { FormWithSubmissionCount } from "../../../services/api/types";
import { publicFormPageUrl } from "../../utils/publicFormUrl";
@ -222,6 +232,8 @@ export function EventHomeModules() {
const canReadDocuments = canModule("content_management", "read");
const canReadEmailCampaigns = canModule("email_outreach_campaigns", "read");
const canWriteEmailCampaigns = canModule("email_outreach_campaigns", "write");
const canReadExhibitors = canModule("exhibitors", "read");
const canWriteExhibitors = canModule("exhibitors", "write");
const bp = useBreakpoint();
const isMobile = bp === "mobile";
const isTablet = bp === "tablet";
@ -267,6 +279,11 @@ export function EventHomeModules() {
const [emailCampaignLoading, setEmailCampaignLoading] = useState(false);
const [showCreateEmailCampaign, setShowCreateEmailCampaign] = useState(false);
const [showEmailCampaignsPanel, setShowEmailCampaignsPanel] = useState(false);
const [exhibitorCount, setExhibitorCount] = useState(0);
const [exhibitorLoading, setExhibitorLoading] = useState(false);
const [showExhibitorsPanel, setShowExhibitorsPanel] = useState(false);
const [showAddExhibitor, setShowAddExhibitor] = useState(false);
const [editingExhibitor, setEditingExhibitor] = useState<ApiExhibitor | null>(null);
useEffect(() => {
if (!selectedEventId) {
@ -495,6 +512,27 @@ export function EventHomeModules() {
void loadEmailCampaignMeta();
}, [loadEmailCampaignMeta]);
const loadExhibitorMeta = useCallback(async () => {
if (!selectedEventId) {
setExhibitorCount(0);
setExhibitorLoading(false);
return;
}
setExhibitorLoading(true);
try {
const res = await exhibitorsApi.list(selectedEventId);
setExhibitorCount(res.data?.total ?? res.data?.exhibitors?.length ?? 0);
} catch {
setExhibitorCount(0);
} finally {
setExhibitorLoading(false);
}
}, [selectedEventId]);
useEffect(() => {
void loadExhibitorMeta();
}, [loadExhibitorMeta]);
const handleBadgeSuccess = () => {
setShowAddBadge(false);
setDuplicateBadgeBatchId(null);
@ -578,6 +616,11 @@ export function EventHomeModules() {
: emailCampaignLoading
? "Loading…"
: `${emailCampaignCount} email campaign${emailCampaignCount === 1 ? "" : "s"}`;
const exhibitorMeta = !selectedEventId
? "Select an event to manage exhibitors"
: exhibitorLoading
? "Loading…"
: `${exhibitorCount} exhibitor${exhibitorCount === 1 ? "" : "s"}`;
const webCheckInMeta = !selectedEventId
? "Select an event to configure web check-in"
: webCheckInLoading
@ -1142,6 +1185,55 @@ export function EventHomeModules() {
}
/>
) : null}
{canReadExhibitors ? (
<ModuleCard
icon={<Store size={18} />}
title="Exhibitors"
meta={exhibitorMeta}
description="Companies and booths for this event"
actions={
<>
<button
type="button"
disabled={actionsDisabled}
style={{
...moduleButtonStyle(),
opacity: actionsDisabled ? 0.55 : 1,
cursor: actionsDisabled ? "not-allowed" : "pointer",
}}
onClick={() => setShowExhibitorsPanel(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 || !canWriteExhibitors}
style={{
...moduleButtonStyle(true),
opacity: actionsDisabled || !canWriteExhibitors ? 0.55 : 1,
cursor: actionsDisabled || !canWriteExhibitors ? "not-allowed" : "pointer",
}}
onClick={() => {
setEditingExhibitor(null);
setShowAddExhibitor(true);
}}
>
Add exhibitor
</button>
</>
}
/>
) : null}
</section>
{formShareTarget ? (
@ -1259,6 +1351,43 @@ export function EventHomeModules() {
onSuccess={() => void loadEmailCampaignMeta()}
/>
) : null}
{showExhibitorsPanel && selectedEventId ? (
<EventExhibitorsPanel
eventId={selectedEventId}
eventName={eventName ?? undefined}
canWrite={canWriteExhibitors}
onClose={() => setShowExhibitorsPanel(false)}
onAdd={() => {
setShowExhibitorsPanel(false);
setEditingExhibitor(null);
setShowAddExhibitor(true);
}}
onEdit={(ex) => {
setShowExhibitorsPanel(false);
setEditingExhibitor(ex);
setShowAddExhibitor(true);
}}
onChanged={() => void loadExhibitorMeta()}
/>
) : null}
{showAddExhibitor && selectedEventId ? (
<AddExhibitorPanel
key={editingExhibitor?._id ?? "create"}
eventId={selectedEventId}
eventName={eventName ?? undefined}
exhibitor={editingExhibitor}
onClose={() => {
setShowAddExhibitor(false);
setEditingExhibitor(null);
}}
onSuccess={() => {
void loadExhibitorMeta();
setShowExhibitorsPanel(true);
}}
/>
) : null}
</>
);
}

View File

@ -180,6 +180,8 @@ export function DesignPage({ subPage }: { subPage?: string }) {
const [showMicrosite, setShowMicrosite] = useState(false);
const [showBadge, setShowBadge] = useState(false);
const [duplicateBadgeBatchId, setDuplicateBadgeBatchId] = useState<string | null>(null);
const [badgeDeleteConfirm, setBadgeDeleteConfirm] = useState<BadgeBatchRecord | null>(null);
const [badgeDeleting, setBadgeDeleting] = useState(false);
const [showEmailer, setShowEmailer] = useState(false);
const [showImport, setShowImport] = useState(false);
const [editTemplateId, setEditTemplateId] = useState<string | null>(null);
@ -340,6 +342,17 @@ export function DesignPage({ subPage }: { subPage?: string }) {
if (!res.error) fetchDesignList();
};
const handleBadgeDeleteConfirm = async () => {
if (!badgeDeleteConfirm || badgeDeleting) return;
const id = badgeDeleteConfirm._id;
setBadgeDeleting(true);
const res = await badgeBatchesApi.remove(id, badgeListOrgId);
setBadgeDeleting(false);
setBadgeDeleteConfirm(null);
if (!res.error) fetchDesignList();
else setError(res.error || "Failed to delete badge batch");
};
const handleBrochurePdf = async (t: DesignTemplate, mode: "preview" | "download") => {
if (apiType !== "brochure") return;
setBrochurePdfBusyId(t._id);
@ -651,6 +664,31 @@ export function DesignPage({ subPage }: { subPage?: string }) {
<Copy size={13} /> Duplicate
</button>
)}
{canManageBadgeBatch && (
<button
type="button"
title={b.status === "processing" ? "Cannot delete while processing" : "Delete badge export"}
disabled={b.status === "processing" || badgeDeleting}
onClick={() => setBadgeDeleteConfirm(b)}
style={{
display: "inline-flex",
alignItems: "center",
gap: "6px",
padding: "6px 12px",
borderRadius: "var(--radius-button)",
border: "1px solid var(--border)",
background: "var(--card)",
color: b.status === "processing" ? "var(--muted)" : "var(--destructive)",
fontSize: "var(--text-xs)",
fontFamily: "var(--font-noto-sans)",
fontWeight: "var(--font-weight-medium)",
cursor: b.status === "processing" || badgeDeleting ? "not-allowed" : "pointer",
opacity: b.status === "processing" ? 0.7 : 1,
}}
>
<Trash2 size={13} /> Delete
</button>
)}
{b.status === "ready" && b.zipUrl ? (
<a
href={b.zipUrl}
@ -873,6 +911,18 @@ export function DesignPage({ subPage }: { subPage?: string }) {
variant="danger"
/>
)}
{badgeDeleteConfirm && (
<ConfirmModal
title="Delete badge export?"
message={`Delete "${badgeDeleteConfirm.name}"? The generated zip will be removed. This cannot be undone.`}
confirmLabel={badgeDeleting ? "Deleting…" : "Delete"}
onConfirm={handleBadgeDeleteConfirm}
onCancel={() => {
if (!badgeDeleting) setBadgeDeleteConfirm(null);
}}
variant="danger"
/>
)}
</>
);
}

View File

@ -1313,6 +1313,7 @@ function EmailDeliverySection() {
const [hasPassword, setHasPassword] = useState(false);
const [fromName, setFromName] = useState("");
const [fromAddress, setFromAddress] = useState("");
const [replyTo, setReplyTo] = useState("");
const [testTo, setTestTo] = useState("");
const [msg, setMsg] = useState("");
const [err, setErr] = useState("");
@ -1333,6 +1334,7 @@ function EmailDeliverySection() {
setHasPassword(d.hasPassword);
setFromName(d.fromName || "");
setFromAddress(d.fromAddress || "");
setReplyTo(d.replyTo || "");
}
} catch (e) {
setErr(e instanceof Error ? e.message : "Failed to load");
@ -1358,6 +1360,7 @@ function EmailDeliverySection() {
password: password || undefined,
fromName,
fromAddress,
replyTo,
});
if (r.error) throw new Error(r.error);
setPassword("");
@ -1388,7 +1391,10 @@ function EmailDeliverySection() {
return (
<div style={{ display: "flex", flexDirection: "column", gap: "16px", maxWidth: 520 }}>
<Card title="SMTP (bring your own)" subtitle="Campaign and system emails use this server when enabled. Password is stored encrypted on the server.">
<Card
title="SMTP (bring your own)"
subtitle="Turn on Use custom SMTP to send via your own mail server. From name and Reply-To are used for organisation emails even when custom SMTP is off (mail still goes through the platform SMTP)."
>
{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" />
@ -1438,12 +1444,41 @@ function EmailDeliverySection() {
<Field>
<Label text="From name" />
<input style={IS} value={fromName} onChange={(e) => setFromName(e.target.value)} onFocus={focusIn} onBlur={focusOut} />
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: "4px 0 0" }}>
Used even when custom SMTP is off.
</p>
</Field>
<Field>
<Label text="From email" required />
<input style={IS} value={fromAddress} onChange={(e) => setFromAddress(e.target.value)} placeholder="hello@yourdomain.com" onFocus={focusIn} onBlur={focusOut} />
<Label text="From email" required={enabled} />
<input
style={IS}
value={fromAddress}
onChange={(e) => setFromAddress(e.target.value)}
placeholder="hello@yourdomain.com"
onFocus={focusIn}
onBlur={focusOut}
disabled={!enabled}
/>
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: "4px 0 0" }}>
Required when custom SMTP is on. Ignored when off (platform From email is used).
</p>
</Field>
</Grid2>
<Field>
<Label text="Reply-To email" />
<input
style={IS}
type="email"
value={replyTo}
onChange={(e) => setReplyTo(e.target.value)}
placeholder="replies@yourdomain.com"
onFocus={focusIn}
onBlur={focusOut}
/>
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: "4px 0 0" }}>
Used even when custom SMTP is off. When custom SMTP is on and this is blank, defaults to From email.
</p>
</Field>
<SaveAction onSave={save} disabled={enabled && (!host.trim() || !fromAddress.trim())} />
<Divider />
<Field>

View File

@ -6,6 +6,7 @@ export const EMAIL_PLACEHOLDERS: { token: string; label: string }[] = [
{ token: "{{contact.email}}", label: "Email" },
{ token: "{{contact.organization}}", label: "Company" },
{ token: "{{contact.phone}}", label: "Phone" },
{ token: "{{contact.qrImage}}", label: "Participant QR" },
{ token: "{{event.name}}", label: "Event name" },
{ token: "{{event.startDate}}", label: "Event start" },
{ token: "{{event.endDate}}", label: "Event end" },
@ -17,7 +18,7 @@ export const EMAIL_PLACEHOLDERS: { token: string; label: string }[] = [
/** Dropdown options: map each Meta `{{n}}` to a contact/event field for sending (samples still required for Meta review). */
export const WA_TEMPLATE_MERGE_OPTIONS: { mergeKey: string; label: string }[] = [
{ mergeKey: "", label: "— None (manual sample only) —" },
...EMAIL_PLACEHOLDERS.map(({ token, label }) => ({
...EMAIL_PLACEHOLDERS.filter((p) => p.token !== "{{contact.qrImage}}").map(({ token, label }) => ({
mergeKey: token.replace(/^\{\{|\}\}$/g, "").trim(),
label,
})),

View File

@ -0,0 +1,513 @@
import React, { useMemo, useRef, useState } from "react";
import { ChevronDown, Upload } from "lucide-react";
import {
exhibitorsApi,
uploadMediaFile,
type ApiExhibitor,
type ExhibitorPayload,
type ExhibitorStatus,
} from "../../../services/api";
import { PanelShell } from "./PanelShell";
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",
};
const SS: React.CSSProperties = {
...IS,
cursor: "pointer",
appearance: "none",
paddingRight: "32px",
};
const STATUS_OPTIONS: { value: ExhibitorStatus; label: string }[] = [
{ value: "published", label: "Published" },
{ value: "draft", label: "Draft" },
{ value: "hidden", label: "Hidden" },
];
type FormState = {
name: string;
status: ExhibitorStatus;
logo: string;
shortDescription: string;
description: string;
category: string;
boothNumber: string;
hall: string;
website: string;
isFeatured: boolean;
contactName: string;
contactEmail: string;
contactPhone: string;
linkedin: string;
twitter: string;
instagram: string;
socialWebsite: string;
productsText: string;
internalNotes: string;
};
function fromExhibitor(ex?: ApiExhibitor | null): FormState {
return {
name: ex?.name || "",
status: ex?.status || "published",
logo: ex?.logo || "",
shortDescription: ex?.shortDescription || "",
description: ex?.description || "",
category: ex?.category || "",
boothNumber: ex?.boothNumber || "",
hall: ex?.hall || "",
website: ex?.website || "",
isFeatured: Boolean(ex?.isFeatured),
contactName: ex?.contactName || "",
contactEmail: ex?.contactEmail || "",
contactPhone: ex?.contactPhone || "",
linkedin: ex?.socialLinks?.linkedin || "",
twitter: ex?.socialLinks?.twitter || "",
instagram: ex?.socialLinks?.instagram || "",
socialWebsite: ex?.socialLinks?.website || "",
productsText: Array.isArray(ex?.products) ? ex!.products!.join(", ") : "",
internalNotes: ex?.internalNotes || "",
};
}
function Label({ text, required }: { text: string; required?: boolean }) {
return (
<label
style={{
display: "block",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
fontWeight: "var(--font-weight-medium)",
color: "var(--card-foreground)",
marginBottom: "5px",
}}
>
{required ? <span style={{ color: "var(--destructive)", marginRight: 3 }}>*</span> : null}
{text}
</label>
);
}
function Field({ children, error }: { children: React.ReactNode; error?: string }) {
return (
<div style={{ display: "flex", flexDirection: "column" }}>
{children}
{error ? (
<p
style={{
fontSize: "var(--text-xs)",
fontFamily: "var(--font-noto-sans)",
color: "var(--destructive)",
margin: "5px 0 0",
}}
>
{error}
</p>
) : null}
</div>
);
}
function SelectWrap({ children }: { children: React.ReactNode }) {
return (
<div style={{ position: "relative" }}>
{children}
<ChevronDown
size={13}
style={{
position: "absolute",
right: 10,
top: "50%",
transform: "translateY(-50%)",
color: "var(--muted)",
pointerEvents: "none",
}}
/>
</div>
);
}
export function AddExhibitorPanel({
eventId,
eventName,
exhibitor,
onClose,
onSuccess,
}: {
eventId: string;
eventName?: string;
exhibitor?: ApiExhibitor | null;
onClose: () => void;
onSuccess?: () => void;
}) {
const isEdit = Boolean(exhibitor?._id);
const [form, setForm] = useState<FormState>(() => fromExhibitor(exhibitor));
const [errors, setErrors] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const [saveErr, setSaveErr] = useState<string | null>(null);
const [uploadingLogo, setUploadingLogo] = useState(false);
const [showOps, setShowOps] = useState(Boolean(exhibitor?.internalNotes));
const logoInputRef = useRef<HTMLInputElement>(null);
const set = <K extends keyof FormState>(key: K, value: FormState[K]) => {
setForm((prev) => ({ ...prev, [key]: value }));
if (errors[key]) setErrors((prev) => {
const next = { ...prev };
delete next[key];
return next;
});
};
const completedCount = useMemo(() => (form.name.trim() ? 1 : 0), [form.name]);
const buildPayload = (): ExhibitorPayload => {
const products = form.productsText
.split(/[,;\n]/)
.map((t) => t.trim())
.filter(Boolean);
return {
name: form.name.trim(),
status: form.status,
logo: form.logo.trim() || undefined,
shortDescription: form.shortDescription.trim() || undefined,
description: form.description.trim() || undefined,
category: form.category.trim() || undefined,
boothNumber: form.boothNumber.trim() || undefined,
hall: form.hall.trim() || undefined,
website: form.website.trim() || undefined,
isFeatured: form.isFeatured,
contactName: form.contactName.trim() || undefined,
contactEmail: form.contactEmail.trim() || undefined,
contactPhone: form.contactPhone.trim() || undefined,
socialLinks: {
linkedin: form.linkedin.trim() || undefined,
twitter: form.twitter.trim() || undefined,
instagram: form.instagram.trim() || undefined,
website: form.socialWebsite.trim() || undefined,
},
products,
internalNotes: form.internalNotes.trim() || undefined,
};
};
const validate = () => {
const next: Record<string, string> = {};
if (!form.name.trim()) next.name = "Company / brand name is required.";
if (form.contactEmail.trim() && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.contactEmail.trim())) {
next.contactEmail = "Enter a valid email.";
}
setErrors(next);
return Object.keys(next).length === 0;
};
const submit = async () => {
setSaveErr(null);
if (!validate()) return;
setSaving(true);
const payload = buildPayload();
const res = isEdit
? await exhibitorsApi.update(exhibitor!._id, payload)
: await exhibitorsApi.create(eventId, payload);
setSaving(false);
if (res.error) {
setSaveErr(typeof res.error === "string" ? res.error : "Could not save exhibitor.");
return;
}
onSuccess?.();
onClose();
};
const uploadLogo = async (file: File | null) => {
if (!file) return;
setUploadingLogo(true);
setSaveErr(null);
try {
const res = await uploadMediaFile(file, { eventId, module: "exhibitors/logo" });
if (!res?.url) throw new Error("Upload did not return a URL");
set("logo", res.url);
} catch (e) {
setSaveErr(e instanceof Error ? e.message : "Logo upload failed.");
} finally {
setUploadingLogo(false);
}
};
return (
<PanelShell
onClose={onClose}
title={isEdit ? "Edit exhibitor" : "Add exhibitor"}
breadcrumb={eventName ? `Event Home · ${eventName}` : "Event Home · Exhibitors"}
description="Company listing for this event’s exhibitor hall."
completedCount={completedCount}
totalSections={1}
ctaLabel={saving ? "Saving…" : isEdit ? "Save changes" : "Add exhibitor"}
ctaDisabled={saving || uploadingLogo}
onCtaClick={() => void submit()}
showProgress={false}
panelWidth="min(640px, 100vw)"
>
<div style={{ padding: "16px 20px", display: "flex", flexDirection: "column", gap: 16 }}>
{saveErr ? (
<p style={{ margin: 0, fontSize: "var(--text-sm)", color: "var(--destructive)", fontFamily: "var(--font-noto-sans)" }}>
{saveErr}
</p>
) : null}
<Field error={errors.name}>
<Label text="Company / brand name" required />
<input value={form.name} onChange={(e) => set("name", e.target.value)} style={IS} placeholder="e.g. Acme Devices" />
</Field>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
<Field>
<Label text="Status" />
<SelectWrap>
<select value={form.status} onChange={(e) => set("status", e.target.value as ExhibitorStatus)} style={SS}>
{STATUS_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</SelectWrap>
</Field>
<Field>
<Label text="Category / industry" />
<input
value={form.category}
onChange={(e) => set("category", e.target.value)}
style={IS}
placeholder="e.g. Pharma, Devices"
/>
</Field>
</div>
<Field>
<Label text="Logo" />
<div style={{ display: "flex", gap: 10, alignItems: "center" }}>
{form.logo ? (
<img
src={form.logo}
alt=""
style={{
width: 48,
height: 48,
objectFit: "contain",
borderRadius: "var(--radius-button)",
border: "1px solid var(--border)",
background: "var(--secondary)",
}}
/>
) : null}
<button
type="button"
disabled={uploadingLogo}
onClick={() => logoInputRef.current?.click()}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "8px 12px",
borderRadius: "var(--radius-button)",
border: "1px dashed var(--border)",
background: "transparent",
cursor: uploadingLogo ? "wait" : "pointer",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
color: "var(--primary)",
}}
>
<Upload size={14} /> {uploadingLogo ? "Uploading…" : form.logo ? "Replace logo" : "Upload logo"}
</button>
<input
ref={logoInputRef}
type="file"
accept="image/*"
hidden
onChange={(e) => {
const file = e.target.files?.[0] || null;
e.target.value = "";
void uploadLogo(file);
}}
/>
</div>
<input
value={form.logo}
onChange={(e) => set("logo", e.target.value)}
style={{ ...IS, marginTop: 8 }}
placeholder="Or paste logo URL"
/>
</Field>
<Field>
<Label text="Short description" />
<input
value={form.shortDescription}
onChange={(e) => set("shortDescription", e.target.value)}
style={IS}
placeholder="1–2 lines for cards"
maxLength={180}
/>
</Field>
<Field>
<Label text="About / full description" />
<textarea
value={form.description}
onChange={(e) => set("description", e.target.value)}
rows={4}
style={{ ...IS, resize: "vertical", minHeight: 96 }}
placeholder="Longer company overview for attendees"
/>
</Field>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
<Field>
<Label text="Booth number" />
<input value={form.boothNumber} onChange={(e) => set("boothNumber", e.target.value)} style={IS} placeholder="e.g. A12" />
</Field>
<Field>
<Label text="Hall / zone / floor" />
<input value={form.hall} onChange={(e) => set("hall", e.target.value)} style={IS} placeholder="e.g. Hall B" />
</Field>
</div>
<Field>
<Label text="Website" />
<input value={form.website} onChange={(e) => set("website", e.target.value)} style={IS} placeholder="https://" />
</Field>
<label
style={{
display: "inline-flex",
alignItems: "center",
gap: 8,
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
cursor: "pointer",
}}
>
<input type="checkbox" checked={form.isFeatured} onChange={(e) => set("isFeatured", e.target.checked)} />
Featured exhibitor
</label>
<div>
<p
style={{
margin: "0 0 10px",
fontSize: "var(--text-sm)",
fontWeight: "var(--font-weight-medium)",
fontFamily: "var(--font-inter)",
}}
>
Primary contact
</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
<Field>
<Label text="Contact name" />
<input value={form.contactName} onChange={(e) => set("contactName", e.target.value)} style={IS} />
</Field>
<Field error={errors.contactEmail}>
<Label text="Contact email" />
<input
type="email"
value={form.contactEmail}
onChange={(e) => set("contactEmail", e.target.value)}
style={IS}
/>
</Field>
</div>
<div style={{ marginTop: 12 }}>
<Field>
<Label text="Contact phone" />
<input value={form.contactPhone} onChange={(e) => set("contactPhone", e.target.value)} style={IS} />
</Field>
</div>
</div>
<div>
<p
style={{
margin: "0 0 10px",
fontSize: "var(--text-sm)",
fontWeight: "var(--font-weight-medium)",
fontFamily: "var(--font-inter)",
}}
>
Social links
</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
<Field>
<Label text="LinkedIn" />
<input value={form.linkedin} onChange={(e) => set("linkedin", e.target.value)} style={IS} />
</Field>
<Field>
<Label text="Twitter / X" />
<input value={form.twitter} onChange={(e) => set("twitter", e.target.value)} style={IS} />
</Field>
<Field>
<Label text="Instagram" />
<input value={form.instagram} onChange={(e) => set("instagram", e.target.value)} style={IS} />
</Field>
<Field>
<Label text="Social website" />
<input value={form.socialWebsite} onChange={(e) => set("socialWebsite", e.target.value)} style={IS} />
</Field>
</div>
</div>
<Field>
<Label text="Products / offerings" />
<input
value={form.productsText}
onChange={(e) => set("productsText", e.target.value)}
style={IS}
placeholder="Comma-separated tags"
/>
</Field>
<div>
<button
type="button"
onClick={() => setShowOps((v) => !v)}
style={{
background: "none",
border: "none",
padding: 0,
cursor: "pointer",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
color: "var(--primary)",
marginBottom: showOps ? 10 : 0,
}}
>
{showOps ? "Hide internal notes" : "Show internal notes (staff only)"}
</button>
{showOps ? (
<Field>
<Label text="Internal notes" />
<textarea
value={form.internalNotes}
onChange={(e) => set("internalNotes", e.target.value)}
rows={3}
style={{ ...IS, resize: "vertical" }}
placeholder="Not shown to attendees"
/>
</Field>
) : null}
</div>
</div>
</PanelShell>
);
}

View File

@ -61,6 +61,7 @@ export function BadgeDesignerPreview({
aspectRatio,
backgroundColor,
backgroundImageUrl,
backgroundIsPdf = false,
backgroundImageLayout,
layoutElements,
customImages,
@ -76,6 +77,8 @@ export function BadgeDesignerPreview({
badgeAspectRatio: number;
backgroundColor: string;
backgroundImageUrl: string;
/** True when background is a PDF template (blob upload or .pdf URL). */
backgroundIsPdf?: boolean;
backgroundImageLayout: BadgeBackgroundImageLayout;
layoutElements: BadgeLayoutElement[];
customImages: { id: string; imageUrl: string }[];
@ -312,17 +315,57 @@ export function BadgeDesignerPreview({
overflow: "hidden",
}}
>
<img
src={backgroundImageUrl}
alt=""
draggable={false}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
pointerEvents: "none",
}}
/>
{backgroundIsPdf ? (
<div
style={{
width: "100%",
height: "100%",
position: "relative",
pointerEvents: "none",
background: "rgba(15,23,42,0.04)",
}}
>
<object
data={backgroundImageUrl}
type="application/pdf"
style={{
width: "100%",
height: "100%",
border: "none",
pointerEvents: "none",
}}
>
<div
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 12,
textAlign: "center",
fontSize: 11,
color: "var(--muted)",
fontFamily: "var(--font-noto-sans)",
}}
>
PDF template — overlays previewed as fields
</div>
</object>
</div>
) : (
<img
src={backgroundImageUrl}
alt=""
draggable={false}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
pointerEvents: "none",
}}
/>
)}
</div>
) : null}
{layoutElements.map((el) => (

View File

@ -183,6 +183,30 @@ function TextInspector({
</div>
</div>
<div>
<span style={{ fontSize: "var(--text-xs)", color: "var(--muted)" }}>Prefix</span>
<input
style={{ ...IS, marginTop: 4 }}
value={element.prefix ?? ""}
onChange={(e) => onChange({ prefix: e.target.value })}
onFocus={focusIn}
onBlur={focusOut}
placeholder="e.g. Dr. "
/>
</div>
<div>
<span style={{ fontSize: "var(--text-xs)", color: "var(--muted)" }}>Postfix</span>
<input
style={{ ...IS, marginTop: 4 }}
value={element.postfix ?? ""}
onChange={(e) => onChange({ postfix: e.target.value })}
onFocus={focusIn}
onBlur={focusOut}
placeholder="e.g. , PhD"
/>
</div>
<div>
<span style={{ fontSize: "var(--text-xs)", color: "var(--muted)" }}>Font family</span>
<SelectWrap>

View File

@ -118,7 +118,7 @@ function BackgroundUploadButton({
<input
ref={ref}
type="file"
accept="image/*"
accept="image/*,.pdf,application/pdf"
style={{ display: "none" }}
disabled={busy || disabled}
onChange={(e) => {
@ -188,6 +188,7 @@ export function CreateBadgePanel({
defaultBackgroundImageLayout,
);
const [backgroundPreviewUrl, setBackgroundPreviewUrl] = useState("");
const [backgroundIsPdf, setBackgroundIsPdf] = useState(false);
const [enabledFields, setEnabledFields] = useState<string[]>(["name", "title", "org", "qr"]);
const [layoutElements, setLayoutElements] = useState<BadgeLayoutElement[]>(() =>
defaultLayoutForFields(["name", "title", "org", "qr"], "#0B4DD1", "color"),
@ -197,7 +198,9 @@ export function CreateBadgePanel({
const [imageUploadBusy, setImageUploadBusy] = useState(false);
const [selectedElementId, setSelectedElementId] = useState<string | null>(null);
const [badgeTypeLabel, setBadgeTypeLabel] = useState("Attendee");
const [outputFormat, setOutputFormat] = useState<"pdf" | "png" | "jpeg">("pdf");
const [outputFormat, setOutputFormat] = useState<"pdf" | "pdf_per_person" | "png" | "jpeg">("pdf");
const [printDpi, setPrintDpi] = useState(300);
const [colorSpace, setColorSpace] = useState<"rgb" | "cmyk">("rgb");
const [submitting, setSubmitting] = useState(false);
const [submitMsg, setSubmitMsg] = useState<string | null>(null);
const [err, setErr] = useState("");
@ -282,11 +285,14 @@ export function CreateBadgePanel({
setBackgroundImageUrl(s.backgroundImageUrl);
setBackgroundImageLayout(s.backgroundImageLayout);
setBackgroundPreviewUrl("");
setBackgroundIsPdf(/\.pdf($|\?)/i.test(s.backgroundImageUrl));
setEnabledFields(s.enabledFields);
setLayoutElements(s.layoutElements);
setCustomTexts(s.customTexts);
setCustomImages(s.customImages);
setOutputFormat(s.outputFormat);
setPrintDpi(s.printDpi ?? 300);
setColorSpace(s.colorSpace === "cmyk" ? "cmyk" : "rgb");
setBadgeTypeLabel(s.badgeTypeLabel);
setSelectedElementId(null);
setHydrating(false);
@ -412,12 +418,17 @@ export function CreateBadgePanel({
};
const onHeaderFile = async (file: File | null | undefined) => {
if (!file || !file.type.startsWith("image/")) {
setErr("Choose an image file.");
const isPdf =
Boolean(file) &&
(file!.type === "application/pdf" || /\.pdf$/i.test(file!.name));
const isImage = Boolean(file) && file!.type.startsWith("image/");
if (!file || (!isImage && !isPdf)) {
setErr("Choose an image (SVG/PNG/JPEG) or PDF template.");
return;
}
const local = URL.createObjectURL(file);
setBackgroundPreviewUrl(local);
setBackgroundIsPdf(isPdf);
setHeaderBusy(true);
setErr("");
const hadBgImage = Boolean(backgroundImageUrl.trim());
@ -425,6 +436,7 @@ export function CreateBadgePanel({
const r = await uploadMediaFile(file, { eventId: badgeMediaEventId, module: "badges/backgrounds" });
if (r.url) {
setBackgroundImageUrl(r.url);
setBackgroundIsPdf(isPdf || /\.pdf($|\?)/i.test(r.url));
if (!hadBgImage) setBackgroundImageLayout(defaultBackgroundImageLayout());
setSelectedElementId(BADGE_BACKGROUND_IMAGE_ID);
}
@ -524,6 +536,8 @@ export function CreateBadgePanel({
customTexts: customTexts.map(({ id, text }) => ({ id, text })),
customImages: customImages.map(({ id, imageUrl }) => ({ id, imageUrl })),
outputFormat,
printDpi,
colorSpace,
badgeTypeLabel: badgeTypeLabel.trim() || "Attendee",
};
@ -571,6 +585,7 @@ export function CreateBadgePanel({
badgeAspectRatio={aspectRatio}
backgroundColor={backgroundColor}
backgroundImageUrl={displayBgUrl}
backgroundIsPdf={backgroundIsPdf || /\.pdf($|\?)/i.test(displayBgUrl)}
backgroundImageLayout={backgroundImageLayout}
layoutElements={layoutElements}
customImages={customImages}
@ -756,15 +771,18 @@ export function CreateBadgePanel({
<HelperText text="Always applied behind badge fields. Shows through transparent areas of the background image." />
</Field>
<Field>
<Label text="Background image (optional)" />
<Label text="Background image / PDF template (optional)" />
<input style={IS} value={backgroundImageUrl} onChange={(e) => {
const v = e.target.value;
setBackgroundImageUrl(v);
setBackgroundIsPdf(/\.pdf($|\?)/i.test(v));
if (!v.trim()) {
setBackgroundPreviewUrl("");
setBackgroundIsPdf(false);
if (selectedElementId === BADGE_BACKGROUND_IMAGE_ID) setSelectedElementId(null);
}
}} placeholder="https://… or upload" onFocus={focusIn} onBlur={focusOut} />
}} placeholder="https://… or upload image / PDF" onFocus={focusIn} onBlur={focusOut} />
<HelperText text="PDF templates keep designer artwork and stamp name/QR on top. SVG/PNG/JPEG use the standard image path." />
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: 8 }}>
<BackgroundUploadButton busy={headerBusy} onFile={(f) => void onHeaderFile(f)} />
{backgroundImageUrl.trim() ? (
@ -784,6 +802,7 @@ export function CreateBadgePanel({
onClick={() => {
setBackgroundImageUrl("");
setBackgroundPreviewUrl("");
setBackgroundIsPdf(false);
if (selectedElementId === BADGE_BACKGROUND_IMAGE_ID) setSelectedElementId(null);
}}
style={{ ...SS, padding: "8px 12px", fontSize: "var(--text-sm)" }}
@ -875,17 +894,95 @@ export function CreateBadgePanel({
>
<Field>
<Label text="Format" required />
<RadioGroup
name="outFmt"
value={outputFormat}
onChange={(v) => setOutputFormat(v as "pdf" | "png" | "jpeg")}
options={[
{ label: "PDF (multi-page, one badge per page)", value: "pdf" },
{ label: "PNG (one file per person)", value: "png" },
{ label: "JPEG (one file per person)", value: "jpeg" },
]}
/>
<div style={{ display: "flex", flexDirection: "column", gap: 8, paddingTop: 2 }}>
{(
[
{ label: "PDF (multi-page, one badge per page)", value: "pdf" },
{ label: "PDF (one file per person)", value: "pdf_per_person" },
{ label: "PNG (one file per person)", value: "png" },
{ label: "JPEG (one file per person)", value: "jpeg" },
] as const
).map((o) => (
<label
key={o.value}
style={{
display: "flex",
alignItems: "center",
gap: 8,
cursor: "pointer",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
color: "var(--card-foreground)",
}}
>
<input
type="radio"
name="outFmt"
value={o.value}
checked={outputFormat === o.value}
onChange={() => setOutputFormat(o.value)}
style={{ accentColor: "var(--primary)", width: 16, height: 16, cursor: "pointer" }}
/>
{o.label}
</label>
))}
</div>
</Field>
<Field>
<Label text="Print DPI" />
<SelectWrap>
<select
style={SS}
value={String(printDpi)}
onChange={(e) => setPrintDpi(Number(e.target.value) || 300)}
onFocus={focusIn}
onBlur={focusOut}
>
<option value="150">150 DPI (faster, draft)</option>
<option value="200">200 DPI</option>
<option value="300">300 DPI (default / print)</option>
<option value="600">600 DPI (high quality, larger files)</option>
</select>
</SelectWrap>
<HelperText text="Used for PNG/JPEG badge size and for rasterizing PNG/SVG backgrounds and QR/photos in PDF. Higher DPI = sharper and slower." />
</Field>
{(outputFormat === "pdf" || outputFormat === "pdf_per_person") && (
<Field>
<Label text="PDF color space" />
<div style={{ display: "flex", flexDirection: "column", gap: 8, paddingTop: 2 }}>
{(
[
{ label: "RGB (screen / default)", value: "rgb" as const },
{ label: "CMYK (print / press)", value: "cmyk" as const },
]
).map((o) => (
<label
key={o.value}
style={{
display: "flex",
alignItems: "center",
gap: 8,
cursor: "pointer",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
color: "var(--card-foreground)",
}}
>
<input
type="radio"
name="colorSpace"
value={o.value}
checked={colorSpace === o.value}
onChange={() => setColorSpace(o.value)}
style={{ accentColor: "var(--primary)", width: 16, height: 16, cursor: "pointer" }}
/>
{o.label}
</label>
))}
</div>
<HelperText text="CMYK draws text and fills in DeviceCMYK (black text as K100). Photos/QR stay RGB images. Ghostscript is not required." />
</Field>
)}
<HelperText text="Run npm run badge-batch-worker on the server so jobs complete. AWS S3/CloudFront must be configured." />
</AccordionSection>
</div>

View File

@ -1,4 +1,4 @@
import React, { useState, useRef, useCallback, useEffect } from "react";
import React, { useState, useRef, useCallback, useEffect, useMemo } from "react";
import { X, ChevronDown, ChevronUp, Plus, Trash2, Check, Upload, Search, UserPlus } from "lucide-react";
import { eventsApi, sessionsApi, tracksApi, speakersApi } from "../../../services/api";
import type { Speaker } from "../../../services/api";
@ -10,6 +10,14 @@ import {
type CustomFieldForPayload,
} from "./createEventPayload";
import { FlatpickrDatetimeInput } from "../ui/FlatpickrDatetimeInput";
import {
DEFAULT_KEY_PERSON_ROLE,
KEY_PERSON_ROLES,
normalizeKeyPersonRole,
type KeyPersonRole,
} from "../../constants/keyPersonRoles";
type KeyPersonRow = { speakerId: string; role: KeyPersonRole };
const IS: React.CSSProperties = {
width: "100%",
@ -615,7 +623,8 @@ export function CreateEventSessionPanel({
const [speakerCatalog, setSpeakerCatalog] = useState<Speaker[]>([]);
const [speakersLoading, setSpeakersLoading] = useState(true);
const [selectedSpeakerIds, setSelectedSpeakerIds] = useState<string[]>([]);
const [keyPersons, setKeyPersons] = useState<KeyPersonRow[]>([]);
const selectedSpeakerIds = useMemo(() => keyPersons.map((k) => k.speakerId), [keyPersons]);
const [speakerSearch, setSpeakerSearch] = useState("");
const [showNewSpeaker, setShowNewSpeaker] = useState(false);
const [nsFirst, setNsFirst] = useState("");
@ -623,6 +632,7 @@ export function CreateEventSessionPanel({
const [nsEmail, setNsEmail] = useState("");
const [nsPhone, setNsPhone] = useState("");
const [nsTitle, setNsTitle] = useState("");
const [nsRole, setNsRole] = useState<KeyPersonRole>(DEFAULT_KEY_PERSON_ROLE);
const [nsErr, setNsErr] = useState<SectionErr>({});
const [nsSaving, setNsSaving] = useState(false);
@ -649,12 +659,18 @@ export function CreateEventSessionPanel({
[speakerCatalog],
);
const addSpeakerId = (id: string) => {
const addSpeakerId = (id: string, role: KeyPersonRole = DEFAULT_KEY_PERSON_ROLE) => {
if (!id || selectedSpeakerIds.includes(id)) return;
setSelectedSpeakerIds(p => [...p, id]);
setKeyPersons((p) => [...p, { speakerId: id, role: normalizeKeyPersonRole(role) }]);
};
const removeSpeakerId = (id: string) => setSelectedSpeakerIds(p => p.filter(x => x !== id));
const removeSpeakerId = (id: string) => setKeyPersons((p) => p.filter((x) => x.speakerId !== id));
const setKeyPersonRole = (id: string, role: KeyPersonRole) => {
setKeyPersons((p) =>
p.map((row) => (row.speakerId === id ? { ...row, role: normalizeKeyPersonRole(role) } : row)),
);
};
const createSpeakerAndAttach = async () => {
const e: SectionErr = {};
@ -680,12 +696,13 @@ export function CreateEventSessionPanel({
}
const created = res.data as Speaker;
setSpeakerCatalog(prev => [created, ...prev.filter(x => x._id !== created._id)]);
addSpeakerId(created._id);
addSpeakerId(created._id, nsRole);
setNsFirst("");
setNsLast("");
setNsEmail("");
setNsPhone("");
setNsTitle("");
setNsRole(DEFAULT_KEY_PERSON_ROLE);
setNsErr({});
setShowNewSpeaker(false);
};
@ -912,7 +929,15 @@ export function CreateEventSessionPanel({
track: trackId,
location: sessionLocation,
status: "scheduled",
...(selectedSpeakerIds.length > 0 ? { speakers: selectedSpeakerIds } : {}),
...(selectedSpeakerIds.length > 0
? {
speakers: selectedSpeakerIds,
keyPersons: keyPersons.map((k) => ({
speaker: k.speakerId,
role: normalizeKeyPersonRole(k.role),
})),
}
: {}),
});
setSubmitting(false);
if (sessRes.error != null || !sessRes.data) {
@ -1209,7 +1234,7 @@ export function CreateEventSessionPanel({
completed={completed.has(3)}
onToggle={() => toggle(3)}
onNext={() => tryAdvance(3)}
nextLabel="Continue to Speakers & notes"
nextLabel="Continue to Key Persons & notes"
nextDisabled={submitting}
>
<Row>
@ -1278,8 +1303,8 @@ export function CreateEventSessionPanel({
<AccordionSection
n={4}
title="Speakers & notes"
subtitle="Faculty for this session; optional notes stored on the session"
title="Key Persons & notes"
subtitle="Faculty for this session with a role each; optional notes stored on the session"
open={openSection === 4}
completed={completed.has(4)}
onToggle={() => toggle(4)}
@ -1288,34 +1313,56 @@ export function CreateEventSessionPanel({
nextDisabled={submitting}
>
<Field>
<Label text="Speakers" />
<Label text="Key Persons" />
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: "0 0 8px", fontFamily: "var(--font-noto-sans)" }}>
Select from your organization&apos;s directory or add someone new.
Select from your organization&apos;s directory or add someone new. Assign a role for each person (defaults to Speaker).
</p>
{selectedSpeakerIds.length > 0 && (
<div style={{ display: "flex", flexWrap: "wrap", gap: "6px", marginBottom: "10px" }}>
{selectedSpeakerIds.map(id => (
<span
key={id}
{keyPersons.length > 0 && (
<div style={{ display: "flex", flexDirection: "column", gap: "8px", marginBottom: "10px" }}>
{keyPersons.map((row) => (
<div
key={row.speakerId}
style={{
display: "inline-flex",
display: "grid",
gridTemplateColumns: "minmax(0, 1fr) minmax(140px, 200px) auto",
gap: "8px",
alignItems: "center",
gap: "6px",
padding: "4px 8px 4px 10px",
padding: "8px 10px",
borderRadius: "var(--radius-button)",
background: "var(--secondary)",
border: "1px solid var(--border)",
fontSize: "var(--text-xs)",
fontFamily: "var(--font-noto-sans)",
}}
>
{speakerLabel(id)}
<span
style={{
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
fontWeight: "var(--font-weight-medium)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{speakerLabel(row.speakerId)}
</span>
<select
value={row.role}
onChange={(e) => setKeyPersonRole(row.speakerId, e.target.value as KeyPersonRole)}
aria-label={`Role for ${speakerLabel(row.speakerId)}`}
style={{ ...IS, padding: "6px 8px", fontSize: "var(--text-xs)" }}
>
{KEY_PERSON_ROLES.map((role) => (
<option key={role} value={role}>
{role}
</option>
))}
</select>
<button
type="button"
onClick={() => removeSpeakerId(id)}
onClick={() => removeSpeakerId(row.speakerId)}
style={{
display: "flex",
padding: 0,
padding: "4px",
border: "none",
background: "none",
cursor: "pointer",
@ -1323,9 +1370,9 @@ export function CreateEventSessionPanel({
}}
aria-label="Remove"
>
<X size={12} />
<X size={14} />
</button>
</span>
</div>
))}
</div>
)}
@ -1440,7 +1487,7 @@ export function CreateEventSessionPanel({
color: "var(--primary)",
}}
>
<UserPlus size={14} /> {showNewSpeaker ? "Hide new speaker form" : "Add new speaker"}
<UserPlus size={14} /> {showNewSpeaker ? "Hide new person form" : "Add new key person"}
</button>
{showNewSpeaker && (
<div
@ -1511,7 +1558,7 @@ export function CreateEventSessionPanel({
<input value={nsPhone} onChange={e => setNsPhone(e.target.value)} style={IS} />
</div>
<div>
<label style={{ fontSize: "var(--text-xs)", display: "block", marginBottom: "4px", fontFamily: "var(--font-noto-sans)" }}>Title / role</label>
<label style={{ fontSize: "var(--text-xs)", display: "block", marginBottom: "4px", fontFamily: "var(--font-noto-sans)" }}>Title</label>
<input
value={nsTitle}
onChange={e => setNsTitle(e.target.value)}
@ -1520,6 +1567,20 @@ export function CreateEventSessionPanel({
/>
</div>
</div>
<div>
<label style={{ fontSize: "var(--text-xs)", display: "block", marginBottom: "4px", fontFamily: "var(--font-noto-sans)" }}>Session role</label>
<select
value={nsRole}
onChange={(e) => setNsRole(e.target.value as KeyPersonRole)}
style={IS}
>
{KEY_PERSON_ROLES.map((role) => (
<option key={role} value={role}>
{role}
</option>
))}
</select>
</div>
<button
type="button"
disabled={nsSaving}

View File

@ -9,6 +9,7 @@ import { useAuth } from "../../contexts/AuthContext";
import { usePermission } from "../../hooks/usePermission";
import { eventsApi, formsApi, participantListsApi, designTemplatesApi, portfolioListOrganizationId } from "../../../services/api";
import type { Form, FormField, ParticipantListRecord, DesignTemplate } from "../../../services/api";
import { eventTicketsApi, type EventTicketRecord } from "../../../services/api/eventTickets";
import { assignUniqueFieldNames } from "../../utils/formFieldNames";
import {
EVENT_HEADER_TOGGLE_LABELS,
@ -489,7 +490,7 @@ interface FForm {
anonymous: "Yes" | "No";
publish: "Draft" | "Published";
paymentEnabled: "Yes" | "No";
amount: string;
ticketIds: string[];
confirmationEmail: "Yes" | "No";
confirmationEmailTemplateId: string;
onSubmitTags: string;
@ -497,6 +498,7 @@ interface FForm {
allowDuplicateEmail: "Yes" | "No";
allowDuplicatePhone: "Yes" | "No";
headerShowLogo: "Yes" | "No";
headerShowBanner: "Yes" | "No";
headerShowTitle: "Yes" | "No";
headerShowDateRange: "Yes" | "No";
headerShowEventType: "Yes" | "No";
@ -549,7 +551,7 @@ const initialForm = (qTypes: string[], isFeedback: boolean): FForm => ({
anonymous: isFeedback ? "Yes" : "No",
publish: "Draft",
paymentEnabled: "No",
amount: "",
ticketIds: [],
confirmationEmail: "No",
confirmationEmailTemplateId: "",
onSubmitTags: "",
@ -557,6 +559,7 @@ const initialForm = (qTypes: string[], isFeedback: boolean): FForm => ({
allowDuplicateEmail: "Yes",
allowDuplicatePhone: "Yes",
headerShowLogo: "Yes",
headerShowBanner: "No",
headerShowTitle: "Yes",
headerShowDateRange: "Yes",
headerShowEventType: "Yes",
@ -827,6 +830,8 @@ export function CreateFormPanel({
});
const [fixedLists, setFixedLists] = useState<ParticipantListRecord[]>([]);
const [emailerTemplates, setEmailerTemplates] = useState<DesignTemplate[]>([]);
const [eventTickets, setEventTickets] = useState<EventTicketRecord[]>([]);
const [ticketsLoading, setTicketsLoading] = useState(false);
const [sectionErrors, setSectionErrors] = useState<Record<number, string>>({});
const [submitting, setSubmitting] = useState(false);
const [loading, setLoading] = useState(!!formId);
@ -895,6 +900,35 @@ export function CreateFormPanel({
};
}, [isReg, listOrgId]);
useEffect(() => {
if (!isReg || !selectedEventId.trim()) {
setEventTickets([]);
return;
}
let cancelled = false;
setTicketsLoading(true);
void eventTicketsApi
.list(selectedEventId)
.then((tickets) => {
if (cancelled) return;
setEventTickets(tickets);
const valid = new Set(tickets.map((t) => t._id));
setForm((prev) => ({
...prev,
ticketIds: prev.ticketIds.filter((id) => valid.has(id)),
}));
})
.catch(() => {
if (!cancelled) setEventTickets([]);
})
.finally(() => {
if (!cancelled) setTicketsLoading(false);
});
return () => {
cancelled = true;
};
}, [isReg, selectedEventId]);
useEffect(() => {
if (!formId) return;
let cancelled = false;
@ -930,7 +964,7 @@ export function CreateFormPanel({
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) : "",
ticketIds: Array.isArray(s.ticketIds) ? s.ticketIds.map((id) => String(id)) : [],
confirmationEmail: s.sendConfirmationEmail === true ? "Yes" : "No",
confirmationEmailTemplateId: s.confirmationEmailTemplateId
? String(s.confirmationEmailTemplateId)
@ -940,6 +974,7 @@ export function CreateFormPanel({
allowDuplicateEmail: s.allowDuplicateEmail === false ? "No" : "Yes",
allowDuplicatePhone: s.allowDuplicatePhone === false ? "No" : "Yes",
headerShowLogo: header.showLogo,
headerShowBanner: header.showBanner,
headerShowTitle: header.showTitle,
headerShowDateRange: header.showDateRange,
headerShowEventType: header.showEventType,
@ -997,10 +1032,12 @@ export function CreateFormPanel({
const choicesOk = f.questions.every(q => isSectionFieldType(q.type) || !q.label.trim() || fieldChoicesAreValid(q));
return anyLabel && choicesOk;
}
if (n === 3 && isReg) return true;
if (n === 4 && isReg) {
if (n === 3 && isReg) {
if (f.confirmationEmail === "Yes" && !f.confirmationEmailTemplateId.trim()) return false;
if (f.paymentEnabled === "Yes" && !f.amount.trim()) return false;
return true;
}
if (n === 4 && isReg) {
if (f.paymentEnabled === "Yes" && (!Array.isArray(f.ticketIds) || f.ticketIds.length === 0)) return false;
return true;
}
if (n === (isReg ? 5 : 3)) return true;
@ -1034,7 +1071,8 @@ export function CreateFormPanel({
collectEmail: form.collectEmail === "Yes",
allowAnonymous: form.anonymous === "Yes",
paymentEnabled: form.paymentEnabled === "Yes",
paymentAmount: form.paymentEnabled === "Yes" && form.amount.trim() ? form.amount.trim() : "",
paymentAmount: "",
ticketIds: form.paymentEnabled === "Yes" ? form.ticketIds : [],
sendConfirmationEmail: form.confirmationEmail === "Yes",
confirmationEmailTemplateId:
form.confirmationEmail === "Yes" && form.confirmationEmailTemplateId.trim()
@ -1045,6 +1083,7 @@ export function CreateFormPanel({
captchaFailedMessage: form.captchaFailedMessage.trim(),
eventHeader: eventHeaderToApi({
showLogo: form.headerShowLogo,
showBanner: form.headerShowBanner,
showTitle: form.headerShowTitle,
showDateRange: form.headerShowDateRange,
showEventType: form.headerShowEventType,
@ -1248,12 +1287,12 @@ export function CreateFormPanel({
<AccordionSection
n={3}
title="Participant actions"
subtitle="Tags and lists applied when someone submits"
subtitle="Tags, lists, and confirmation email applied when someone submits"
open={acc.open === 3}
completed={acc.done.has(3)}
onToggle={() => acc.toggle(3)}
onSave={() => tryAdvance(3)}
nextLabel="Payment"
nextLabel="Tickets"
sectionError={sectionErrors[3]}
>
<Field>
@ -1294,33 +1333,6 @@ export function CreateFormPanel({
/>
<HelperText text="Compares the last 10 digits only (ignores +, spaces, country code)." />
</Field>
</Grid>
</AccordionSection>
)}
{isReg && (
<AccordionSection
n={4}
title="Payment"
subtitle="Configure payment settings for paid registrations"
open={acc.open === 4}
completed={acc.done.has(4)}
onToggle={() => acc.toggle(4)}
onSave={() => tryAdvance(4)}
nextLabel="Publish Settings"
sectionError={sectionErrors[4]}
>
<Grid cols={2}>
<Field>
<Label text="Enable Payment" required />
<RadioGroup name="regPayment" value={form.paymentEnabled} onChange={v => set("paymentEnabled", v as FForm["paymentEnabled"])} options={[{ label: "Yes", value: "Yes" }, { label: "No", value: "No" }]} />
</Field>
{form.paymentEnabled === "Yes" && (
<Field>
<Label text="Registration Fee (₹)" required />
<input type="number" style={IS} placeholder="0.00" value={form.amount} onChange={e => set("amount", e.target.value)} onFocus={focusIn} onBlur={focusOut} />
</Field>
)}
<Field>
<Label text="Confirmation Email" required />
<RadioGroup
@ -1371,6 +1383,129 @@ export function CreateFormPanel({
</AccordionSection>
)}
{isReg && (
<AccordionSection
n={4}
title="Tickets"
subtitle="Attach event tickets for paid registration (2-step public form)"
open={acc.open === 4}
completed={acc.done.has(4)}
onToggle={() => acc.toggle(4)}
onSave={() => tryAdvance(4)}
nextLabel="Publish Settings"
sectionError={sectionErrors[4]}
>
<Field>
<Label text="Enable Ticket" required />
<RadioGroup
name="regPayment"
value={form.paymentEnabled}
onChange={(v) => {
const next = v as FForm["paymentEnabled"];
setForm((prev) => ({
...prev,
paymentEnabled: next,
ticketIds: next === "Yes" ? prev.ticketIds : [],
}));
}}
options={[{ label: "Yes", value: "Yes" }, { label: "No", value: "No" }]}
/>
<HelperText text="When Yes, the public registration form is a 2-step process: details, then ticket selection and payment." />
</Field>
{form.paymentEnabled === "Yes" && (
<Field>
<Label text="Tickets for this form" required />
{!selectedEventId.trim() ? (
<HelperText text="Select an event in Form Details first." />
) : ticketsLoading ? (
<HelperText text="Loading tickets…" />
) : eventTickets.length === 0 ? (
<HelperText text="No tickets found for this event. Create tickets under the event’s Ticketing page, then return here." />
) : (
<>
<div style={{ display: "flex", gap: 12, marginBottom: 8, flexWrap: "wrap" }}>
<button
type="button"
onClick={() => set("ticketIds", eventTickets.map((t) => t._id))}
style={{
padding: "4px 10px",
borderRadius: "var(--radius-button)",
border: "1px solid var(--border)",
background: "var(--card)",
cursor: "pointer",
fontSize: "var(--text-sm)",
}}
>
Select all
</button>
<button
type="button"
onClick={() => set("ticketIds", [])}
style={{
padding: "4px 10px",
borderRadius: "var(--radius-button)",
border: "1px solid var(--border)",
background: "var(--card)",
cursor: "pointer",
fontSize: "var(--text-sm)",
}}
>
Clear
</button>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{eventTickets.map((t) => {
const checked = form.ticketIds.includes(t._id);
const price = (Number(t.basePriceMinor) || 0) / 100;
return (
<label
key={t._id}
style={{
display: "flex",
alignItems: "flex-start",
gap: 10,
padding: "10px 12px",
borderRadius: 8,
border: `1px solid ${checked ? "var(--primary)" : "var(--border)"}`,
background: checked ? "color-mix(in srgb, var(--primary) 8%, transparent)" : "var(--card)",
cursor: "pointer",
}}
>
<input
type="checkbox"
checked={checked}
onChange={() => {
setForm((prev) => ({
...prev,
ticketIds: checked
? prev.ticketIds.filter((id) => id !== t._id)
: [...prev.ticketIds, t._id],
}));
}}
style={{ marginTop: 3 }}
/>
<span style={{ flex: 1, minWidth: 0 }}>
<span style={{ display: "block", fontWeight: 600 }}>
{t.name}
{!t.isActive ? " (inactive)" : ""}
</span>
<span style={{ display: "block", fontSize: "0.8125rem", color: "var(--muted)" }}>
{t.currency || "INR"} {price.toFixed(2)}
{t.description?.trim() ? ` · ${t.description.trim()}` : ""}
</span>
</span>
</label>
);
})}
</div>
<HelperText text="Registrants can choose from the tickets you select here." />
</>
)}
</Field>
)}
</AccordionSection>
)}
<AccordionSection
n={isReg ? 5 : 3}
title="Publish Settings"
@ -1517,11 +1652,12 @@ export function CreateFormPanel({
<Field>
<Label text="Event header on public form" />
<HelperText text="Choose which linked event details appear above the form on the public page." />
<HelperText text="Choose which linked event details appear above the form on the public page. Logo and banner can both be shown when the event has both assets." />
<Grid cols={2}>
{(Object.keys(EVENT_HEADER_TOGGLE_LABELS) as EventHeaderToggleKey[]).map((key) => {
const formKey = {
showLogo: "headerShowLogo",
showBanner: "headerShowBanner",
showTitle: "headerShowTitle",
showDateRange: "headerShowDateRange",
showEventType: "headerShowEventType",

View File

@ -0,0 +1,413 @@
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Upload } from "lucide-react";
import { PanelShell, Label, Field, HelperText, RadioGroup, IS, SS, focusIn, focusOut } from "./PanelShell";
import { useAuth } from "../../contexts/AuthContext";
import { participantsApi, participantListsApi, portfolioListOrganizationId } from "../../../services/api";
import type { ParticipantListRecord } from "../../../services/api";
export type EventImportMode =
| "NEW_INSERT"
| "UPSERT"
| "UPDATE"
| "REPLACE"
| "TAGS_ONLY"
| "STATUS_UPDATE"
| "REMOVE"
| "SYNC";
export type EventImportResult = {
created: number;
updated: number;
removed: number;
skipped: number;
skippedDuplicate: number;
failed: number;
errors: { line: number; message: string }[];
listId?: string;
listLinked?: number;
};
const MODE_OPTIONS: { value: EventImportMode; label: string; help: string }[] = [
{ value: "NEW_INSERT", label: "Insert new only", help: "Add new participants; skip emails already on this event." },
{ value: "UPSERT", label: "Update & insert", help: "Update matching rows and insert new ones." },
{ value: "UPDATE", label: "Update matching only", help: "Update matching rows; ignore emails not on this event." },
{ value: "REPLACE", label: "Replace matching fields", help: "Overwrite matching profiles; empty cells clear optional fields; tags replaced." },
{ value: "TAGS_ONLY", label: "Tags only", help: "Merge tags onto matching memberships; no profile create/update." },
{ value: "STATUS_UPDATE", label: "Update status only", help: "Set membership status from the CSV status column for matching rows." },
{ value: "REMOVE", label: "Remove from event", help: "Remove matching participants from this event." },
{ value: "SYNC", label: "Sync roster", help: "Upsert CSV rows, then remove anyone on the event whose email is missing from the file." },
];
type ListAction = "none" | "create" | "add_existing";
type MembershipStatus = "pending" | "confirmed" | "rejected";
const STATUS_OPTIONS: { value: MembershipStatus; label: string }[] = [
{ value: "pending", label: "Pending" },
{ value: "confirmed", label: "Confirmed" },
{ value: "rejected", label: "Rejected" },
];
function parseBulkTags(raw: string): string[] {
return raw
.split(/[;,]/)
.map((t) => t.trim())
.filter(Boolean);
}
export function ImportEventParticipantsPanel({
eventId,
onClose,
onSuccess,
}: {
eventId: string;
onClose: () => void;
onSuccess: (result: EventImportResult) => void;
}) {
const { user } = useAuth();
const listOrgId = portfolioListOrganizationId(user);
const [csvText, setCsvText] = useState("");
const [fileLabel, setFileLabel] = useState<string | null>(null);
const [mode, setMode] = useState<EventImportMode>("NEW_INSERT");
const [confirmSync, setConfirmSync] = useState(false);
const [membershipStatus, setMembershipStatus] = useState<MembershipStatus>("pending");
const [bulkTags, setBulkTags] = useState("");
const [listAction, setListAction] = useState<ListAction>("none");
const [listName, setListName] = useState("");
const [listDescription, setListDescription] = useState("");
const [existingListId, setExistingListId] = useState("");
const [fixedLists, setFixedLists] = useState<ParticipantListRecord[]>([]);
const [saving, setSaving] = useState(false);
const [err, setErr] = useState<string | null>(null);
const showTags = mode !== "STATUS_UPDATE" && mode !== "REMOVE";
const showLists = mode !== "REMOVE";
const showStatus =
mode === "NEW_INSERT" ||
mode === "UPSERT" ||
mode === "UPDATE" ||
mode === "REPLACE" ||
mode === "SYNC";
const modeHelp = MODE_OPTIONS.find((m) => m.value === mode)?.help ?? "";
useEffect(() => {
if (!listOrgId || !showLists) return;
participantListsApi.list({ organization: listOrgId }).then((r) => {
const lists = (r.data?.lists || []).filter((l) => l.type === "fixed");
setFixedLists(lists);
});
}, [listOrgId, showLists]);
useEffect(() => {
if (mode === "REMOVE") setListAction("none");
if (mode !== "SYNC") setConfirmSync(false);
}, [mode]);
const onFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setErr(null);
file
.text()
.then((text) => {
setCsvText(text);
setFileLabel(file.name);
})
.catch(() => setErr("Could not read file."));
e.target.value = "";
};
const ctaDisabled = useMemo(() => {
if (saving || !csvText.trim()) return true;
if (mode === "SYNC" && !confirmSync) return true;
if (showLists && listAction === "create" && !listName.trim()) return true;
if (showLists && listAction === "add_existing" && !existingListId) return true;
return false;
}, [saving, csvText, mode, confirmSync, showLists, listAction, listName, existingListId]);
const submit = useCallback(async () => {
setErr(null);
if (!csvText.trim()) {
setErr("Choose a CSV file first.");
return;
}
if (mode === "SYNC" && !confirmSync) {
setErr("Confirm that people missing from the CSV will be removed from the event.");
return;
}
if (showLists && listAction === "create" && !listName.trim()) {
setErr("Enter a list name.");
return;
}
if (showLists && listAction === "add_existing" && !existingListId) {
setErr("Select an existing list.");
return;
}
setSaving(true);
const res = await participantsApi.importCsv(eventId, {
csv: csvText,
mode,
confirmSync: mode === "SYNC" ? true : undefined,
membershipStatus: showStatus ? membershipStatus : undefined,
tags: showTags ? parseBulkTags(bulkTags) : undefined,
listAction: showLists ? listAction : "none",
listName: listAction === "create" ? listName.trim() : undefined,
listDescription: listAction === "create" ? listDescription.trim() : undefined,
existingListId: listAction === "add_existing" ? existingListId : undefined,
});
setSaving(false);
if (res.error || !res.data) {
setErr(res.error || "Import failed");
return;
}
onSuccess(res.data);
onClose();
}, [
csvText,
mode,
confirmSync,
showStatus,
membershipStatus,
showTags,
showLists,
bulkTags,
listAction,
listName,
listDescription,
existingListId,
eventId,
onSuccess,
onClose,
]);
return (
<PanelShell
onClose={onClose}
title="Import participants"
breadcrumb="Participants · Import"
description="Upload a CSV for this event. Choose how matching emails are handled, then optionally tag rows or save them to a list."
completedCount={csvText.trim() ? 1 : 0}
totalSections={1}
ctaLabel={saving ? "Importing…" : "Import"}
ctaDisabled={ctaDisabled}
onCtaClick={submit}
>
<div style={{ padding: "0 20px 24px", display: "flex", flexDirection: "column", gap: "16px" }}>
{err && (
<p style={{ fontSize: "var(--text-sm)", color: "var(--destructive)", margin: 0, fontFamily: "var(--font-noto-sans)" }}>{err}</p>
)}
<Field>
<Label text="CSV format" />
<div
style={{
padding: "12px 14px",
borderRadius: "var(--radius-button)",
background: "var(--secondary)",
border: "1px solid var(--border)",
fontSize: "var(--text-xs)",
fontFamily: "var(--font-noto-sans)",
color: "var(--card-foreground)",
lineHeight: 1.55,
}}
>
<p style={{ margin: "0 0 8px" }}>
Required: <code>email</code>
{mode === "NEW_INSERT" || mode === "UPSERT" || mode === "SYNC" ? (
<>
, <code>firstName</code>, <code>lastName</code> (for new rows)
</>
) : null}
{mode === "STATUS_UPDATE" ? (
<>
, <code>status</code> (<code>pending</code>, <code>confirmed</code>, <code>checked_in</code>,{" "}
<code>cancelled</code>, <code>rejected</code>)
</>
) : null}
</p>
<p style={{ margin: "0 0 8px", color: "var(--muted)" }}>
Optional: phone, organization, designation, tags, notes
{mode !== "STATUS_UPDATE" ? ". Headers like “First name” / “Last name” are accepted." : "."}
</p>
<p style={{ margin: 0, color: "var(--muted)", wordBreak: "break-all" }}>
Example:{" "}
<code style={{ fontSize: 11 }}>
{mode === "STATUS_UPDATE"
? "email,status"
: "email,firstName,lastName,phone,organization,designation,tags,notes"}
</code>
</p>
</div>
</Field>
<Field>
<Label text="Import behaviour" required />
<select
style={SS}
value={mode}
onChange={(e) => setMode(e.target.value as EventImportMode)}
onFocus={focusIn}
onBlur={focusOut}
>
{MODE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<HelperText text={modeHelp} />
</Field>
{mode === "SYNC" && (
<Field>
<label
style={{
display: "flex",
alignItems: "flex-start",
gap: "8px",
cursor: "pointer",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
color: "var(--card-foreground)",
lineHeight: 1.45,
}}
>
<input
type="checkbox"
checked={confirmSync}
onChange={(e) => setConfirmSync(e.target.checked)}
style={{ marginTop: 2, accentColor: "var(--primary)" }}
/>
<span>
I understand people on this event whose email is <strong>not</strong> in the CSV will be removed from
the event.
</span>
</label>
</Field>
)}
{showStatus && (
<Field>
<Label text="Status" required />
<RadioGroup
name="import-membership-status"
value={membershipStatus}
onChange={(v) => setMembershipStatus(v as MembershipStatus)}
options={STATUS_OPTIONS}
/>
<HelperText text="Applied to created and updated event memberships from this import." />
</Field>
)}
<Field>
<Label text="CSV file" required />
<label
style={{
display: "inline-flex",
alignItems: "center",
gap: "8px",
padding: "10px 14px",
borderRadius: "var(--radius-button)",
border: "1px dashed var(--primary)",
color: "var(--primary)",
cursor: "pointer",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
fontWeight: "var(--font-weight-medium)",
}}
>
<Upload size={16} />
{fileLabel || "Choose file…"}
<input type="file" accept=".csv,text/csv" style={{ display: "none" }} onChange={onFile} />
</label>
<HelperText text="Matching is by email for this event." />
</Field>
{showTags && (
<Field>
<Label text="Apply tags to imported rows" />
<input
style={IS}
value={bulkTags}
onChange={(e) => setBulkTags(e.target.value)}
onFocus={focusIn}
onBlur={focusOut}
placeholder="e.g. vip, day-1"
/>
<HelperText text="Comma or semicolon separated. Combined with any tags column in the CSV." />
</Field>
)}
{showLists && (
<>
<Field>
<Label text="Participant list" />
<RadioGroup
name="import-list-action"
value={listAction}
onChange={(v) => setListAction(v as ListAction)}
options={[
{ label: "None", value: "none" },
{ label: "Create new list", value: "create" },
{ label: "Add to existing", value: "add_existing" },
]}
/>
<HelperText text="Applies to participants created or updated by this import (not removals)." />
</Field>
{listAction === "create" && (
<>
<Field>
<Label text="List name" required />
<input
style={IS}
value={listName}
onChange={(e) => setListName(e.target.value)}
onFocus={focusIn}
onBlur={focusOut}
placeholder="e.g. January invitees"
/>
</Field>
<Field>
<Label text="Description" />
<textarea
style={{ ...IS, resize: "vertical", minHeight: "72px" }}
value={listDescription}
onChange={(e) => setListDescription(e.target.value)}
onFocus={focusIn}
onBlur={focusOut}
placeholder="Optional notes for admins"
/>
</Field>
</>
)}
{listAction === "add_existing" && (
<Field>
<Label text="Existing fixed list" required />
<select
style={SS}
value={existingListId}
onChange={(e) => setExistingListId(e.target.value)}
onFocus={focusIn}
onBlur={focusOut}
>
<option value="">Select a list…</option>
{fixedLists.map((l) => (
<option key={l._id} value={l._id}>
{l.name}
</option>
))}
</select>
{fixedLists.length === 0 && (
<HelperText text="No fixed lists found for this organization." />
)}
</Field>
)}
</>
)}
</div>
</PanelShell>
);
}

View File

@ -1,6 +1,10 @@
import React, { useEffect, useMemo, useState } from "react";
import { websiteSettingsApi } from "../../../services/api/websiteSettings";
import type { WebsiteThemeConfig, WebsiteThemeMode } from "../../../services/api/types";
import type {
WebsiteThemeConfig,
WebsiteThemeMode,
WebsiteThemePattern,
} from "../../../services/api/types";
import {
Field,
HelperText,
@ -15,44 +19,247 @@ import {
const THEME_OPTIONS: { value: WebsiteThemeMode; label: string }[] = [
{ value: "simple", label: "Simple" },
{ value: "gradient", label: "Gradient" },
{ value: "pattern", label: "Pattern" },
];
const SIMPLE_COLORS = [
{ value: "blue", label: "Blue (default)" },
{ value: "red", label: "Red" },
{ value: "green", label: "Green" },
{ value: "yellow", label: "Yellow" },
{ value: "purple", label: "Purple" },
{ value: "gray", label: "Gray" },
{ value: "yellow", label: "Yellow" },
{ value: "orange", label: "Orange" },
] as const;
type SimpleColor = (typeof SIMPLE_COLORS)[number]["value"];
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 PATTERN_OPTIONS: { value: WebsiteThemePattern; label: string }[] = [
{ value: "dots", label: "Dots" },
{ value: "grid", label: "Grid" },
{ value: "zigzag", label: "Zigzag" },
{ value: "diagonal", label: "Diagonal" },
{ value: "hexa", label: "Hexa" },
{ value: "plus", label: "Plus" },
];
const FONT_OPTIONS = ["Open Sans", "Montserrat", "Roboto", "Poppins", "Raleway"] as const;
/** Default palette for simple/pattern color presets (matches public site conferenceTheme). */
const PRIMARY_COLOR_DEFAULTS: Record<SimpleColor, string> = {
blue: "#112c59",
red: "#4c1429",
green: "#073428",
purple: "#2a235d",
gray: "#2a2e33",
yellow: "#4f4109",
orange: "#683606",
};
const BUTTON_COLOR_DEFAULTS: Record<SimpleColor, string> = {
blue: "#50a2ed",
red: "#8f3958",
green: "#46b295",
purple: "#6954c9",
gray: "#5f656d",
yellow: "#ac9943",
orange: "#c7782e",
};
const TEXT_HIGHLIGHT_COLOR_DEFAULTS: Record<SimpleColor, string> = {
blue: "#6ba8e3",
red: "#e27fa3",
green: "#7dc9b2",
purple: "#9f94ec",
gray: "#c2cad3",
yellow: "#d4c169",
orange: "#e5b587",
};
function paletteDefaultsForColor(color: string): Pick<
WebsiteThemeConfig,
"primaryColor" | "buttonColor" | "textHighlightColor"
> {
const key = (SIMPLE_COLORS.some((c) => c.value === color) ? color : "blue") as SimpleColor;
return {
primaryColor: PRIMARY_COLOR_DEFAULTS[key],
buttonColor: BUTTON_COLOR_DEFAULTS[key],
textHighlightColor: TEXT_HIGHLIGHT_COLOR_DEFAULTS[key],
};
}
const PALETTE_FIELDS: {
key: keyof Pick<
WebsiteThemeConfig,
| "primaryColor"
| "secondaryColor"
| "headerColor"
| "buttonColor"
| "textColor"
| "textHighlightColor"
>;
label: string;
help: string;
}[] = [
{ key: "primaryColor", label: "Primary color", help: "Main background / brand surface." },
{ key: "secondaryColor", label: "Secondary color", help: "Secondary surface accent." },
{ key: "headerColor", label: "Header color", help: "Top navigation background." },
{ key: "buttonColor", label: "Button color", help: "Primary CTA buttons." },
{ key: "textColor", label: "Text color", help: "Body / default text." },
{ key: "textHighlightColor", label: "Text highlight", help: "Highlighted labels and accents." },
];
const DEFAULT_THEME: WebsiteThemeConfig = {
theme: "simple",
color: "blue",
pattern: "",
font: "Open Sans",
...paletteDefaultsForColor("blue"),
secondaryColor: "",
headerColor: "",
textColor: "",
};
function trimColor(v?: string | null): string {
return v != null ? String(v).trim() : "";
}
/** Keep a valid #rrggbb for `<input type="color">`; empty → fallback swatch. */
function colorInputValue(hex: string, fallback = "#112c59"): string {
const s = trimColor(hex);
if (/^#[0-9a-fA-F]{6}$/.test(s)) return s;
if (/^#[0-9a-fA-F]{3}$/.test(s)) {
const r = s[1];
const g = s[2];
const b = s[3];
return `#${r}${r}${g}${g}${b}${b}`;
}
return fallback;
}
function normalizeTheme(raw?: WebsiteThemeConfig | null): WebsiteThemeConfig {
const mode: WebsiteThemeMode = raw?.theme === "gradient" ? "gradient" : "simple";
const modeRaw = raw?.theme != null ? String(raw.theme).trim().toLowerCase() : "simple";
const mode: WebsiteThemeMode =
modeRaw === "gradient" ? "gradient" : modeRaw === "pattern" ? "pattern" : "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 {
if (mode === "gradient") {
const ok = new Set(GRADIENT_COLORS.map((c) => c.value));
if (!ok.has(color as (typeof GRADIENT_COLORS)[number]["value"])) color = "green";
} else {
const ok = new Set(SIMPLE_COLORS.map((c) => c.value));
if (!ok.has(color as (typeof SIMPLE_COLORS)[number]["value"])) color = "blue";
}
return { theme: mode, color, font };
const patternRaw = raw?.pattern != null ? String(raw.pattern).trim().toLowerCase() : "";
const patternOk = new Set(PATTERN_OPTIONS.map((p) => p.value));
const pattern =
mode === "pattern"
? patternOk.has(patternRaw as WebsiteThemePattern)
? patternRaw
: "dots"
: patternRaw && patternOk.has(patternRaw as WebsiteThemePattern)
? patternRaw
: "";
const palette =
mode === "simple" || mode === "pattern" ? paletteDefaultsForColor(color) : null;
const primaryColor = trimColor(raw?.primaryColor) || palette?.primaryColor || "";
const buttonColor = trimColor(raw?.buttonColor) || palette?.buttonColor || "";
const textHighlightColor =
trimColor(raw?.textHighlightColor) || palette?.textHighlightColor || "";
return {
theme: mode,
color,
pattern,
font,
primaryColor,
secondaryColor: trimColor(raw?.secondaryColor),
headerColor: trimColor(raw?.headerColor),
buttonColor,
textColor: trimColor(raw?.textColor),
textHighlightColor,
};
}
function PaletteColorField({
label,
help,
value,
onChange,
swatchFallback = "#112c59",
placeholder = "optional",
}: {
label: string;
help: string;
value: string;
onChange: (next: string) => void;
swatchFallback?: string;
placeholder?: string;
}) {
return (
<Field>
<Label text={label} />
<div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
<input
type="color"
value={colorInputValue(value, swatchFallback)}
onChange={(e) => onChange(e.target.value)}
style={{
width: 40,
height: 36,
padding: 0,
border: "none",
background: "transparent",
cursor: "pointer",
flexShrink: 0,
}}
aria-label={label}
/>
<input
type="text"
value={value}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
style={{ ...SS, flex: 1 }}
onFocus={focusIn}
onBlur={focusOut}
/>
{value ? (
<button
type="button"
onClick={() => onChange("")}
style={{
border: "1px solid var(--border)",
background: "var(--card)",
borderRadius: "var(--radius-button)",
padding: "6px 10px",
fontSize: "var(--text-xs)",
fontFamily: "var(--font-noto-sans)",
color: "var(--muted)",
cursor: "pointer",
flexShrink: 0,
}}
>
Clear
</button>
) : null}
</div>
<HelperText text={help} />
</Field>
);
}
export function WebsiteThemeSettingsPanel({
@ -67,9 +274,7 @@ export function WebsiteThemeSettingsPanel({
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");
const [theme, setTheme] = useState<WebsiteThemeConfig>(DEFAULT_THEME);
useEffect(() => {
let cancelled = false;
@ -83,10 +288,7 @@ export function WebsiteThemeSettingsPanel({
setError(res.error);
return;
}
const t = normalizeTheme(res.data?.theme);
setThemeMode(t.theme);
setColor(t.color);
setFont(t.font || "Open Sans");
setTheme(normalizeTheme(res.data?.theme));
})
.catch((e) => {
if (!cancelled) setError(e instanceof Error ? e.message : "Could not load website settings.");
@ -100,32 +302,62 @@ export function WebsiteThemeSettingsPanel({
}, [eventId]);
const colorOptions = useMemo(
() => (themeMode === "gradient" ? GRADIENT_COLORS : SIMPLE_COLORS),
[themeMode],
() => (theme.theme === "gradient" ? GRADIENT_COLORS : SIMPLE_COLORS),
[theme.theme],
);
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 patchTheme = (partial: Partial<WebsiteThemeConfig>) => {
setTheme((prev) => {
const nextMode = partial.theme ?? prev.theme;
let nextColor = partial.color ?? prev.color;
if (partial.theme) {
if (partial.theme === "gradient") {
const ok = new Set(GRADIENT_COLORS.map((c) => c.value));
if (!ok.has(nextColor as (typeof GRADIENT_COLORS)[number]["value"])) nextColor = "green";
} else {
const ok = new Set(SIMPLE_COLORS.map((c) => c.value));
if (!ok.has(nextColor as (typeof SIMPLE_COLORS)[number]["value"])) nextColor = "blue";
}
}
let nextPattern = partial.pattern !== undefined ? partial.pattern : prev.pattern;
if (partial.theme === "pattern" && !trimColor(String(nextPattern ?? ""))) {
nextPattern = "dots";
}
const colorOrModeChanged =
(partial.color !== undefined && partial.color !== prev.color) ||
(partial.theme !== undefined && partial.theme !== prev.theme);
const next: WebsiteThemeConfig = {
...prev,
...partial,
theme: nextMode,
color: nextColor,
pattern: nextPattern,
};
// When preset color/mode changes, refresh primary/button/highlight to that color's defaults.
if (colorOrModeChanged && (nextMode === "simple" || nextMode === "pattern")) {
Object.assign(next, paletteDefaultsForColor(nextColor));
if (partial.primaryColor !== undefined) next.primaryColor = partial.primaryColor;
if (partial.buttonColor !== undefined) next.buttonColor = partial.buttonColor;
if (partial.textHighlightColor !== undefined) {
next.textHighlightColor = partial.textHighlightColor;
}
}
return normalizeTheme(next);
});
};
const save = async () => {
setSaving(true);
setError(null);
try {
const theme = normalizeTheme({ theme: themeMode, color, font });
const res = await websiteSettingsApi.update(eventId, { theme });
const payload = normalizeTheme(theme);
const res = await websiteSettingsApi.update(eventId, { theme: payload });
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");
setTheme(normalizeTheme(res.data?.theme ?? payload));
onSuccess?.();
onClose();
} catch (e) {
@ -139,7 +371,7 @@ export function WebsiteThemeSettingsPanel({
<PanelShell
title="Website settings"
breadcrumb="Event Home"
description="Choose the look of the public event website (theme, color, and font)."
description="Choose the look of the public event website (theme, pattern, palette, and font)."
completedCount={1}
totalSections={1}
showProgress={false}
@ -147,7 +379,7 @@ export function WebsiteThemeSettingsPanel({
ctaDisabled={loading || saving}
onCtaClick={() => void save()}
onClose={onClose}
panelWidth="min(480px, 100vw)"
panelWidth="min(520px, 100vw)"
>
<div style={{ padding: "20px" }}>
{loading ? (
@ -160,8 +392,8 @@ export function WebsiteThemeSettingsPanel({
<Label text="Theme" required />
<SelectWrap>
<select
value={themeMode}
onChange={(e) => onThemeModeChange(e.target.value as WebsiteThemeMode)}
value={theme.theme}
onChange={(e) => patchTheme({ theme: e.target.value as WebsiteThemeMode })}
style={SS}
onFocus={focusIn}
onBlur={focusOut}
@ -173,15 +405,15 @@ export function WebsiteThemeSettingsPanel({
))}
</select>
</SelectWrap>
<HelperText text="Simple uses a flat color look. Gradient uses a richer background preset." />
<HelperText text="Simple and Pattern use a flat palette. Gradient uses a richer background preset." />
</Field>
<Field>
<Label text="Color" required />
<SelectWrap>
<select
value={color}
onChange={(e) => setColor(e.target.value)}
value={theme.color}
onChange={(e) => patchTheme({ color: e.target.value })}
style={SS}
onFocus={focusIn}
onBlur={focusOut}
@ -195,12 +427,34 @@ export function WebsiteThemeSettingsPanel({
</SelectWrap>
</Field>
{theme.theme === "pattern" ? (
<Field>
<Label text="Pattern" required />
<SelectWrap>
<select
value={theme.pattern || "dots"}
onChange={(e) => patchTheme({ pattern: e.target.value as WebsiteThemePattern })}
style={SS}
onFocus={focusIn}
onBlur={focusOut}
>
{PATTERN_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</SelectWrap>
<HelperText text="Background tile used with the Pattern theme." />
</Field>
) : null}
<Field>
<Label text="Font" required />
<SelectWrap>
<select
value={font}
onChange={(e) => setFont(e.target.value)}
value={theme.font || "Open Sans"}
onChange={(e) => patchTheme({ font: e.target.value })}
style={SS}
onFocus={focusIn}
onBlur={focusOut}
@ -214,8 +468,72 @@ export function WebsiteThemeSettingsPanel({
</SelectWrap>
</Field>
<div
style={{
borderTop: "1px solid var(--border)",
paddingTop: "12px",
display: "flex",
flexDirection: "column",
gap: "16px",
}}
>
<div>
<p
style={{
margin: "0 0 4px",
fontSize: "var(--text-sm)",
fontWeight: 600,
fontFamily: "var(--font-noto-sans)",
color: "var(--card-foreground)",
}}
>
Palette overrides
</p>
<HelperText text="Optional. Leave blank to use the preset palette for the selected color." />
</div>
{PALETTE_FIELDS.map((field) => {
const defaults =
theme.theme === "simple" || theme.theme === "pattern"
? paletteDefaultsForColor(theme.color)
: null;
const placeholder =
field.key === "primaryColor"
? defaults?.primaryColor || "#112c59"
: field.key === "buttonColor"
? defaults?.buttonColor || "#50a2ed"
: field.key === "textHighlightColor"
? defaults?.textHighlightColor || "#6ba8e3"
: "optional";
return (
<PaletteColorField
key={field.key}
label={field.label}
help={
field.key === "primaryColor" ||
field.key === "buttonColor" ||
field.key === "textHighlightColor"
? `${field.help} Default for ${theme.color}: ${placeholder}.`
: field.help
}
value={String(theme[field.key] ?? "")}
swatchFallback={placeholder.startsWith("#") ? placeholder : "#112c59"}
placeholder={placeholder}
onChange={(next) => patchTheme({ [field.key]: next })}
/>
);
})}
</div>
{error ? (
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--destructive)", margin: 0 }}>
<p
style={{
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
color: "var(--destructive)",
margin: 0,
}}
>
{error}
</p>
) : null}

View File

@ -24,7 +24,9 @@ export type BadgeFormState = {
layoutElements: BadgeLayoutElement[];
customTexts: { id: string; text: string }[];
customImages: { id: string; imageUrl: string }[];
outputFormat: "pdf" | "png" | "jpeg";
outputFormat: "pdf" | "pdf_per_person" | "png" | "jpeg";
printDpi: number;
colorSpace: "rgb" | "cmyk";
badgeTypeLabel: string;
};
@ -115,7 +117,12 @@ export function formStateFromBadgeBatch(batch: BadgeBatchRecord): BadgeFormState
const scope = str(c.participantScope, "all") === "participant_list" ? "participant_list" : "all";
const out = str(c.outputFormat, "pdf");
const outputFormat: BadgeFormState["outputFormat"] =
out === "png" || out === "jpeg" ? out : "pdf";
out === "png" || out === "jpeg" || out === "pdf_per_person" ? out : "pdf";
const dpiRaw = Number(c.printDpi);
const printDpi =
Number.isFinite(dpiRaw) ? Math.min(600, Math.max(72, Math.round(dpiRaw))) : 300;
const colorSpace: BadgeFormState["colorSpace"] =
str(c.colorSpace, "rgb").toLowerCase() === "cmyk" ? "cmyk" : "rgb";
return {
formName: duplicateExportName(batch.name),
@ -137,6 +144,8 @@ export function formStateFromBadgeBatch(batch: BadgeBatchRecord): BadgeFormState
customTexts,
customImages,
outputFormat,
printDpi,
colorSpace,
badgeTypeLabel: str(c.badgeTypeLabel, "Attendee"),
};
}

View File

@ -82,6 +82,10 @@ export type BadgeLayoutElement = {
/** Default true — reduce font size when text overflows */
shrinkToFit?: boolean;
textAlign?: BadgeTextAlign;
/** Optional string before the field value (default empty) */
prefix?: string;
/** Optional string after the field value (default empty) */
postfix?: string;
};
export const DEFAULT_TEXT_LAYOUT = {
@ -213,6 +217,7 @@ export const BADGE_FONT_FAMILIES = [
{ id: "Arial", label: "Arial", css: "Arial, sans-serif" },
{ id: "Georgia", label: "Georgia", css: "Georgia, serif" },
{ id: "Times New Roman", label: "Times New Roman", css: "'Times New Roman', Times, serif" },
{ id: "Swiss921 BT", label: "Swiss921 BT", css: "'Swiss921 BT', sans-serif" },
] as const;
export const DEFAULT_TEXT_COLOR = "#0f172a";
@ -389,23 +394,37 @@ export function previewAvatarColor(seed: string): string {
return PREVIEW_AVATAR_COLORS[n % PREVIEW_AVATAR_COLORS.length];
}
export function applyTextAffixes(el: Pick<BadgeLayoutElement, "prefix" | "postfix">, text: string): string {
const base = String(text ?? "");
if (!base) return "";
return `${el.prefix ?? ""}${base}${el.postfix ?? ""}`;
}
export function previewTextForElement(
el: BadgeLayoutElement,
sample: BadgePreviewSample,
): string {
if (el.kind === "custom_text") return el.text?.trim() || "Your text";
switch (el.field) {
case "name":
return sample.name;
case "title":
return sample.title;
case "org":
return sample.org;
case "badge_type":
return sample.badgeType;
default:
return "";
let base = "";
if (el.kind === "custom_text") base = el.text?.trim() || "Your text";
else {
switch (el.field) {
case "name":
base = sample.name;
break;
case "title":
base = sample.title;
break;
case "org":
base = sample.org;
break;
case "badge_type":
base = sample.badgeType;
break;
default:
base = "";
}
}
return applyTextAffixes(el, base);
}
export function isTextElement(el: BadgeLayoutElement): boolean {

View File

@ -14,6 +14,7 @@ import type { Participant as ApiParticipant } from "../../../services/api/types"
import { portfolioListOrganizationId } from "../../../services/api/scopedOrganization";
import { AddParticipantPanel } from "../panels/AddParticipantPanel";
import { CreateCustomListPanel } from "../panels/CreateCustomListPanel";
import { ImportEventParticipantsPanel, type EventImportResult } from "../panels/ImportEventParticipantsPanel";
import { ParticipantQrModal } from "./ParticipantQrModal";
import { ParticipantDocumentsPanel } from "../panels/ParticipantDocumentsPanel";
@ -326,125 +327,39 @@ function ParticipantRow({ p, onView,onEdit, onDelete, onConfirm, onReject, onQr,
</button>
</>
)}
{/* View Button */}
<button
type="button"
onClick={onView}
title="View profile"
aria-label="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",
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = "var(--primary)";
e.currentTarget.style.color = "var(--primary)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = "var(--border)";
e.currentTarget.style.color = "var(--card-foreground)";
}}
>
<Eye size={11} /> View
</button>
{/* QR Button */}
<button
type="button"
onClick={onQr}
title="Check-in QR code (EventBuzz Checkins app)"
aria-label="Check-in QR code"
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",
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = "var(--primary)";
e.currentTarget.style.color = "var(--primary)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = "var(--border)";
e.currentTarget.style.color = "var(--card-foreground)";
}}
>
<QrCode size={11} /> QR
</button>
<button
onClick={onDocs}
title="Participant documents"
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",
}}
>
<FileText size={11} /> Document
</button>
<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",
}}
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)";
}}
>
<QrCode size={13} />
</button>
<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)"; }}
onMouseEnter={(e) => { e.currentTarget.style.borderColor = "var(--primary)"; e.currentTarget.style.color = "var(--primary)"; }}
onMouseLeave={(e) => { e.currentTarget.style.borderColor = "var(--border)"; e.currentTarget.style.color = "var(--card-foreground)"; }}
>
<Eye size={13} />
</button>
<button
type="button"
onClick={onQr}
title="Check-in QR code (EventBuzz Checkins app)"
aria-label="Check-in QR code"
style={iconBtnStyle}
onMouseEnter={(e) => { e.currentTarget.style.borderColor = "var(--primary)"; e.currentTarget.style.color = "var(--primary)"; }}
onMouseLeave={(e) => { e.currentTarget.style.borderColor = "var(--border)"; e.currentTarget.style.color = "var(--card-foreground)"; }}
>
<QrCode size={13} />
</button>
<button
type="button"
onClick={onDocs}
title="Participant documents"
aria-label="Participant documents"
style={iconBtnStyle}
onMouseEnter={(e) => { e.currentTarget.style.borderColor = "var(--primary)"; e.currentTarget.style.color = "var(--primary)"; }}
onMouseLeave={(e) => { e.currentTarget.style.borderColor = "var(--border)"; e.currentTarget.style.color = "var(--card-foreground)"; }}
>
<FileText size={13} />
</button>
{canEdit && (
<button
@ -805,14 +720,8 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
const [editId, setEditId] = useState<string | null>(null);
const [confirm, setConfirm] = useState<{ msg: string; onConfirm: () => void } | null>(null);
const [exporting, setExporting] = useState(false);
const [importing, setImporting] = useState(false);
const importInputRef = useRef<HTMLInputElement>(null);
const [importResult, setImportResult] = useState<{
created: number;
skippedDuplicate: number;
failed: number;
errors: { line: number; message: string }[];
} | null>(null);
const [showImportPanel, setShowImportPanel] = useState(false);
const [importResult, setImportResult] = useState<EventImportResult | null>(null);
const [exportError, setExportError] = useState<string | null>(null);
const fetchList = useCallback(() => {
@ -916,26 +825,6 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
else setExportError(null);
};
const onImportFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !selectedEventId) return;
setImporting(true);
try {
const text = await file.text();
const res = await participantsApi.importCsv(selectedEventId, text);
if (res.data) {
setImportResult(res.data);
fetchList();
} else {
setExportError(res.error || "Import failed");
}
} catch {
setExportError("Could not read file.");
}
setImporting(false);
e.target.value = "";
};
const filtersActive = statusFilter !== "All" || designationFilter !== "All" || !!activeTag;
if (!selectedEventId) {
@ -979,7 +868,6 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
</div>
)}
<div style={{ display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" }}>
<input ref={importInputRef} type="file" accept=".csv,text/csv" style={{ display: "none" }} onChange={onImportFile} />
<button
type="button"
disabled={exporting}
@ -993,13 +881,12 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
{isSuperAdmin && (
<button
type="button"
disabled={importing}
onClick={() => importInputRef.current?.click()}
style={{ display: "flex", alignItems: "center", gap: "6px", padding: "7px 14px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--card-foreground)", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", cursor: importing ? "wait" : "pointer" }}
onMouseEnter={(e) => { if (!importing) { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.color = "var(--primary)"; } }}
onClick={() => setShowImportPanel(true)}
style={{ display: "flex", alignItems: "center", gap: "6px", padding: "7px 14px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--card-foreground)", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", cursor: "pointer" }}
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--card-foreground)"; }}
>
<Upload size={14} /> {importing ? "Importing…" : isMobile ? "Import" : "Import CSV"}
<Upload size={14} /> {isMobile ? "Import" : "Import CSV"}
</button>
)}
{isSuperAdmin && (
@ -1272,6 +1159,16 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
onSuccess={() => { fetchList(); setShowAddPanel(false); setEditId(null); }}
/>
)}
{showImportPanel && selectedEventId && (
<ImportEventParticipantsPanel
eventId={selectedEventId}
onClose={() => setShowImportPanel(false)}
onSuccess={(result) => {
setImportResult(result);
fetchList();
}}
/>
)}
{confirm && (
<ConfirmModal message={confirm.msg} onConfirm={confirm.onConfirm} onCancel={() => setConfirm(null)} />
)}
@ -1280,9 +1177,23 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
<div style={{ background: "var(--card)", borderRadius: "var(--radius-card)", maxWidth: "440px", width: "100%", padding: "22px", boxShadow: "var(--shadow-xl)", border: "1px solid var(--border)" }} onClick={(e) => e.stopPropagation()}>
<p style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-base)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", margin: "0 0 12px" }}>Import finished</p>
<ul style={{ margin: "0 0 14px", paddingLeft: "18px", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)", lineHeight: 1.6 }}>
<li><strong>{importResult.created}</strong> added</li>
<li><strong>{importResult.skippedDuplicate}</strong> skipped (email already registered for this event)</li>
{(importResult.created ?? 0) > 0 && <li><strong>{importResult.created}</strong> added</li>}
{(importResult.updated ?? 0) > 0 && <li><strong>{importResult.updated}</strong> updated</li>}
{(importResult.removed ?? 0) > 0 && <li><strong>{importResult.removed}</strong> removed from event</li>}
<li><strong>{importResult.skipped ?? importResult.skippedDuplicate ?? 0}</strong> skipped</li>
{importResult.failed > 0 && <li><strong>{importResult.failed}</strong> rows failed validation</li>}
{importResult.listId && (
<li>
List updated
{importResult.listLinked != null ? ` (${importResult.listLinked} linked)` : ""}
</li>
)}
{(importResult.created ?? 0) === 0 &&
(importResult.updated ?? 0) === 0 &&
(importResult.removed ?? 0) === 0 &&
importResult.failed === 0 && (
<li>No rows changed</li>
)}
</ul>
{importResult.errors.length > 0 && (
<div style={{ maxHeight: "160px", overflowY: "auto", marginBottom: "14px", padding: "10px", background: "var(--secondary)", borderRadius: "var(--radius-button)", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)" }}>
@ -1292,9 +1203,6 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
{importResult.failed > importResult.errors.length && <div>…and more</div>}
</div>
)}
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: "0 0 14px", fontFamily: "var(--font-noto-sans)" }}>
Required columns: <code style={{ fontSize: "11px" }}>email</code>, <code>firstName</code> (or First name), <code>lastName</code> (or Last name). Optional: phone, organization, designation, tags, notes.
</p>
<button type="button" onClick={() => setImportResult(null)} style={{ padding: "8px 18px", 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)" }}>OK</button>
</div>
</div>

View File

@ -19,6 +19,7 @@ import {
type ParticipantListRecord,
type FormWithSubmissionCount,
type TicketingManualPaymentSettings,
type TicketingOnlinePaymentSettings,
type TicketManualOrderRow,
} from "../../../services/api";
import { buildUpiPayUri } from "../../../utils/upiPayUri";
@ -77,6 +78,12 @@ export function EventTicketingPage() {
const [ordersLoading, setOrdersLoading] = useState(false);
const [enablementPayBusy, setEnablementPayBusy] = useState(false);
const [onlineSettings, setOnlineSettings] = useState<TicketingOnlinePaymentSettings | null>(null);
const [onlineEnabled, setOnlineEnabled] = useState(false);
const [onlineGatewayIds, setOnlineGatewayIds] = useState<string[]>([]);
const [onlineSaving, setOnlineSaving] = useState(false);
const [onlineErr, setOnlineErr] = useState<string | null>(null);
const canManageBilling = user?.role === "super_admin" || user?.role === "admin";
const checkoutUrl =
@ -117,13 +124,16 @@ export function EventTicketingPage() {
const loadManual = useCallback(async () => {
if (!selectedEventId) {
setManualSettings(null);
setOnlineSettings(null);
return;
}
setManualLoading(true);
setManualErr(null);
setOnlineErr(null);
try {
const [m, evRes] = await Promise.all([
const [m, online, evRes] = await Promise.all([
eventTicketingManualApi.get(selectedEventId),
eventTicketingManualApi.getOnline(selectedEventId),
eventsApi.get(selectedEventId),
]);
setEventTitle(evRes.data?.name?.trim() || "");
@ -134,11 +144,15 @@ export function EventTicketingPage() {
setManualUpiId(m.upiId || "");
setManualPayee(m.payeeName || "");
setManualInstructions(m.instructions || "");
setOnlineSettings(online);
setOnlineEnabled(online.enabled);
setOnlineGatewayIds(online.gatewayIds || []);
const ord = await ticketManualOrdersApi.list(selectedEventId, "pending_review");
setPendingOrders(ord);
} catch (e) {
setManualErr(e instanceof Error ? e.message : "Failed to load UPI settings");
setManualSettings(null);
setOnlineSettings(null);
} finally {
setManualLoading(false);
}
@ -209,6 +223,32 @@ export function EventTicketingPage() {
}
};
const saveOnlineSettings = async () => {
if (!selectedEventId) return;
setOnlineSaving(true);
setOnlineErr(null);
try {
const next = await eventTicketingManualApi.patchOnline(selectedEventId, {
enabled: onlineEnabled,
gatewayIds: onlineEnabled ? onlineGatewayIds : [],
});
setOnlineSettings(next);
setOnlineEnabled(next.enabled);
setOnlineGatewayIds(next.gatewayIds || []);
} catch (e) {
const msg = e instanceof Error ? e.message : "Save failed";
setOnlineErr(msg);
window.alert(msg);
} finally {
setOnlineSaving(false);
}
};
const toggleOnlineGateway = (id: string, supported: boolean) => {
if (!supported) return;
setOnlineGatewayIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
};
const payEnablementFee = async () => {
if (!selectedEventId || !canManageBilling) return;
setEnablementPayBusy(true);
@ -402,7 +442,8 @@ export function EventTicketingPage() {
</h1>
<p style={{ fontSize: "var(--text-sm)", color: "var(--muted)", marginTop: "6px", maxWidth: "560px" }}>
Sell tickets on the public checkout page. Buyers are added to the participant list you choose. Online card/UPI uses your
Razorpay (or platform) gateway when configured; if not, you can enable per-event UPI QR and review payment screenshots
Razorpay (and other organisation gateways you enable below) when configured; you can also enable per-event UPI QR and
review payment screenshots
below.
</p>
</div>
@ -594,6 +635,108 @@ export function EventTicketingPage() {
top: 16,
}}
>
<h2
style={{
fontFamily: "var(--font-inter)",
fontSize: "var(--text-lg)",
fontWeight: "var(--font-weight-medium)",
margin: "0 0 8px",
}}
>
Online payment gateways
</h2>
<p style={{ fontSize: "var(--text-sm)", color: "var(--muted)", margin: "0 0 12px" }}>
Choose organisation payment gateways (from Settings → Payments) that buyers can use when purchasing tickets for this
event.
</p>
{onlineErr ? <p style={{ color: "var(--destructive)", fontSize: "var(--text-sm)" }}>{onlineErr}</p> : null}
{!manualLoading && onlineSettings ? (
<div style={{ display: "flex", flexDirection: "column", gap: "12px", marginBottom: "20px" }}>
<label style={{ display: "flex", alignItems: "center", gap: "8px", fontSize: "var(--text-sm)" }}>
<input
type="checkbox"
checked={onlineEnabled}
onChange={(e) => {
const on = e.target.checked;
setOnlineEnabled(on);
if (on && onlineGatewayIds.length === 0) {
const first = (onlineSettings.orgGateways || []).find(
(g) => g.isActive && g.ticketCheckoutSupported,
);
if (first) setOnlineGatewayIds([first.id]);
}
}}
/>
Enable online checkout for this event
</label>
{onlineEnabled ? (
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
{(onlineSettings.orgGateways || []).length === 0 ? (
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: 0 }}>
No gateways on this organisation yet. Add Razorpay (or another provider) under{" "}
<strong>Settings → Payments</strong>, then return here to enable it for tickets.
</p>
) : (
(onlineSettings.orgGateways || []).map((g) => {
const supported = g.ticketCheckoutSupported && g.isActive;
return (
<label
key={g.id}
style={{
display: "flex",
alignItems: "flex-start",
gap: "8px",
fontSize: "var(--text-sm)",
opacity: supported ? 1 : 0.55,
}}
>
<input
type="checkbox"
checked={onlineGatewayIds.includes(g.id)}
disabled={!supported}
onChange={() => toggleOnlineGateway(g.id, supported)}
style={{ marginTop: 3 }}
/>
<span>
<strong>{g.label || g.provider}</strong>
{" · "}
{g.provider}
{g.isPrimary ? " · primary" : ""}
{g.testMode ? " · test" : " · live"}
{!g.isActive ? " · inactive" : ""}
{!g.ticketCheckoutSupported ? " · not available for tickets yet" : ""}
</span>
</label>
);
})
)}
</div>
) : (
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: 0 }}>
Online card/UPI via your connected gateway will not be offered at ticket checkout for this event.
</p>
)}
<button
type="button"
disabled={onlineSaving}
onClick={() => void saveOnlineSettings()}
style={{
alignSelf: "flex-start",
padding: "8px 16px",
borderRadius: "var(--radius-button)",
border: "none",
background: "var(--primary)",
color: "var(--primary-foreground)",
cursor: onlineSaving ? "wait" : "pointer",
}}
>
{onlineSaving ? "Saving…" : "Save online payment settings"}
</button>
</div>
) : null}
<h2
style={{
fontFamily: "var(--font-inter)",
@ -605,10 +748,10 @@ export function EventTicketingPage() {
UPI / manual checkout
</h2>
<p style={{ fontSize: "var(--text-sm)", color: "var(--muted)", margin: "0 0 12px" }}>
For events without an online payment gateway. Buyers pay you directly, then upload proof for you to approve.
Buyers pay you directly via UPI, then upload proof for you to approve. Can be used alone or alongside online gateways.
</p>
{manualErr && <p style={{ color: "var(--destructive)", fontSize: "var(--text-sm)" }}>{manualErr}</p>}
{manualLoading && <p style={{ color: "var(--muted)" }}>Loading UPI settings…</p>}
{manualLoading && <p style={{ color: "var(--muted)" }}>Loading payment settings…</p>}
{!manualLoading && manualSettings && (
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
{!manualSettings.enablementPaid ?
@ -787,24 +930,27 @@ export function EventTicketingPage() {
Buyers pay ticket price plus tax only on this path. Eventbuzz is covered by the one-time manual UPI enablement fee you
pay per event (Billing).
</p>
<button
type="button"
disabled={manualSaving}
onClick={() => void saveManualSettings()}
style={{
alignSelf: "flex-start",
padding: "8px 16px",
borderRadius: "var(--radius-button)",
border: "none",
background: "var(--primary)",
color: "var(--primary-foreground)",
cursor: "pointer",
}}
>
{manualSaving ? "Saving…" : "Save UPI settings"}
</button>
</>
)}
{(manualSettings.enablementPaid || manualEnabled || manualSettings.enabled) ? (
<button
type="button"
disabled={manualSaving || (!manualSettings.enablementPaid && manualEnabled)}
onClick={() => void saveManualSettings()}
style={{
alignSelf: "flex-start",
padding: "8px 16px",
borderRadius: "var(--radius-button)",
border: "none",
background: "var(--primary)",
color: "var(--primary-foreground)",
cursor: manualSaving ? "wait" : "pointer",
}}
>
{manualSaving ? "Saving…" : manualEnabled ? "Save UPI settings" : "Save (disable UPI checkout)"}
</button>
) : null}
</div>
)}

View File

@ -6,13 +6,34 @@ import {
uploadMediaFile,
portfolioListOrganizationId,
type ConciergeBotRecord,
type ConciergeFaqItem,
type KnowledgeSourceRecord,
type ContextChunkRecord,
type Event,
} from "../../../services/api";
import { API_BASE_URL } from "../../../services/api/client";
import { useAuth } from "../../contexts/AuthContext";
/** Public concierge API base (must end with /api). Falls back to local concierge in dev. */
const CONCIERGE_API_BASE = (
import.meta.env.VITE_CONCIERGE_URL || "http://localhost:3010/api"
).replace(/\/$/, "");
function faqFormatLabel(format?: string): string {
if (format === "mcq") return "MCQ";
if (format === "mcq_multi") return "Multi-select";
if (format === "mcq_short_text") return "MCQ + Short Answer";
if (format === "contact_details") return "Contact details";
if (format === "media_upload") return "Image / PDF";
return "Short Answer";
}
function inferFaqMediaType(file: File, url: string): "image" | "pdf" | "" {
const mime = String(file.type || "").toLowerCase();
if (mime.includes("pdf") || /\.pdf$/i.test(file.name) || /\.pdf(\?|#|$)/i.test(url)) return "pdf";
if (mime.startsWith("image/") || /\.(png|jpe?g|gif|webp|svg)$/i.test(file.name)) return "image";
return "";
}
const IS: React.CSSProperties = {
width: "100%",
padding: "8px 12px",
@ -57,10 +78,11 @@ function eventIdOf(bot: ConciergeBotRecord): string {
return "";
}
function buildEmbedSnippet(botId: string) {
const apiBase = API_BASE_URL;
function buildEmbedSnippet(botId: string, accentColor?: string | null) {
const apiBase = CONCIERGE_API_BASE;
const widgetSrc = apiBase.replace(/\/api\/?$/, "") + "/embed/concierge-widget.js";
return `<script>\n window.EB_CONCIERGE = { botId: \"${botId}\", apiBase: \"${apiBase}\" };\n</script>\n<script async src=\"${widgetSrc}\"></script>`;
const accent = String(accentColor || "").trim() || "rgb(13, 148, 136)";
return `<script>\n window.EB_CONCIERGE = { botId: \"${botId}\", apiBase: \"${apiBase}\", accentColor: \"${accent}\" };\n</script>\n<script async src=\"${widgetSrc}?v=22\"></script>`;
}
export function WhatsAppChatbotsPage() {
@ -68,7 +90,7 @@ export function WhatsAppChatbotsPage() {
const [bots, setBots] = useState<ConciergeBotRecord[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [bot, setBot] = useState<ConciergeBotRecord | null>(null);
const [tab, setTab] = useState<"settings" | "knowledge" | "preview">("settings");
const [tab, setTab] = useState<"settings" | "venue-faq" | "knowledge" | "preview">("settings");
const [sources, setSources] = useState<KnowledgeSourceRecord[]>([]);
const [chunks, setChunks] = useState<ContextChunkRecord[]>([]);
const [previewSourceId, setPreviewSourceId] = useState<string | null>(null);
@ -147,6 +169,21 @@ export function WhatsAppChatbotsPage() {
includePlacesRecommendations: bot.knowledge.includePlacesRecommendations,
}
: undefined,
faqs: (bot.faqs || []).map((f) => ({
key: f.key,
category: f.category,
question: f.question,
answerFormat: f.answerFormat,
options: f.options || [],
answer: f.answer || "",
answerDetail: f.answerDetail || "",
contactEmail: f.contactEmail || "",
contactPhone: f.contactPhone || "",
mediaUrl: f.mediaUrl || "",
mediaName: f.mediaName || "",
mediaType: f.mediaType || "",
selectedOptions: f.selectedOptions || [],
})),
});
if (res.error) {
setMsg(res.error);
@ -157,6 +194,59 @@ export function WhatsAppChatbotsPage() {
await loadBots();
};
const patchFaq = (index: number, patch: Partial<ConciergeFaqItem>) => {
if (!bot) return;
const faqs = [...(bot.faqs || [])];
if (!faqs[index]) return;
faqs[index] = { ...faqs[index], ...patch };
setBot({ ...bot, faqs });
};
const updateFaqAnswer = (index: number, answer: string) => {
patchFaq(index, { answer });
};
const updateFaqAnswerDetail = (index: number, answerDetail: string) => {
patchFaq(index, { answerDetail });
};
const toggleFaqOption = (index: number, option: string) => {
if (!bot) return;
const row = bot.faqs?.[index];
if (!row) return;
const current = Array.isArray(row.selectedOptions) ? row.selectedOptions : [];
const next = current.includes(option)
? current.filter((o) => o !== option)
: [...current, option];
patchFaq(index, { selectedOptions: next });
};
const handleFaqMediaUpload = async (index: number, file: File) => {
if (!bot) return;
const eid = eventIdOf(bot);
setMsg(null);
try {
const up = await uploadMediaFile(file, {
eventId: eid || undefined,
module: "concierge-faq",
visibility: "public",
});
const url = String(up.url || "").trim();
if (!url) {
setMsg("Upload failed — no file URL");
return;
}
patchFaq(index, {
mediaUrl: url,
mediaName: file.name,
mediaType: inferFaqMediaType(file, url),
});
setMsg("Floor plan / map uploaded — save Bot FAQ to persist");
} catch (e) {
setMsg(e instanceof Error ? e.message : "Upload failed");
}
};
const handleCreate = async () => {
if (!newEventId || !newPhoneId) {
setMsg("Select an event and enter a WhatsApp phone number ID.");
@ -337,15 +427,20 @@ export function WhatsAppChatbotsPage() {
)}
{bot && (
<>
<div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
{(["settings", "knowledge", "preview"] as const).map((t) => (
<div style={{ display: "flex", gap: 8, marginBottom: 16, flexWrap: "wrap" }}>
{([
["settings", "Settings"],
["venue-faq", "Bot FAQ"],
["knowledge", "Knowledge"],
["preview", "Preview"],
] as const).map(([t, label]) => (
<button
key={t}
type="button"
style={tab === t ? btnPrimary : btnSecondary}
onClick={() => setTab(t)}
>
{t.charAt(0).toUpperCase() + t.slice(1)}
{label}
</button>
))}
</div>
@ -493,12 +588,16 @@ export function WhatsAppChatbotsPage() {
<textarea
readOnly
style={{ ...IS, minHeight: 110, fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace" }}
value={buildEmbedSnippet(bot._id)}
value={buildEmbedSnippet(bot._id, bot.accentColor)}
/>
<button
type="button"
style={{ ...btnSecondary, marginTop: 8 }}
onClick={() => navigator.clipboard.writeText(buildEmbedSnippet(bot._id)).then(() => setMsg("Embed code copied"))}
onClick={() =>
navigator.clipboard
.writeText(buildEmbedSnippet(bot._id, bot.accentColor))
.then(() => setMsg("Embed code copied"))
}
>
Copy embed code
</button>
@ -506,6 +605,246 @@ export function WhatsAppChatbotsPage() {
</div>
)}
{tab === "venue-faq" && (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<p style={{ margin: 0, fontSize: 13, color: "var(--muted-foreground)" }}>
Configure answers for fixed bot FAQ prompts. Questions are not editable. Empty answers fall back to event data or the help desk in the concierge.
</p>
{(bot.faqs || []).length === 0 && (
<p style={{ margin: 0, fontSize: 12, color: "var(--muted-foreground)" }}>
No questions yet — open this tab after loading the bot (templates seed automatically).
</p>
)}
{(bot.faqs || []).map((f: ConciergeFaqItem, index: number) => {
const format = f.answerFormat || "short_text";
const options = Array.isArray(f.options) ? f.options : [];
const selected = Array.isArray(f.selectedOptions) ? f.selectedOptions : [];
const showMcq = format === "mcq" || format === "mcq_short_text";
const showMulti = format === "mcq_multi";
const showText =
format === "short_text" || format === "mcq_short_text";
const showContact = format === "contact_details";
const showMedia = format === "media_upload";
return (
<div
key={f.key || `${f.category}-${index}`}
style={{ border: "1px solid var(--border)", borderRadius: 8, padding: 12 }}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
gap: 8,
alignItems: "flex-start",
marginBottom: 10,
}}
>
<p style={{ margin: 0, fontSize: 13, fontWeight: 600, lineHeight: 1.4 }}>
{f.question}
</p>
<span
style={{
flexShrink: 0,
fontSize: 11,
color: "var(--muted-foreground)",
border: "1px solid var(--border)",
borderRadius: 4,
padding: "2px 6px",
}}
>
{faqFormatLabel(format)}
</span>
</div>
{showContact && (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<p style={{ margin: 0, fontSize: 12, color: "var(--muted-foreground)" }}>
These details are shared with visitors who ask for human / emergency help.
</p>
<label style={{ display: "block", fontSize: 12 }}>
Email
<input
style={{ ...IS, marginTop: 4 }}
type="email"
placeholder="organizer@example.com"
value={f.contactEmail || ""}
onChange={(e) => patchFaq(index, { contactEmail: e.target.value })}
/>
</label>
<label style={{ display: "block", fontSize: 12 }}>
Phone
<input
style={{ ...IS, marginTop: 4 }}
type="tel"
placeholder="+91 …"
value={f.contactPhone || ""}
onChange={(e) => patchFaq(index, { contactPhone: e.target.value })}
/>
</label>
<label style={{ display: "block", fontSize: 12 }}>
Extra note (optional)
<textarea
style={{ ...IS, minHeight: 48, marginTop: 4 }}
placeholder="e.g. On-site help desk near registration"
value={f.answerDetail || ""}
onChange={(e) => updateFaqAnswerDetail(index, e.target.value)}
/>
</label>
</div>
)}
{showMedia && (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<p style={{ margin: 0, fontSize: 12, color: "var(--muted-foreground)" }}>
Upload a floor plan image or PDF. Visitors will see a preview and download option.
</p>
<input
type="file"
accept="image/*,.pdf,application/pdf"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void handleFaqMediaUpload(index, file);
e.target.value = "";
}}
/>
{f.mediaUrl ? (
<div
style={{
border: "1px solid var(--border)",
borderRadius: 8,
padding: 10,
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
<div style={{ fontSize: 12 }}>
<strong>{f.mediaName || "Uploaded file"}</strong>
{f.mediaType ? ` · ${f.mediaType}` : ""}
</div>
{f.mediaType === "image" ? (
<img
src={f.mediaUrl}
alt={f.mediaName || "Floor plan"}
style={{
maxWidth: "100%",
maxHeight: 180,
objectFit: "contain",
borderRadius: 6,
border: "1px solid var(--border)",
}}
/>
) : (
<a href={f.mediaUrl} target="_blank" rel="noreferrer" style={{ fontSize: 13 }}>
Open / preview file
</a>
)}
<button
type="button"
style={{ ...btnSecondary, alignSelf: "flex-start" }}
onClick={() =>
patchFaq(index, { mediaUrl: "", mediaName: "", mediaType: "" })
}
>
Remove file
</button>
</div>
) : null}
</div>
)}
{showMulti && (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<p style={{ margin: 0, fontSize: 12, color: "var(--muted-foreground)" }}>
Select all that apply.
</p>
{options.map((opt) => (
<label
key={opt}
style={{
display: "flex",
alignItems: "center",
gap: 8,
fontSize: 13,
cursor: "pointer",
}}
>
<input
type="checkbox"
checked={selected.includes(opt)}
onChange={() => toggleFaqOption(index, opt)}
/>
{opt}
</label>
))}
</div>
)}
{showMcq && (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{options.map((opt) => (
<label
key={opt}
style={{
display: "flex",
alignItems: "center",
gap: 8,
fontSize: 13,
cursor: "pointer",
}}
>
<input
type="radio"
name={`faq-mcq-${f.key || index}`}
checked={(f.answer || "") === opt}
onChange={() => updateFaqAnswer(index, opt)}
/>
{opt}
</label>
))}
{(f.answer || "") && !options.includes(f.answer || "") && (
<button
type="button"
style={{ ...btnSecondary, alignSelf: "flex-start", marginTop: 4 }}
onClick={() => updateFaqAnswer(index, "")}
>
Clear selection
</button>
)}
</div>
)}
{showText && (
<label style={{ display: "block", fontSize: 12, marginTop: showMcq ? 10 : 0 }}>
{format === "mcq_short_text"
? "Desk location / details (optional)"
: "Answer"}
<textarea
style={{ ...IS, minHeight: 64, marginTop: 4 }}
placeholder={
format === "mcq_short_text"
? "e.g. Registration desk, Hall A entrance"
: format === "short_text" && f.key === "wifi"
? "Network name and password"
: "Organizer answer (leave blank to use fallback)"
}
value={
format === "short_text"
? f.answer || ""
: f.answerDetail || ""
}
onChange={(e) =>
format === "short_text"
? updateFaqAnswer(index, e.target.value)
: updateFaqAnswerDetail(index, e.target.value)
}
/>
</label>
)}
</div>
);
})}
<button type="button" style={btnPrimary} onClick={saveSettings}>
Save Bot FAQ
</button>
</div>
)}
{tab === "knowledge" && (
<div>
<div style={{ marginBottom: 16, padding: 12, border: "1px solid var(--border)", borderRadius: 8 }}>

View File

@ -0,0 +1,49 @@
/** Per-session role for a key person on a session (default Speaker). */
export const DEFAULT_KEY_PERSON_ROLE = "Speaker";
export const KEY_PERSON_ROLES = [
"Speaker",
"Keynote Speaker",
"Moderator",
"Panelist",
"Host",
"Master of Ceremonies",
"Workshop Leader",
"Facilitator",
"Professor",
"Trainer",
"Instructor",
"Session Chair",
"Track Chair",
"Faculty",
"Course Director",
"Q&A Facilitator",
"Audience Host",
"Presenter",
"Co-Speaker",
"Guest Speaker",
"Fireside Guest",
"Interviewee",
"Discussant",
"Respondent",
"Mentor",
"Coach",
"Judge",
"Evaluator",
"Table Host",
"Roundtable Leader",
"Lightning Speaker",
"Pitcher",
"Rapporteur",
"Session Scribe",
"Virtual/Hybrid Host",
] as const;
export type KeyPersonRole = (typeof KEY_PERSON_ROLES)[number];
export function normalizeKeyPersonRole(role?: string | null): KeyPersonRole {
const value = String(role || "").trim();
return (KEY_PERSON_ROLES as readonly string[]).includes(value)
? (value as KeyPersonRole)
: DEFAULT_KEY_PERSON_ROLE;
}

View File

@ -15,6 +15,10 @@ const SAMPLE_VARS: Record<string, string> = {
"contact.email": "jane@example.com",
"contact.organization": "Acme Inc.",
"contact.phone": "+91 98765 43210",
"contact.qrUrl":
"https://api.qrserver.com/v1/create-qr-code/?size=220x220&ecc=M&margin=8&data=eventbuzz%3Aparticipant%3A507f1f77bcf86cd799439011",
"contact.qrImage":
'<img src="https://api.qrserver.com/v1/create-qr-code/?size=220x220&ecc=M&margin=8&data=eventbuzz%3Aparticipant%3A507f1f77bcf86cd799439011" alt="Check-in QR" width="200" height="200" style="display:block;margin:12px auto;border:0;" />',
"event.name": "Annual Summit 2026",
"event.startDate": "15 Mar 2026, 9:00 am",
"event.endDate": "17 Mar 2026, 6:00 pm",

View File

@ -1,5 +1,6 @@
export interface FormEventHeaderDisplay {
showLogo?: boolean;
showBanner?: boolean;
showTitle?: boolean;
showDateRange?: boolean;
showEventType?: boolean;
@ -14,6 +15,7 @@ export function resolveFormEventHeaderDisplay(
): ResolvedFormEventHeaderDisplay {
return {
showLogo: display?.showLogo !== false,
showBanner: display?.showBanner === true,
showTitle: display?.showTitle !== false,
showDateRange: display?.showDateRange !== false,
showEventType: display?.showEventType !== false,
@ -25,7 +27,8 @@ export function resolveFormEventHeaderDisplay(
export type EventHeaderToggleKey = keyof ResolvedFormEventHeaderDisplay;
export const EVENT_HEADER_TOGGLE_LABELS: Record<EventHeaderToggleKey, string> = {
showLogo: "Event logo / banner",
showLogo: "Event logo",
showBanner: "Event banner",
showTitle: "Event title",
showDateRange: "Date range",
showEventType: "Event type",
@ -37,6 +40,7 @@ export function eventHeaderFromApi(raw?: FormEventHeaderDisplay | null): Record<
const resolved = resolveFormEventHeaderDisplay(raw);
return {
showLogo: resolved.showLogo ? "Yes" : "No",
showBanner: resolved.showBanner ? "Yes" : "No",
showTitle: resolved.showTitle ? "Yes" : "No",
showDateRange: resolved.showDateRange ? "Yes" : "No",
showEventType: resolved.showEventType ? "Yes" : "No",
@ -50,6 +54,7 @@ export function eventHeaderToApi(
): FormEventHeaderDisplay {
return {
showLogo: toggles.showLogo === "Yes",
showBanner: toggles.showBanner === "Yes",
showTitle: toggles.showTitle === "Yes",
showDateRange: toggles.showDateRange === "Yes",
showEventType: toggles.showEventType === "Yes",

View File

@ -21,6 +21,19 @@ export function sessionToPayload(s: ApiSession): SessionPayload {
else if (typeof trackRaw === "string" && trackRaw.trim()) track = trackRaw;
const speakers = (s.speakers || []).map((x) => (typeof x === "string" ? x : x._id));
const keyPersons =
Array.isArray(s.keyPersons) && s.keyPersons.length
? s.keyPersons
.map((kp) => {
const speakerId =
typeof kp.speaker === "object" && kp.speaker && "_id" in kp.speaker
? String(kp.speaker._id)
: String(kp.speaker || "");
if (!speakerId) return null;
return { speaker: speakerId, role: String(kp.role || "Speaker").trim() || "Speaker" };
})
.filter((x): x is { speaker: string; role: string } => Boolean(x))
: speakers.map((speaker) => ({ speaker, role: "Speaker" }));
return {
title: s.title,
@ -31,6 +44,7 @@ export function sessionToPayload(s: ApiSession): SessionPayload {
location: s.location,
track,
speakers,
keyPersons,
moderator: s.moderator ?? null,
capacity: s.capacity,
status: s.status,

BIN
src/assets/logo-small.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

BIN
src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -0,0 +1,188 @@
.form-ticket-step-label {
margin: 0 0 12px;
font-size: 0.8125rem;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.form-ticket-list {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 16px;
}
.form-ticket-card {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
width: 100%;
text-align: left;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid #e2e8f0;
background: #fff;
cursor: pointer;
color: inherit;
font: inherit;
}
.form-ticket-card--active {
border-color: #0f172a;
box-shadow: 0 0 0 1px #0f172a;
}
.form-ticket-card-name {
font-weight: 600;
font-size: 0.95rem;
}
.form-ticket-card-price {
font-size: 0.875rem;
color: #334155;
}
.form-ticket-card-desc {
font-size: 0.8125rem;
color: #64748b;
}
.form-ticket-channels {
display: flex;
gap: 8px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.form-ticket-channel {
flex: 1;
min-width: 120px;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid #e2e8f0;
background: #f8fafc;
cursor: pointer;
font-size: 0.8125rem;
}
.form-ticket-channel--active {
border-color: #0f172a;
background: #0f172a;
color: #fff;
}
.form-ticket-manual {
margin-bottom: 12px;
}
.form-ticket-qr {
display: block;
max-width: 180px;
margin: 0 auto 10px;
border-radius: 8px;
}
.form-ticket-qr-wrap {
display: flex;
justify-content: center;
margin: 0 0 12px;
padding: 12px;
background: #fff;
border-radius: 8px;
border: 1px solid #e2e8f0;
}
.form-ticket-upi-id {
margin: 0 0 8px;
font-size: 0.875rem;
font-weight: 600;
text-align: center;
}
.form-ticket-total {
margin: 0 0 12px;
font-size: 0.95rem;
}
.form-ticket-tax {
color: #64748b;
font-weight: 400;
}
.form-ticket-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.form-panel {
padding: 24px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.form-label {
display: block;
font-size: 0.75rem;
font-weight: 600;
color: #64748b;
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.form-input {
width: 100%;
padding: 10px 12px;
font-size: 0.9rem;
border-radius: 8px;
border: 1px solid #e2e8f0;
background: #fff;
color: #0f172a;
box-sizing: border-box;
}
.form-submit {
padding: 12px 16px;
border-radius: 8px;
border: none;
background: #0f172a;
color: #fff;
font-weight: 600;
cursor: pointer;
}
.form-submit--secondary {
background: #fff;
color: #0f172a;
border: 1px solid #cbd5e1;
}
.form-submit:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.form-error {
margin: 0;
color: #dc2626;
font-size: 0.875rem;
}
.form-desc {
margin: 0 0 8px;
color: #64748b;
font-size: 0.875rem;
}
.form-field {
display: block;
margin-bottom: 4px;
}

View File

@ -0,0 +1,417 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import QRCode from 'qrcode';
import { loadRazorpayScript } from '../../services/api/subscription';
import {
publicEventTicketsApi,
type PublicTicketCatalogItem,
type TicketQuote,
} from '../../services/api/publicEventTickets';
import type { TicketAttendeePayload } from '../../utils/registrationTicketAttendee';
import { formatTicketPrice, INDIAN_STATES } from '../../utils/registrationTicketAttendee';
import { buildUpiPayUri } from '../../utils/upiPayUri';
import './RegistrationTicketStep.css';
const PAYMENT_CANCELLED_MESSAGE = 'Payment cancelled';
type ManualUpi = NonNullable<
Awaited<ReturnType<typeof publicEventTicketsApi.checkoutOptions>>['manualUpi']
>;
type Props = {
eventId: string;
formId: string;
eventName?: string;
tickets: PublicTicketCatalogItem[];
attendee: TicketAttendeePayload;
onBack: () => void;
onPaid: () => void;
onPendingReview: (message: string) => void;
};
export function RegistrationTicketStep({
eventId,
formId,
eventName,
tickets,
attendee,
onBack,
onPaid,
onPendingReview,
}: Props) {
const [ticketId, setTicketId] = useState(tickets[0]?._id || '');
const [billingState, setBillingState] = useState('');
const [quote, setQuote] = useState<TicketQuote | null>(null);
const [quoteError, setQuoteError] = useState<string | null>(null);
const [payError, setPayError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [checkoutChannel, setCheckoutChannel] = useState<'online' | 'manual'>('online');
const [onlineAvailable, setOnlineAvailable] = useState(false);
const [onlineGateways, setOnlineGateways] = useState<
NonNullable<Awaited<ReturnType<typeof publicEventTicketsApi.checkoutOptions>>['onlineGateways']>
>([]);
const [selectedOnlineGatewayId, setSelectedOnlineGatewayId] = useState('');
const [manualUpi, setManualUpi] = useState<ManualUpi | null>(null);
const [screenshotDataUrl, setScreenshotDataUrl] = useState('');
const [buyerNote, setBuyerNote] = useState('');
const [dynamicQrDataUrl, setDynamicQrDataUrl] = useState<string | null>(null);
const selected = useMemo(() => tickets.find((t) => t._id === ticketId) || null, [tickets, ticketId]);
const payMode = useMemo(() => {
if (onlineAvailable && manualUpi) return 'both';
if (onlineAvailable) return 'online';
if (manualUpi) return 'manual';
return 'none';
}, [onlineAvailable, manualUpi]);
const isManualCheckout = payMode === 'manual' || (payMode === 'both' && checkoutChannel === 'manual');
useEffect(() => {
let cancelled = false;
void publicEventTicketsApi
.checkoutOptions(eventId)
.then((opts) => {
if (cancelled) return;
setOnlineAvailable(!!opts.onlineAvailable);
setManualUpi(opts.manualUpi || null);
const gateways = opts.onlineGateways || [];
setOnlineGateways(gateways);
setSelectedOnlineGatewayId(gateways[0]?.id || '');
if (!opts.onlineAvailable && opts.manualUpi) setCheckoutChannel('manual');
})
.catch(() => {
if (!cancelled) {
setOnlineAvailable(false);
setManualUpi(null);
setOnlineGateways([]);
setSelectedOnlineGatewayId('');
}
});
return () => {
cancelled = true;
};
}, [eventId]);
useEffect(() => {
if (!ticketId || !selected) {
setQuote(null);
return;
}
if (selected.requiresBillingState && !billingState) {
setQuote(null);
setQuoteError(null);
return;
}
let cancelled = false;
setQuoteError(null);
const run = async () => {
try {
const q = isManualCheckout
? await publicEventTicketsApi.manualQuote(eventId, {
ticketId,
quantity: 1,
billingStateCode: billingState || undefined,
})
: await publicEventTicketsApi.quote(eventId, {
ticketId,
quantity: 1,
billingStateCode: billingState || undefined,
});
if (!cancelled) setQuote(q);
} catch (e) {
if (!cancelled) {
setQuote(null);
setQuoteError(e instanceof Error ? e.message : 'Could not load price');
}
}
};
void run();
return () => {
cancelled = true;
};
}, [eventId, ticketId, billingState, selected, isManualCheckout]);
useEffect(() => {
if (!isManualCheckout || !manualUpi || manualUpi.manualQrMode !== 'dynamic') {
setDynamicQrDataUrl(null);
return;
}
const pa = manualUpi.upiId?.trim();
const evName = (manualUpi.eventName || eventName || '').trim();
const quoteTotal = quote?.totalMinor;
if (!pa || !selected?.name || !evName || quoteTotal == null) {
setDynamicQrDataUrl(null);
return;
}
const pn = `${selected.name} · ${evName}`.slice(0, 99);
const am = (quoteTotal / 100).toFixed(2);
const uri = buildUpiPayUri({ pa, pn, am });
if (!uri) {
setDynamicQrDataUrl(null);
return;
}
let cancelled = false;
void QRCode.toDataURL(uri, { margin: 1, width: 280, errorCorrectionLevel: 'M' }).then((url) => {
if (!cancelled) setDynamicQrDataUrl(url);
});
return () => {
cancelled = true;
};
}, [isManualCheckout, manualUpi, selected?.name, eventName, quote?.totalMinor]);
const onScreenshot = useCallback((file: File | null) => {
if (!file) {
setScreenshotDataUrl('');
return;
}
const reader = new FileReader();
reader.onload = () => setScreenshotDataUrl(typeof reader.result === 'string' ? reader.result : '');
reader.readAsDataURL(file);
}, []);
const pay = async () => {
setPayError(null);
if (!selected || !quote) {
setPayError('Select a ticket and wait for the price.');
return;
}
if (selected.requiresBillingState && !billingState) {
setPayError('Select your billing state.');
return;
}
if (!attendee.email || !attendee.firstName || !attendee.lastName) {
setPayError('Registration details are incomplete. Go back and fill required fields.');
return;
}
if (payMode === 'none') {
setPayError('Payment is not available for this event yet.');
return;
}
if (isManualCheckout && !screenshotDataUrl) {
setPayError('Upload a payment screenshot to continue.');
return;
}
setBusy(true);
try {
const attendees = [attendee];
const pe = attendee.email;
if (isManualCheckout) {
const res = await publicEventTicketsApi.manualSubmit(eventId, {
ticketId,
quantity: 1,
billingStateCode: billingState || undefined,
purchaserEmail: pe,
sourceFormId: formId,
expectedTotalMinor: quote.totalMinor,
screenshotDataUrl,
buyerNote: buyerNote.trim() || undefined,
attendees,
});
onPendingReview(res.message || 'Submitted for review.');
return;
}
const order = await publicEventTicketsApi.checkout(eventId, {
ticketId,
quantity: 1,
billingStateCode: billingState || undefined,
purchaserEmail: pe,
sourceFormId: formId,
expectedTotalMinor: quote.totalMinor,
idempotencyKey: `reg-${formId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
orgGatewayId: selectedOnlineGatewayId || onlineGateways[0]?.id || undefined,
attendees,
});
await loadRazorpayScript();
type RazorpayResponse = { razorpay_order_id: string; razorpay_payment_id: string; razorpay_signature: string };
type RazorpayConstructor = new (opts: {
key: string;
amount: number;
currency: string;
order_id: string;
name: string;
description: string;
handler: (response: RazorpayResponse) => void;
modal?: { ondismiss?: () => void };
theme?: { color?: string };
prefill?: { email?: string; name?: string };
}) => { open: () => void; on: (ev: string, fn: (r: { error: { description?: string } }) => void) => void };
const Rzp = (window as unknown as { Razorpay: RazorpayConstructor }).Razorpay;
await new Promise<void>((resolve, reject) => {
let invoked = false;
const rzp = new Rzp({
key: order.keyId,
amount: order.amount,
currency: order.currency,
order_id: order.orderId,
name: eventName || 'Event',
description: selected.name,
handler: (response: RazorpayResponse) => {
invoked = true;
void publicEventTicketsApi
.verifyPayment(eventId, {
orderId: response.razorpay_order_id,
paymentId: response.razorpay_payment_id,
signature: response.razorpay_signature,
})
.then(() => resolve())
.catch((e) => reject(e instanceof Error ? e : new Error('Verification failed')));
},
modal: {
ondismiss: () => {
if (!invoked) reject(new Error(PAYMENT_CANCELLED_MESSAGE));
},
},
theme: { color: '#0f172a' },
prefill: {
email: pe,
name: `${attendee.firstName} ${attendee.lastName}`.trim(),
},
});
rzp.on('payment.failed', (response: { error: { description?: string } }) => {
reject(new Error(response.error?.description || 'Payment failed'));
});
rzp.open();
});
onPaid();
} catch (e) {
const msg = e instanceof Error ? e.message : 'Checkout failed';
if (msg !== PAYMENT_CANCELLED_MESSAGE) setPayError(msg);
} finally {
setBusy(false);
}
};
if (!tickets.length) {
return (
<div className="form-panel">
<p className="form-error">No tickets are available for this form.</p>
<button type="button" className="form-submit" onClick={onBack}>
Back
</button>
</div>
);
}
return (
<div className="form-panel form-ticket-step">
<p className="form-ticket-step-label">Step 2 of 2 — Choose your ticket</p>
<div className="form-ticket-list">
{tickets.map((t) => {
const active = t._id === ticketId;
return (
<button
key={t._id}
type="button"
className={`form-ticket-card${active ? ' form-ticket-card--active' : ''}`}
onClick={() => setTicketId(t._id)}
>
<span className="form-ticket-card-name">{t.name}</span>
<span className="form-ticket-card-price">
{formatTicketPrice(t.basePriceMinor, t.currency || 'INR')}
</span>
{t.description?.trim() ? (
<span className="form-ticket-card-desc">{t.description.trim()}</span>
) : null}
</button>
);
})}
</div>
{selected?.requiresBillingState ? (
<label className="form-field">
<span className="form-label">Billing state</span>
<select
className="form-input"
value={billingState}
onChange={(e) => setBillingState(e.target.value)}
>
<option value="">Select state…</option>
{INDIAN_STATES.map((s) => (
<option key={s.code} value={s.code}>
{s.name}
</option>
))}
</select>
</label>
) : null}
{payMode === 'both' ? (
<div className="form-ticket-channels">
<button
type="button"
className={`form-ticket-channel${checkoutChannel === 'online' ? ' form-ticket-channel--active' : ''}`}
onClick={() => setCheckoutChannel('online')}
>
Card / UPI (Razorpay)
</button>
<button
type="button"
className={`form-ticket-channel${checkoutChannel === 'manual' ? ' form-ticket-channel--active' : ''}`}
onClick={() => setCheckoutChannel('manual')}
>
Manual UPI
</button>
</div>
) : null}
{isManualCheckout && manualUpi ? (
<div className="form-ticket-manual">
{manualUpi.upiId ? <p className="form-ticket-upi-id">UPI ID: {manualUpi.upiId}</p> : null}
{manualUpi.instructions ? <p className="form-desc">{manualUpi.instructions}</p> : null}
<div className="form-ticket-qr-wrap">
{manualUpi.manualQrMode === 'dynamic' ? (
dynamicQrDataUrl ? (
<img src={dynamicQrDataUrl} alt="UPI QR" className="form-ticket-qr" />
) : (
<p className="form-desc">Select a ticket to generate the UPI QR with the exact amount.</p>
)
) : manualUpi.upiQrImageUrl ? (
<img src={manualUpi.upiQrImageUrl} alt="UPI QR" className="form-ticket-qr" />
) : null}
</div>
<label className="form-field">
<span className="form-label">Payment screenshot</span>
<input
type="file"
accept="image/*"
className="form-input"
onChange={(e) => onScreenshot(e.target.files?.[0] || null)}
/>
</label>
<label className="form-field">
<span className="form-label">Note (optional)</span>
<input
className="form-input"
value={buyerNote}
onChange={(e) => setBuyerNote(e.target.value)}
placeholder="Transaction reference"
/>
</label>
</div>
) : null}
{quoteError ? <p className="form-error">{quoteError}</p> : null}
{quote ? (
<p className="form-ticket-total">
Total: <strong>{formatTicketPrice(quote.totalMinor, quote.currency)}</strong>
{quote.taxMinor > 0 ? (
<span className="form-ticket-tax"> (incl. tax {formatTicketPrice(quote.taxMinor, quote.currency)})</span>
) : null}
</p>
) : null}
{payError ? <p className="form-error">{payError}</p> : null}
<div className="form-ticket-actions">
<button type="button" className="form-submit form-submit--secondary" onClick={onBack} disabled={busy}>
Back
</button>
<button type="button" className="form-submit" onClick={() => void pay()} disabled={busy || !quote || payMode === 'none'}>
{busy ? 'Processing…' : isManualCheckout ? 'Submit payment proof' : 'Pay & register'}
</button>
</div>
</div>
);
}

View File

@ -1,4 +1,4 @@
import { api } from './client';
import { api, fetchWithAuth } from './client';
export type BadgeBatchStatus = 'pending' | 'processing' | 'ready' | 'failed';
@ -35,6 +35,8 @@ export interface BadgeLayoutElementPayload {
fullWidthCenter?: boolean;
shrinkToFit?: boolean;
textAlign?: 'left' | 'center' | 'right';
prefix?: string;
postfix?: string;
}
export interface BadgeCustomTextPayload {
@ -74,7 +76,11 @@ export interface CreateBadgeBatchPayload {
layout?: BadgeLayoutElementPayload[];
customTexts?: BadgeCustomTextPayload[];
customImages?: BadgeCustomImagePayload[];
outputFormat: 'pdf' | 'png' | 'jpeg';
outputFormat: 'pdf' | 'pdf_per_person' | 'png' | 'jpeg';
/** Rasterization DPI for PNG/JPEG and raster assets (72–600, default 300). */
printDpi?: number;
/** PDF color space: rgb (screen) or cmyk (native DeviceCMYK text/fills). */
colorSpace?: 'rgb' | 'cmyk';
badgeTypeLabel?: string;
}
@ -101,4 +107,51 @@ export const badgeBatchesApi = {
create(payload: CreateBadgeBatchPayload) {
return api.post<BadgeBatchRecord>('/badge-batches', payload);
},
remove(id: string, organization?: string) {
const sp = new URLSearchParams();
if (organization) sp.set('organization', organization);
const q = sp.toString();
return api.delete<{ deleted: boolean }>(`/badge-batches/${id}${q ? `?${q}` : ''}`);
},
/** True when worker saved zip under BADGE_BATCH_LOCAL_DIR (zipS3Key prefix local:). */
isLocalExport(batch: Pick<BadgeBatchRecord, 'zipS3Key' | 'zipUrl'> | null | undefined) {
if (batch?.zipS3Key?.startsWith('local:')) return true;
return Boolean(batch?.zipUrl && /\/api\/badge-batches\/[^/]+\/download/.test(batch.zipUrl));
},
async downloadZip(batch: BadgeBatchRecord, organization?: string) {
if (!this.isLocalExport(batch)) {
if (!batch.zipUrl) throw new Error('No download URL');
window.open(batch.zipUrl, '_blank', 'noopener,noreferrer');
return;
}
const sp = new URLSearchParams();
if (organization) sp.set('organization', organization);
const q = sp.toString();
const res = await fetchWithAuth(`/badge-batches/${batch._id}/download${q ? `?${q}` : ''}`);
if (!res.ok) {
let message = res.statusText || 'Download failed';
try {
const body = (await res.json()) as { error?: { message?: string } };
if (body?.error?.message) message = body.error.message;
} catch {
/* ignore */
}
throw new Error(message);
}
const blob = await res.blob();
const cd = res.headers.get('Content-Disposition') || '';
const match = /filename="?([^"]+)"?/i.exec(cd);
const filename = match?.[1] || `badge-batch-${batch._id}.zip`;
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = objectUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(objectUrl);
},
};

View File

@ -21,17 +21,65 @@ export interface ConciergeBotKnowledge {
lastPlacesSnapshotAt?: string;
}
export type ConciergeFaqCategory =
| 'venue_map'
| 'venue_wayfinding'
| 'venue_amenity'
| 'venue_transport'
| 'venue_parking'
| 'venue_parking_transport'
| 'venue_food'
| 'venue_accessibility'
| 'wifi'
| 'app_account'
| 'checkin_venue'
| 'emergency_contact'
| 'ticket_checkin'
| 'session_recordings'
| 'registration_update'
| 'checkin_timing'
| 'dress_code'
| 'refund_policy';
export type ConciergeFaqAnswerFormat =
| 'mcq'
| 'short_text'
| 'mcq_short_text'
| 'mcq_multi'
| 'contact_details'
| 'media_upload';
export interface ConciergeFaqItem {
key?: string;
category: ConciergeFaqCategory;
question: string;
answerFormat?: ConciergeFaqAnswerFormat;
options?: string[];
answer?: string;
/** Extra free text for short_text / mcq_short_text (e.g. desk location). */
answerDetail?: string;
contactEmail?: string;
contactPhone?: string;
mediaUrl?: string;
mediaName?: string;
mediaType?: string;
selectedOptions?: string[];
}
export interface ConciergeBotRecord {
_id: string;
organization: string;
event: string | { _id: string; name?: string; startDate?: string; endDate?: string };
name?: string;
wabaPhoneNumberId: string;
/** Website widget accent (hex or rgb). */
accentColor?: string;
enabled: boolean;
status: ConciergeBotStatus;
greeting?: ConciergeBotGreeting;
registration?: ConciergeBotRegistration;
knowledge?: ConciergeBotKnowledge;
faqs?: ConciergeFaqItem[];
lastKnowledgeClearedAt?: string;
createdAt?: string;
updatedAt?: string;
@ -110,9 +158,11 @@ export const conciergeBotsApi = {
payload: Partial<{
name: string;
wabaPhoneNumberId: string;
accentColor: string;
greeting: ConciergeBotGreeting;
registration: ConciergeBotRegistration;
knowledge: ConciergeBotKnowledge;
faqs: ConciergeFaqItem[];
enabled: boolean;
}>,
) {

View File

@ -15,6 +15,24 @@ export type TicketingManualPaymentSettings = {
enablementPaidAt: string | null;
};
export type TicketingOnlineGatewayOption = {
id: string;
provider: string;
label: string;
isActive: boolean;
isPrimary: boolean;
testMode: boolean;
ticketCheckoutSupported: boolean;
};
export type TicketingOnlinePaymentSettings = {
configured: boolean;
enabled: boolean;
gatewayIds: string[];
orgGateways: TicketingOnlineGatewayOption[];
activeForCheckout: TicketingOnlineGatewayOption[];
};
export type TicketManualOrderRow = {
_id: string;
status: string;
@ -58,6 +76,26 @@ export const eventTicketingManualApi = {
return r.data.ticketingManualPayment;
},
async getOnline(eventId: string): Promise<TicketingOnlinePaymentSettings> {
const r = await api.get<{ ticketingOnlinePayment: TicketingOnlinePaymentSettings }>(
`/events/${eventId}/ticketing-online-payment`,
);
if (r.error || !r.data?.ticketingOnlinePayment) throw new Error(r.error ?? 'Failed to load online payment settings');
return r.data.ticketingOnlinePayment;
},
async patchOnline(
eventId: string,
payload: { enabled: boolean; gatewayIds: string[] },
): Promise<TicketingOnlinePaymentSettings> {
const r = await api.patch<{ ticketingOnlinePayment: TicketingOnlinePaymentSettings }>(
`/events/${eventId}/ticketing-online-payment`,
payload,
);
if (r.error || !r.data?.ticketingOnlinePayment) throw new Error(r.error ?? 'Failed to save');
return r.data.ticketingOnlinePayment;
},
async checkoutEnablementOrder(eventId: string): Promise<{
keyId: string;
orderId: string;

View File

@ -0,0 +1,79 @@
import { api } from './client';
export type ExhibitorStatus = 'draft' | 'published' | 'hidden';
export type ExhibitorSocialLinks = {
linkedin?: string;
twitter?: string;
instagram?: string;
website?: string;
};
export type ApiExhibitor = {
_id: string;
event: string;
name: string;
status: ExhibitorStatus;
logo?: string;
shortDescription?: string;
description?: string;
category?: string;
boothNumber?: string;
hall?: string;
website?: string;
isFeatured?: boolean;
contactName?: string;
contactEmail?: string;
contactPhone?: string;
socialLinks?: ExhibitorSocialLinks;
products?: string[];
internalNotes?: string;
createdAt?: string;
updatedAt?: string;
};
export type ExhibitorPayload = {
name: string;
status?: ExhibitorStatus;
logo?: string;
shortDescription?: string;
description?: string;
category?: string;
boothNumber?: string;
hall?: string;
website?: string;
isFeatured?: boolean;
contactName?: string;
contactEmail?: string;
contactPhone?: string;
socialLinks?: ExhibitorSocialLinks;
products?: string[];
internalNotes?: string;
};
export const exhibitorsApi = {
list(eventId: string, params?: { q?: string }) {
const sp = new URLSearchParams();
if (params?.q) sp.set('q', params.q);
const q = sp.toString();
return api.get<{ exhibitors: ApiExhibitor[]; total: number }>(
`/events/${eventId}/exhibitors${q ? `?${q}` : ''}`,
);
},
get(id: string) {
return api.get<ApiExhibitor>(`/exhibitors/${id}`);
},
create(eventId: string, payload: ExhibitorPayload) {
return api.post<ApiExhibitor>(`/events/${eventId}/exhibitors`, payload);
},
update(id: string, payload: Partial<ExhibitorPayload> & { name?: string }) {
return api.put<ApiExhibitor>(`/exhibitors/${id}`, payload);
},
remove(id: string) {
return api.delete<null>(`/exhibitors/${id}`);
},
};

View File

@ -39,15 +39,31 @@ export {
eventTicketingManualApi,
ticketManualOrdersApi,
type TicketingManualPaymentSettings,
type TicketingOnlinePaymentSettings,
type TicketingOnlineGatewayOption,
type TicketManualOrderRow,
type PatchTicketingManualPayload,
} from './eventTicketingManual';
export { speakersApi } from './speakers';
export {
exhibitorsApi,
type ApiExhibitor,
type ExhibitorPayload,
type ExhibitorStatus,
type ExhibitorSocialLinks,
} from './exhibitors';
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, type SessionTopic } from './sessions';
export {
sessionsApi,
type ApiSession,
type ApiSessionKeyPerson,
type SessionPayload,
type SessionKeyPersonPayload,
type SessionTopic,
} from './sessions';
export { eventStaffApi, type EventStaffMember, type AgendaTrack } from './eventStaff';
export { quizPollsApi, type QuizPoll, type QuizPollPayload, type QuizPollQuestion } from './quizPolls';
export {
@ -73,6 +89,8 @@ export { whatsappTemplatesApi } from './whatsappTemplates';
export {
conciergeBotsApi,
type ConciergeBotRecord,
type ConciergeFaqCategory,
type ConciergeFaqItem,
type KnowledgeSourceRecord,
type ContextChunkRecord,
} from './conciergeBots';

View File

@ -44,6 +44,7 @@ export const organizationApi = {
hasPassword: boolean;
fromName: string;
fromAddress: string;
replyTo: string;
}>('/organization/email-settings');
},
@ -56,6 +57,7 @@ export const organizationApi = {
password?: string;
fromName?: string;
fromAddress?: string;
replyTo?: string;
}) {
return api.put<{
enabled: boolean;
@ -67,6 +69,7 @@ export const organizationApi = {
hasPassword: boolean;
fromName: string;
fromAddress: string;
replyTo: string;
}>('/organization/email-settings', payload);
},

View File

@ -185,13 +185,39 @@ export const participantsApi = {
return { ok: true as const };
},
importCsv(eventId: string, csv: string) {
importCsv(
eventId: string,
payload: {
csv: string;
mode?:
| 'NEW_INSERT'
| 'UPSERT'
| 'UPDATE'
| 'REPLACE'
| 'TAGS_ONLY'
| 'STATUS_UPDATE'
| 'REMOVE'
| 'SYNC';
confirmSync?: boolean;
membershipStatus?: 'pending' | 'confirmed' | 'rejected';
tags?: string[];
listAction?: 'none' | 'create' | 'add_existing';
listName?: string;
listDescription?: string;
existingListId?: string;
}
) {
return api.post<{
created: number;
updated: number;
removed: number;
skipped: number;
skippedDuplicate: number;
failed: number;
errors: { line: number; message: string }[];
}>(`/events/${eventId}/participants/import`, { csv });
listId?: string;
listLinked?: number;
}>(`/events/${eventId}/participants/import`, payload);
},
async downloadExportAll(

View File

@ -62,6 +62,15 @@ export type TicketQuote = {
export type PublicCheckoutOptions = {
onlineAvailable: boolean;
onlineGateways?: Array<{
id: string;
provider: string;
label: string;
isActive: boolean;
isPrimary: boolean;
testMode: boolean;
ticketCheckoutSupported: boolean;
}>;
manualUpi: {
manualQrMode: 'upload' | 'dynamic';
eventName: string;
@ -155,6 +164,7 @@ export const publicEventTicketsApi = {
quantity: number;
billingStateCode?: string;
purchaserEmail: string;
sourceFormId?: string;
attendees: Array<{
firstName: string;
lastName: string;
@ -167,6 +177,7 @@ export const publicEventTicketsApi = {
}>;
expectedTotalMinor: number;
idempotencyKey?: string;
orgGatewayId?: string;
},
): Promise<CheckoutOrderResponse> {
const res = await fetch(url(`/public/events/${eventId}/tickets/checkout`), {
@ -199,6 +210,7 @@ export const publicEventTicketsApi = {
quantity: number;
billingStateCode?: string;
purchaserEmail: string;
sourceFormId?: string;
attendees: Array<{
firstName: string;
lastName: string;

View File

@ -1,6 +1,11 @@
import { api } from './client';
import type { ApiTrack } from './tracks';
export interface ApiSessionKeyPerson {
speaker: string | { _id: string; firstName?: string; lastName?: string; title?: string; email?: string };
role?: string;
}
export interface ApiSession {
_id: string;
event: string;
@ -14,6 +19,8 @@ export interface ApiSession {
endTime: string;
location?: string;
speakers?: (string | { _id: string; firstName?: string; lastName?: string })[];
/** Per-session key persons with roles (preferred over flat speakers for edit UI). */
keyPersons?: ApiSessionKeyPerson[];
moderator?: string | null;
capacity?: number;
status: string;
@ -32,6 +39,8 @@ export interface ApiSession {
enableQnA?: boolean;
/** When false, hide Quiz & Polls tab on activity detail (default true for normal events). */
enableQnP?: boolean;
audienceFeedbackForm?: string | null;
audienceRatingsForm?: string | null;
/** Nested session topics for hierarchical programmes */
topics?: SessionTopic[];
}
@ -43,6 +52,11 @@ export interface SessionTopic {
role?: string;
}
export type SessionKeyPersonPayload = {
speaker: string;
role: string;
};
export type SessionPayload = {
title: string;
description?: string;
@ -52,6 +66,7 @@ export type SessionPayload = {
location?: string;
track?: string | null;
speakers?: string[];
keyPersons?: SessionKeyPersonPayload[];
moderator?: string | null;
capacity?: number;
status?: string;

View File

@ -285,13 +285,25 @@ export interface EventMicrosite {
eventName?: string | null;
}
/** SPA microsite theme presets (`website.theme` on public microsite payload). */
export type WebsiteThemeMode = 'simple' | 'gradient';
/** SPA microsite theme presets (`website.theme` on public microsite / defaultsite payload). */
export type WebsiteThemeMode = 'simple' | 'gradient' | 'pattern';
/** SVG tile pattern names when theme === 'pattern'. */
export type WebsiteThemePattern = 'dots' | 'grid' | 'zigzag' | 'diagonal' | 'hexa' | 'plus';
export interface WebsiteThemeConfig {
theme: WebsiteThemeMode;
color: string;
/** Required for theme === 'pattern' */
pattern?: WebsiteThemePattern | string | null;
font?: string | null;
/** Optional palette overrides → --theme-* CSS vars on the public site */
primaryColor?: string | null;
secondaryColor?: string | null;
headerColor?: string | null;
buttonColor?: string | null;
textColor?: string | null;
textHighlightColor?: string | null;
}
export interface EventWebsiteSettings {
@ -314,7 +326,7 @@ 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 */
/** Luma-style theme: simple/gradient/pattern + color + font + optional palette overrides */
theme?: WebsiteThemeConfig | null;
}
@ -364,8 +376,9 @@ export interface Event {
organization?: string;
createdAt?: string;
updatedAt?: string;
/** Public code for https://eventbuzz.live/code/{joinCode} (event hub); assigned on save. */
/** Public code for https://join.eventbuzz.live/code/{joinCode} (event hub); assigned on save. */
joinCode?: string;
website?: EventWebsiteSettings;
settings?: Record<string, unknown>;
metadata?: { totalRevenue?: number; rating?: number; ratingCount?: number };
@ -578,6 +591,7 @@ export interface FormSettings {
allowAnonymous?: boolean;
paymentEnabled?: boolean;
paymentAmount?: string | number;
ticketIds?: string[];
sendConfirmationEmail?: boolean;
/** Emailer DesignTemplate id used for registration confirmation. */
confirmationEmailTemplateId?: string | null;
@ -595,6 +609,7 @@ export interface FormSettings {
duplicatePhoneMessage?: string;
eventHeader?: {
showLogo?: boolean;
showBanner?: boolean;
showTitle?: boolean;
showDateRange?: boolean;
showEventType?: boolean;
@ -627,6 +642,7 @@ export interface FormWithSubmissionCount extends Form {
/** Public event context on GET /api/public/forms/:id (from linked event, safe fields only). */
export interface PublicFormEventSummary {
_id?: string;
name: string;
subtitle?: string;
eventType?: string;
@ -639,6 +655,17 @@ export interface PublicFormEventSummary {
}
/** Safe shape from GET /api/public/forms/:id (no auth). */
export interface PublicFormTicketOption {
_id: string;
name: string;
description?: string;
keyPoints?: string[];
basePriceMinor: number;
currency: string;
maxPerOrder?: number | null;
requiresBillingState?: boolean;
}
export interface PublicFormDefinition {
_id: string;
name: string;
@ -646,11 +673,14 @@ export interface PublicFormDefinition {
type: string;
event?: PublicFormEventSummary | null;
fields?: FormField[];
tickets?: PublicFormTicketOption[];
settings?: {
thankYouMessage?: string;
showThankYou?: boolean;
collectEmail?: boolean;
allowAnonymous?: boolean;
paymentEnabled?: boolean;
ticketIds?: string[];
formClosedMessage?: string;
submitErrorMessage?: string;
captchaFailedMessage?: string;
@ -781,9 +811,29 @@ export interface CreateContentCollectionPayload {
/** PUT /content-collections/:id — at least one field (Joi .min(1)). */
export type UpdateContentCollectionPayload = Partial<CreateContentCollectionPayload>;
export type AttendanceAudienceMode = 'all' | 'statuses' | 'tags' | 'participant_lists';
export type AttendanceMembershipStatus =
| 'pending'
| 'confirmed'
| 'checked_in'
| 'cancelled'
| 'rejected';
export interface AttendanceAudienceSettings {
mode?: AttendanceAudienceMode;
/** Used when mode === 'statuses' */
statuses?: AttendanceMembershipStatus[];
/** Used when mode === 'tags' */
tags?: string[];
/** Used when mode === 'participant_lists' */
participantListIds?: string[];
}
export interface AttendanceSettings {
allowDuplicateCheckIn?: boolean;
publicLinkEnabled?: boolean;
audience?: AttendanceAudienceSettings;
}
export interface AttendanceRecord {

View File

@ -0,0 +1,7 @@
@font-face {
font-family: "Swiss921 BT";
src: url("/fonts/Swiss921BT.ttf") format("truetype");
font-weight: 400;
font-style: normal;
font-display: swap;
}

View File

@ -85,10 +85,12 @@
--radius-card: 8px;
/* The border radius for buttons and form controls. */
--radius-button: 4px;
/* Brand orange for Lucide / UI icons (wordmark buzz color). */
--icon: #EF8D1D;
/* The background color for sidebars, navigation menus, and other persistent elements. */
--sidebar: rgba(255, 255, 255, 1.00);
/* The default color for text or elements that appear on top of sidebars, navigation menus, and other persistent elements. */
--sidebar-foreground: rgba(15, 48, 114, 1.00);
--sidebar-foreground: #484B57;
/* The primary color used for buttons, links, and other interactive elements in sidebars and navigation menus. */
/* WCAG AA: #0B4DD1 on white sidebar = 7.00:1 ✓ */
--sidebar-primary: rgba(11, 77, 209, 1.00);
@ -97,7 +99,7 @@
/* The accent color used for highlights, links, and other interactive elements in sidebars and navigation menus. */
--sidebar-accent: rgba(229, 236, 251, 1.00);
/* The default color for text or elements that appear on top of accent colored elements in sidebars and navigation menus. */
--sidebar-accent-foreground: rgba(13, 62, 161, 1.00);
--sidebar-accent-foreground: #484B57;
/* The default border color for elements in sidebars and navigation menus. */
--sidebar-border: rgba(216, 222, 228, 1.00);
/* The default color for focus rings, outlines, and other focus indicators in sidebars and navigation menus. */
@ -140,6 +142,8 @@
--success-fg: rgba(74, 222, 128, 1.00);
--success-fg-label: rgba(134, 239, 172, 1.00);
--elevation-sm: 0px 4px 12px 0px rgba(0, 0, 0, 0.25);
/* Same brand orange in dark mode for icon consistency. */
--icon: #EF8D1D;
--sidebar: rgba(30, 41, 59, 1.00);
--sidebar-foreground: rgba(248, 250, 252, 1.00);
/* WCAG AA: #60A5FA on dark sidebar-accent (#334155) = 4.05:1 ✓ (was #3B82F6 = 2.81:1 ❌) */
@ -190,6 +194,7 @@
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-icon: var(--icon);
--shadow-sm: var(--elevation-sm);
--shadow-md: 0px 6px 16px 0px rgba(33, 38, 44, 0.20);
--shadow-lg: 0px 8px 24px 0px rgba(33, 38, 44, 0.24);
@ -286,4 +291,32 @@
html {
font-size: var(--font-size);
}
/**
* Lucide React icons use currentColor — force brand orange app-wide.
* Overrides per-icon muted/primary inline colors so icons stay consistent.
*/
svg.lucide {
color: var(--icon) !important;
}
/**
* Icon-in-box: when a container's only child is a Lucide icon, match its
* border to the icon color (no-op when the element has no border).
* Hover fills the box orange with a white icon.
*/
:is(button, a, span, div, label):has(> svg.lucide:only-child) {
border-color: var(--icon) !important;
transition: background-color 0.12s, border-color 0.12s, color 0.12s;
}
:is(button, a):has(> svg.lucide:only-child):not(:disabled):hover {
background-color: var(--icon) !important;
border-color: var(--icon) !important;
color: #fff !important;
}
:is(button, a):has(> svg.lucide:only-child):not(:disabled):hover > svg.lucide {
color: #fff !important;
}

View File

@ -0,0 +1,102 @@
export type TicketAttendeePayload = {
firstName: string;
lastName: string;
email: string;
phone?: string;
organization?: string;
designation?: string;
displayName?: string;
formData?: Record<string, unknown>;
};
function fieldKey(f: { name?: string; label?: string }): string {
return String(f.name || f.label || '').trim();
}
function mappedValue(
fields: Array<{ name?: string; label?: string; mapsTo?: string }>,
values: Record<string, unknown>,
mapsTo: string,
): string {
const f = fields.find((x) => x.mapsTo === mapsTo);
if (!f) return '';
const raw = values[fieldKey(f)];
return raw != null ? String(raw).trim() : '';
}
/** Build ticket checkout attendee + formData from registration form answers. */
export function buildTicketAttendeeFromForm(
fields: Array<{ name?: string; label?: string; mapsTo?: string }>,
values: Record<string, unknown>,
submitData: Record<string, unknown>,
): TicketAttendeePayload {
return {
firstName: mappedValue(fields, values, 'firstName') || mappedValue(fields, submitData, 'firstName'),
lastName: mappedValue(fields, values, 'lastName') || mappedValue(fields, submitData, 'lastName'),
email: (
mappedValue(fields, values, 'email') || mappedValue(fields, submitData, 'email')
).toLowerCase(),
phone: mappedValue(fields, values, 'phone') || mappedValue(fields, submitData, 'phone') || undefined,
organization:
mappedValue(fields, values, 'organization')
|| mappedValue(fields, submitData, 'organization')
|| undefined,
designation:
mappedValue(fields, values, 'designation')
|| mappedValue(fields, submitData, 'designation')
|| undefined,
displayName:
mappedValue(fields, values, 'displayName')
|| mappedValue(fields, submitData, 'displayName')
|| undefined,
formData: submitData,
};
}
export function formatTicketPrice(minor: number, currency = 'INR'): string {
const major = (Number(minor) || 0) / 100;
try {
return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(major);
} catch {
return `${currency} ${major.toFixed(2)}`;
}
}
export const INDIAN_STATES = [
{ code: 'AN', name: 'Andaman and Nicobar Islands' },
{ code: 'AP', name: 'Andhra Pradesh' },
{ code: 'AR', name: 'Arunachal Pradesh' },
{ code: 'AS', name: 'Assam' },
{ code: 'BR', name: 'Bihar' },
{ code: 'CH', name: 'Chandigarh' },
{ code: 'CT', name: 'Chhattisgarh' },
{ code: 'DN', name: 'Dadra and Nagar Haveli and Daman and Diu' },
{ code: 'DL', name: 'Delhi' },
{ code: 'GA', name: 'Goa' },
{ code: 'GJ', name: 'Gujarat' },
{ code: 'HR', name: 'Haryana' },
{ code: 'HP', name: 'Himachal Pradesh' },
{ code: 'JK', name: 'Jammu and Kashmir' },
{ code: 'JH', name: 'Jharkhand' },
{ code: 'KA', name: 'Karnataka' },
{ code: 'KL', name: 'Kerala' },
{ code: 'LA', name: 'Ladakh' },
{ code: 'LD', name: 'Lakshadweep' },
{ code: 'MP', name: 'Madhya Pradesh' },
{ code: 'MH', name: 'Maharashtra' },
{ code: 'MN', name: 'Manipur' },
{ code: 'ML', name: 'Meghalaya' },
{ code: 'MZ', name: 'Mizoram' },
{ code: 'NL', name: 'Nagaland' },
{ code: 'OR', name: 'Odisha' },
{ code: 'PY', name: 'Puducherry' },
{ code: 'PB', name: 'Punjab' },
{ code: 'RJ', name: 'Rajasthan' },
{ code: 'SK', name: 'Sikkim' },
{ code: 'TN', name: 'Tamil Nadu' },
{ code: 'TG', name: 'Telangana' },
{ code: 'TR', name: 'Tripura' },
{ code: 'UP', name: 'Uttar Pradesh' },
{ code: 'UT', name: 'Uttarakhand' },
{ code: 'WB', name: 'West Bengal' },
];

View File

@ -137,8 +137,11 @@ export function EventTicketCheckoutView() {
const [payBusy, setPayBusy] = useState(false);
const [done, setDone] = useState(false);
const [payMode, setPayMode] = useState<"loading" | "online" | "manual" | "both" | "none">("loading");
/** When payMode is "both", whether the buyer uses Razorpay or manual UPI for this order. */
const [checkoutChannel, setCheckoutChannel] = useState<"online" | "manual">("online");
const [onlineGateways, setOnlineGateways] = useState<
NonNullable<Awaited<ReturnType<typeof publicEventTicketsApi.checkoutOptions>>["onlineGateways"]>
>([]);
const [selectedOnlineGatewayId, setSelectedOnlineGatewayId] = useState<string>("");
const [manualUpi, setManualUpi] = useState<PublicCheckoutOptions["manualUpi"]>(null);
const isManualCheckout = useMemo(() => {
@ -174,6 +177,9 @@ export function EventTicketCheckoutView() {
if (cancelled) return;
setSummary(s);
setTickets(tix);
const gateways = opts.onlineGateways || [];
setOnlineGateways(gateways);
setSelectedOnlineGatewayId(gateways[0]?.id || "");
if (opts.onlineAvailable && opts.manualUpi) {
setPayMode("both");
setCheckoutChannel("online");
@ -366,6 +372,7 @@ export function EventTicketCheckoutView() {
purchaserEmail: pe,
expectedTotalMinor: quoteTotal,
idempotencyKey,
orgGatewayId: selectedOnlineGatewayId || onlineGateways[0]?.id || undefined,
attendees: attendees.map((a) => ({
firstName: a.firstName.trim(),
lastName: a.lastName.trim(),
@ -700,8 +707,27 @@ export function EventTicketCheckoutView() {
</div>
</>
: <p style={{ fontSize: "0.9375rem", color: T.body, margin: 0, lineHeight: 1.55, fontWeight: 500 }}>
You will pay through the organiser&apos;s connected Razorpay account (not a platform wallet).
You will pay through the organiser&apos;s connected payment gateway.
</p>}
{!isManualCheckout && onlineGateways.length > 1 ? (
<div style={{ marginTop: 16, display: "flex", flexDirection: "column", gap: 8 }}>
<span style={{ fontSize: "0.7rem", fontWeight: 600, letterSpacing: "0.22em", textTransform: "uppercase", color: T.accent }}>
Gateway
</span>
{onlineGateways.map((g) => (
<label key={g.id} style={{ display: "flex", alignItems: "center", gap: 8, fontSize: "0.9rem", color: T.ink }}>
<input
type="radio"
name="eb-online-gateway"
checked={selectedOnlineGatewayId === g.id}
onChange={() => setSelectedOnlineGatewayId(g.id)}
/>
{g.label || g.provider}
{g.testMode ? " (test)" : ""}
</label>
))}
</div>
) : null}
</section>
)}

View File

@ -7,6 +7,8 @@ import { assignUniqueFieldNames, fieldDataKey, fieldDisplayLabel, countryStateKe
import { SelectWithOtherInput } from "../app/components/forms/SelectWithOtherInput";
import { ensureRegistrationCoreFields } from "../app/utils/registrationFormFields";
import { COUNTRY_NAMES, DEFAULT_COUNTRY, statesForCountry } from "../app/data/countryStates";
import { RegistrationTicketStep } from "../components/forms/RegistrationTicketStep";
import { buildTicketAttendeeFromForm } from "../utils/registrationTicketAttendee";
const EVENT_TYPE_LABEL: Record<string, string> = {
conference: "Conference",
@ -58,6 +60,7 @@ function PublicFormEventHeader({
}) {
const d = {
showLogo: display?.showLogo !== false,
showBanner: display?.showBanner === true,
showTitle: display?.showTitle !== false,
showDateRange: display?.showDateRange !== false,
showEventType: display?.showEventType !== false,
@ -69,7 +72,7 @@ function PublicFormEventHeader({
const kind = formatLocationKind(event.locationType);
const showLoc = d.showVenue && shouldShowLocation(event);
const embedded = variant === "embedded";
const showBanner = d.showLogo && !!event.imageUrl && !event.logoUrl;
const showBanner = d.showBanner && !!event.imageUrl;
const showLogo = d.showLogo && !!event.logoUrl;
const showTitle = d.showTitle && !!event.name;
const showSubtitle = d.showTitle && !!event.subtitle?.trim();
@ -132,33 +135,44 @@ function PublicFormEventHeader({
marginBottom: embedded ? 18 : 28,
paddingBottom: embedded ? 0 : 22,
borderBottom: embedded ? "none" : "1px solid var(--border, #e2e8f0)",
textAlign: "center",
}}
>
{showLogo ? (
<img
src={event.logoUrl}
alt={event.name}
style={{
display: "block",
maxHeight: 96,
maxWidth: "100%",
width: "auto",
height: "auto",
objectFit: "contain",
margin: "0 auto 16px",
}}
/>
) : null}
{showBanner ? (
<div style={{ marginBottom: 16, borderRadius: 10, overflow: "hidden", maxHeight: 160 }}>
<div
style={{
width: "100%",
marginBottom: 16,
borderRadius: 10,
overflow: "hidden",
maxHeight: 180,
lineHeight: 0,
}}
>
<img
src={event.imageUrl}
alt=""
style={{ width: "100%", height: "100%", maxHeight: 160, objectFit: "cover", display: "block" }}
style={{ width: "100%", height: "auto", maxHeight: 180, objectFit: "cover", display: "block" }}
/>
</div>
) : null}
<div style={{ display: "flex", alignItems: "flex-start", gap: 16, flexWrap: "wrap" }}>
{showLogo ? (
<img
src={event.logoUrl}
alt={event.name}
style={{
maxHeight: 64,
maxWidth: 220,
width: "auto",
height: "auto",
objectFit: "contain",
flexShrink: 0,
}}
/>
) : null}
<div style={{ minWidth: 0, flex: "1 1 200px" }}>
{showTitle || showSubtitle || metaItems.length > 0 ? (
<div>
{showTitle ? (
<h1
style={{
@ -191,6 +205,7 @@ function PublicFormEventHeader({
display: "flex",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "center",
gap: "6px 10px",
fontSize: "0.8125rem",
color: "var(--muted, #64748b)",
@ -210,7 +225,7 @@ function PublicFormEventHeader({
</div>
) : null}
</div>
</div>
) : null}
</div>
);
}
@ -271,6 +286,11 @@ export function PublicFormView() {
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [done, setDone] = useState(false);
const [doneMessage, setDoneMessage] = useState<string | null>(null);
const [step, setStep] = useState<1 | 2>(1);
const ticketsEnabled = !!(form?.settings?.paymentEnabled && (form.tickets?.length || 0) > 0);
const eventId = form?.event?._id ? String(form.event._id) : "";
const load = useCallback(async () => {
if (!formId) return;
@ -350,6 +370,16 @@ export function PublicFormView() {
if (field.type === "section") continue;
Object.assign(data, fieldValueToData(field, values));
}
if (ticketsEnabled) {
if (!eventId) {
setSubmitError("This form is missing event context required for ticket payment.");
return;
}
setStep(2);
return;
}
setSubmitting(true);
try {
await formsApi.submit(formId, { data });
@ -403,7 +433,10 @@ export function PublicFormView() {
if (done) {
const showThanks = form.settings?.showThankYou !== false;
const msg = form.settings?.thankYouMessage?.trim() || "Thank you for your response.";
const msg =
doneMessage
|| form.settings?.thankYouMessage?.trim()
|| "Thank you for your response.";
const ev = form.event;
return (
<div
@ -515,6 +548,35 @@ export function PublicFormView() {
<div style={{ marginBottom: 24 }} />
)}
{step === 2 && ticketsEnabled && eventId ? (
<RegistrationTicketStep
eventId={eventId}
formId={formId}
eventName={ev?.name}
tickets={form.tickets || []}
attendee={buildTicketAttendeeFromForm(
form.fields || [],
values,
(() => {
const data: Record<string, unknown> = {};
for (const field of form.fields || []) {
if (field.type === "section") continue;
Object.assign(data, fieldValueToData(field, values));
}
return data;
})(),
)}
onBack={() => setStep(1)}
onPaid={() => {
setDoneMessage(null);
setDone(true);
}}
onPendingReview={(message) => {
setDoneMessage(message);
setDone(true);
}}
/>
) : (
<form
onSubmit={(e) => void onSubmit(e)}
style={{
@ -528,6 +590,11 @@ export function PublicFormView() {
boxShadow: "0 1px 3px rgba(0,0,0,0.06)",
}}
>
{ticketsEnabled ? (
<p style={{ margin: 0, fontSize: "0.8125rem", fontWeight: 600, color: "#64748b", textTransform: "uppercase", letterSpacing: "0.04em" }}>
Step 1 of 2 — Your details
</p>
) : null}
{(form.fields || []).map((field, index) => {
if (field.type === "section") {
const title = (field.label || "").trim();
@ -903,9 +970,10 @@ export function PublicFormView() {
opacity: submitting ? 0.75 : 1,
}}
>
{submitting ? "Submitting…" : "Submit"}
{submitting ? "Submitting…" : ticketsEnabled ? "Continue" : "Submit"}
</button>
</form>
)}
</div>
</div>
);

2
src/vite-env.d.ts vendored
View File

@ -3,6 +3,8 @@
/** App-specific `VITE_*` variables (merges with Vite’s ImportMetaEnv) */
interface ImportMetaEnv {
readonly VITE_API_URL?: string;
/** Concierge public API base for website embed (ends with /api). */
readonly VITE_CONCIERGE_URL?: string;
readonly VITE_GOOGLE_CLIENT_ID?: string;
}