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 00000000..b7fe5f51 Binary files /dev/null and b/public/fonts/Swiss921BT.ttf differ 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} + + + + +
+ + + + +
+ + + + + + + + +