From 24f8edc05dc73123393667d923cb0b68d13d05eb Mon Sep 17 00:00:00 2001 From: Ravi Gulhane <> Date: Thu, 6 Aug 2026 13:05:03 +0530 Subject: [PATCH] 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 --- .env.example | 9 + dist/index.html | 4 +- public/fonts/Swiss921BT.ttf | Bin 0 -> 38964 bytes src/app/components/EventBuzzBrandLogo.tsx | 47 ++ src/app/components/NavBar.tsx | 14 +- src/app/components/Sidebar.tsx | 21 +- src/app/components/TopBar.tsx | 10 +- src/app/components/agenda/AgendaPage.tsx | 28 + .../components/agenda/AgendaSessionPanel.tsx | 132 ++++- src/app/components/agenda/eventBuzzCode.ts | 6 +- .../components/attendance/AttendancePage.tsx | 300 +++++++++- src/app/components/auth/AuthPages.tsx | 91 +--- .../brochure/brochurePreviewData.ts | 5 +- .../components/dashboard/EventBadgesPanel.tsx | 34 +- .../dashboard/EventExhibitorsPanel.tsx | 367 +++++++++++++ .../components/dashboard/EventHomeModules.tsx | 133 ++++- src/app/components/global/GlobalStubPage.tsx | 50 ++ src/app/components/global/SettingsPage.tsx | 41 +- .../components/messaging/mergePlaceholders.ts | 3 +- .../components/panels/AddExhibitorPanel.tsx | 513 ++++++++++++++++++ .../panels/BadgeDesignerPreview.tsx | 65 ++- .../components/panels/BadgeFieldInspector.tsx | 24 + .../components/panels/CreateBadgePanel.tsx | 129 ++++- .../panels/CreateEventSessionPanel.tsx | 119 +++- src/app/components/panels/CreateFormPanel.tsx | 210 +++++-- .../panels/ImportEventParticipantsPanel.tsx | 413 ++++++++++++++ .../panels/WebsiteThemeSettingsPanel.tsx | 404 ++++++++++++-- .../components/panels/badgeBatchFormState.ts | 13 +- src/app/components/panels/badgeLayout.ts | 43 +- .../participants/ParticipantsPage.tsx | 206 ++----- .../ticketing/EventTicketingPage.tsx | 186 ++++++- .../whatsapp-chatbot/WhatsAppChatbotsPage.tsx | 359 +++++++++++- src/app/constants/keyPersonRoles.ts | 49 ++ src/app/utils/emailerPreview.ts | 4 + src/app/utils/formEventHeaderDisplay.ts | 7 +- src/app/utils/sessionFlags.ts | 14 + src/assets/logo-small.png | Bin 0 -> 8623 bytes src/assets/logo.png | Bin 0 -> 16365 bytes .../forms/RegistrationTicketStep.css | 188 +++++++ .../forms/RegistrationTicketStep.tsx | 417 ++++++++++++++ src/services/api/badgeBatches.ts | 57 +- src/services/api/conciergeBots.ts | 50 ++ src/services/api/eventTicketingManual.ts | 38 ++ src/services/api/exhibitors.ts | 79 +++ src/services/api/index.ts | 20 +- src/services/api/organization.ts | 3 + src/services/api/participants.ts | 30 +- src/services/api/publicEventTickets.ts | 12 + src/services/api/sessions.ts | 15 + src/services/api/types.ts | 58 +- src/styles/fonts.css | 7 + src/styles/theme.css | 37 +- src/utils/registrationTicketAttendee.ts | 102 ++++ src/views/EventTicketCheckoutView.tsx | 30 +- src/views/PublicFormView.tsx | 112 +++- src/vite-env.d.ts | 2 + 56 files changed, 4786 insertions(+), 524 deletions(-) create mode 100644 public/fonts/Swiss921BT.ttf create mode 100644 src/app/components/EventBuzzBrandLogo.tsx create mode 100644 src/app/components/dashboard/EventExhibitorsPanel.tsx create mode 100644 src/app/components/panels/AddExhibitorPanel.tsx create mode 100644 src/app/components/panels/ImportEventParticipantsPanel.tsx create mode 100644 src/app/constants/keyPersonRoles.ts create mode 100644 src/assets/logo-small.png create mode 100644 src/assets/logo.png create mode 100644 src/components/forms/RegistrationTicketStep.css create mode 100644 src/components/forms/RegistrationTicketStep.tsx create mode 100644 src/services/api/exhibitors.ts create mode 100644 src/utils/registrationTicketAttendee.ts diff --git a/.env.example b/.env.example index d402b116..f0c162ea 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/dist/index.html b/dist/index.html index be891495..2b772bc4 100644 --- a/dist/index.html +++ b/dist/index.html @@ -12,8 +12,8 @@ rel="stylesheet" /> - - + + diff --git a/public/fonts/Swiss921BT.ttf b/public/fonts/Swiss921BT.ttf new file mode 100644 index 0000000000000000000000000000000000000000..b7fe5f51cbf6ec2bd81a4a6a53b2db0555345191 GIT binary patch literal 38964 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 C>*sI) literal 0 HcmV?d00001 diff --git a/src/app/components/EventBuzzBrandLogo.tsx b/src/app/components/EventBuzzBrandLogo.tsx new file mode 100644 index 00000000..b4348076 --- /dev/null +++ b/src/app/components/EventBuzzBrandLogo.tsx @@ -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 ( + {alt} + ); +} + +export { logoUrl as eventBuzzLogoUrl, logoSmallUrl as eventBuzzLogoSmallUrl }; diff --git a/src/app/components/NavBar.tsx b/src/app/components/NavBar.tsx index 1e9f3e9b..b4f33a2f 100644 --- a/src/app/components/NavBar.tsx +++ b/src/app/components/NavBar.tsx @@ -38,6 +38,9 @@ const EVENT_TABS: EventTab[] = [ { id: "analytics", label: "Analytics", shortLabel: "Analytics", icon: }, ]; +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(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; } }} > - + {tab.icon} {/* On mobile show short labels */} diff --git a/src/app/components/Sidebar.tsx b/src/app/components/Sidebar.tsx index 47be3169..1dedc269 100644 --- a/src/app/components/Sidebar.tsx +++ b/src/app/components/Sidebar.tsx @@ -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 && } - + {child.label} @@ -686,12 +687,7 @@ export function Sidebar({ }}> {/* Header */}

-
-
- EB -
- EventBuzz -
+
- )} + {isMobile && } {/* ── Desktop search with dropdown ── */} {!isMobile && ( diff --git a/src/app/components/agenda/AgendaPage.tsx b/src/app/components/agenda/AgendaPage.tsx index 77ef103e..1f85c39b 100644 --- a/src/app/components/agenda/AgendaPage.tsx +++ b/src/app/components/agenda/AgendaPage.tsx @@ -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, diff --git a/src/app/components/agenda/AgendaSessionPanel.tsx b/src/app/components/agenda/AgendaSessionPanel.tsx index a8c7025b..180eb524 100644 --- a/src/app/components/agenda/AgendaSessionPanel.tsx +++ b/src/app/components/agenda/AgendaSessionPanel.tsx @@ -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(); + 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([]); const [speakersLoading, setSpeakersLoading] = useState(true); - const [selectedSpeakerIds, setSelectedSpeakerIds] = useState(() => extractSpeakerIds(session)); + const [keyPersons, setKeyPersons] = useState(() => 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(DEFAULT_KEY_PERSON_ROLE); const [nsErr, setNsErr] = useState({}); 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 && (
- +

- Select from your organization's faculty directory or add someone new. + Select from your organization's faculty directory or add someone new. Assign a role for each person (defaults to Speaker).

- {selectedSpeakerIds.length > 0 && ( -
- {selectedSpeakerIds.map((id) => ( - 0 && ( +
+ {keyPersons.map((row) => ( +
- {speakerLabel(id)} + + {speakerLabel(row.speakerId)} + + - +
))}
)} @@ -984,14 +1048,14 @@ export function AgendaSessionPanel({ if (speakerCatalog.length === 0) { return (

- No speakers in the directory yet. Add one below. + No faculty in the directory yet. Add one below.

); } if (available.length === 0) { return (

- 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.

); } @@ -1060,7 +1124,7 @@ export function AgendaSessionPanel({ color: "var(--primary)", }} > - {showNewSpeaker ? "Hide new speaker form" : "Add new speaker"} + {showNewSpeaker ? "Hide new person form" : "Add new key person"} {showNewSpeaker && (
setNsPhone(e.target.value)} style={IS} />
- + setNsTitle(e.target.value)} placeholder="e.g. Prof. of Medicine" style={IS} />
+
+ + +
+
+ Includes: {audienceSummary(s)} +
{s.status === "archived" && ( Archived )} @@ -383,6 +517,9 @@ export function AttendancePage() { {selected.description}

)} +

+ Includes: {audienceSummary(selected)} +

{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" }} /> + +
+
Who to include in attendance list
+
+ {AUDIENCE_MODE_OPTIONS.map((opt) => ( + + ))} +
+
+ + {form.audienceMode === "statuses" && ( +
+
Statuses
+
+ {MEMBERSHIP_STATUS_OPTIONS.map((opt) => ( + + ))} +
+
+ )} + + {form.audienceMode === "tags" && ( +
+
Tags
+ {eventTags.length === 0 ? ( +

+ No tags found for this event yet. +

+ ) : ( +
+ {eventTags.map((tag) => ( + + ))} +
+ )} +
+ )} + + {form.audienceMode === "participant_lists" && ( +
+
Contact lists
+ {contactLists.length === 0 ? ( +

+ No contact lists available. +

+ ) : ( +
+ {contactLists.map((list) => ( + + ))} +
+ )} +
+ )} +
- ); +function EventBuzzLogo({ scale = 1 }: { variant?: "light" | "color"; scale?: number }) { + return ; } /* ───────────────────────────────────────────────────────────────── diff --git a/src/app/components/brochure/brochurePreviewData.ts b/src/app/components/brochure/brochurePreviewData.ts index c4ae69c9..c540a66b 100644 --- a/src/app/components/brochure/brochurePreviewData.ts +++ b/src/app/components/brochure/brochurePreviewData.ts @@ -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], }; } diff --git a/src/app/components/dashboard/EventBadgesPanel.tsx b/src/app/components/dashboard/EventBadgesPanel.tsx index b786420b..6faee332 100644 --- a/src/app/components/dashboard/EventBadgesPanel.tsx +++ b/src/app/components/dashboard/EventBadgesPanel.tsx @@ -35,6 +35,7 @@ export function EventBadgesPanel({ const [badges, setBadges] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); + const [downloadingId, setDownloadingId] = useState(null); const loadBadges = useCallback(async () => { setLoading(true); @@ -297,10 +298,26 @@ export function EventBadgesPanel({ ) : null} {b.status === "ready" && b.zipUrl ? ( - { + 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 zip - + {" "} + {downloadingId === b._id ? "Downloading…" : "Download zip"} + ) : ( + ) : null} + + + {loading ? ( +

+ Loading exhibitors… +

+ ) : null} + {error ? ( +

+ {error} +

+ ) : null} + + {!loading && !error && filtered.length === 0 ? ( +
+ +

+ {exhibitors.length === 0 ? "No exhibitors yet" : "No matches"} +

+

+ {exhibitors.length === 0 + ? "Add companies exhibiting at this event to build the exhibitor directory." + : "Try another search."} +

+ {canWrite && exhibitors.length === 0 ? ( + + ) : null} +
+ ) : null} + + {!loading && filtered.length > 0 ? ( +
+ {filtered.map((ex) => ( +
+
+ {ex.logo ? ( + + ) : ( +
+ +
+ )} +
+
+

+ {ex.name} + {ex.isFeatured ? ( + + Featured + + ) : null} +

+ + {statusLabel(ex.status)} + +
+

+ {[ + ex.boothNumber ? `Booth ${ex.boothNumber}` : null, + ex.hall || null, + ex.category || null, + ] + .filter(Boolean) + .join(" · ") || "No booth details yet"} +

+
+
+ {canWrite ? ( +
+ + +
+ ) : null} +
+ ))} +
+ ) : null} + + + ); +} diff --git a/src/app/components/dashboard/EventHomeModules.tsx b/src/app/components/dashboard/EventHomeModules.tsx index c0886b2d..9ab248b8 100644 --- a/src/app/components/dashboard/EventHomeModules.tsx +++ b/src/app/components/dashboard/EventHomeModules.tsx @@ -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(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 ? ( + } + title="Exhibitors" + meta={exhibitorMeta} + description="Companies and booths for this event" + actions={ + <> + + + + } + /> + ) : null} {formShareTarget ? ( @@ -1259,6 +1351,43 @@ export function EventHomeModules() { onSuccess={() => void loadEmailCampaignMeta()} /> ) : null} + + {showExhibitorsPanel && selectedEventId ? ( + setShowExhibitorsPanel(false)} + onAdd={() => { + setShowExhibitorsPanel(false); + setEditingExhibitor(null); + setShowAddExhibitor(true); + }} + onEdit={(ex) => { + setShowExhibitorsPanel(false); + setEditingExhibitor(ex); + setShowAddExhibitor(true); + }} + onChanged={() => void loadExhibitorMeta()} + /> + ) : null} + + {showAddExhibitor && selectedEventId ? ( + { + setShowAddExhibitor(false); + setEditingExhibitor(null); + }} + onSuccess={() => { + void loadExhibitorMeta(); + setShowExhibitorsPanel(true); + }} + /> + ) : null} ); } diff --git a/src/app/components/global/GlobalStubPage.tsx b/src/app/components/global/GlobalStubPage.tsx index 87bfe3b1..69475ad1 100644 --- a/src/app/components/global/GlobalStubPage.tsx +++ b/src/app/components/global/GlobalStubPage.tsx @@ -180,6 +180,8 @@ export function DesignPage({ subPage }: { subPage?: string }) { const [showMicrosite, setShowMicrosite] = useState(false); const [showBadge, setShowBadge] = useState(false); const [duplicateBadgeBatchId, setDuplicateBadgeBatchId] = useState(null); + const [badgeDeleteConfirm, setBadgeDeleteConfirm] = useState(null); + const [badgeDeleting, setBadgeDeleting] = useState(false); const [showEmailer, setShowEmailer] = useState(false); const [showImport, setShowImport] = useState(false); const [editTemplateId, setEditTemplateId] = useState(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 }) { Duplicate )} + {canManageBadgeBatch && ( + + )} {b.status === "ready" && b.zipUrl ? ( )} + {badgeDeleteConfirm && ( + { + if (!badgeDeleting) setBadgeDeleteConfirm(null); + }} + variant="danger" + /> + )} ); } diff --git a/src/app/components/global/SettingsPage.tsx b/src/app/components/global/SettingsPage.tsx index 423ee78f..428a03e6 100644 --- a/src/app/components/global/SettingsPage.tsx +++ b/src/app/components/global/SettingsPage.tsx @@ -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 (
- + {err &&

{err}

} {msg &&

{msg}

} @@ -1438,12 +1444,41 @@ function EmailDeliverySection() { - + + diff --git a/src/app/components/messaging/mergePlaceholders.ts b/src/app/components/messaging/mergePlaceholders.ts index 1781d310..a78991f4 100644 --- a/src/app/components/messaging/mergePlaceholders.ts +++ b/src/app/components/messaging/mergePlaceholders.ts @@ -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, })), diff --git a/src/app/components/panels/AddExhibitorPanel.tsx b/src/app/components/panels/AddExhibitorPanel.tsx new file mode 100644 index 00000000..9fc4fed9 --- /dev/null +++ b/src/app/components/panels/AddExhibitorPanel.tsx @@ -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 ( + + ); +} + +function Field({ children, error }: { children: React.ReactNode; error?: string }) { + return ( +
+ {children} + {error ? ( +

+ {error} +

+ ) : null} +
+ ); +} + +function SelectWrap({ children }: { children: React.ReactNode }) { + return ( +
+ {children} + +
+ ); +} + +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(() => fromExhibitor(exhibitor)); + const [errors, setErrors] = useState>({}); + const [saving, setSaving] = useState(false); + const [saveErr, setSaveErr] = useState(null); + const [uploadingLogo, setUploadingLogo] = useState(false); + const [showOps, setShowOps] = useState(Boolean(exhibitor?.internalNotes)); + const logoInputRef = useRef(null); + + const set = (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 = {}; + 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 ( + void submit()} + showProgress={false} + panelWidth="min(640px, 100vw)" + > +
+ {saveErr ? ( +

+ {saveErr} +

+ ) : null} + + + + +
+ + + + +
+ + + + + + + + +