diff --git a/dist/index.html b/dist/index.html index d5b558b3..be891495 100644 --- a/dist/index.html +++ b/dist/index.html @@ -12,8 +12,8 @@ rel="stylesheet" /> - - + + diff --git a/src/app/components/AppShell.tsx b/src/app/components/AppShell.tsx index 4ebe71f9..76d59b41 100644 --- a/src/app/components/AppShell.tsx +++ b/src/app/components/AppShell.tsx @@ -38,6 +38,12 @@ export function AppShell() { } }, [selectedEventId, activeNav, setActiveNav]); + useEffect(() => { + if (activeNav === "dashboard" && selectedEventId) { + setSidebarCollapsed(true); + } + }, [activeNav, selectedEventId, setSidebarCollapsed]); + return (
(null); const [dashLoading, setDashLoading] = useState(false); @@ -197,6 +199,7 @@ export function MainContent() { loading={dashLoading && !!selectedEventId} canEdit={!!selectedEventId} onEditEvent={() => selectedEventId && setEditEventPanelOpen(true)} + onWebsiteSettings={() => selectedEventId && setWebsiteThemePanelOpen(true)} /> {editEventPanelOpen && selectedEventId && ( )} + {websiteThemePanelOpen && selectedEventId && ( + setWebsiteThemePanelOpen(false)} + onSuccess={() => { + void loadDashboardData(); + }} + /> + )} diff --git a/src/app/components/NavBar.tsx b/src/app/components/NavBar.tsx index 49ca05c0..1e9f3e9b 100644 --- a/src/app/components/NavBar.tsx +++ b/src/app/components/NavBar.tsx @@ -11,7 +11,7 @@ const EVENT_TAB_MODULES: Record = { dashboard: "events", agenda: "sessions", participants: "participants", - attendance: "participants", + attendance: "attendance", "event-activities": "live_polls", rewards: "gamification", analytics: "events", diff --git a/src/app/components/Sidebar.tsx b/src/app/components/Sidebar.tsx index d53961ff..47be3169 100644 --- a/src/app/components/Sidebar.tsx +++ b/src/app/components/Sidebar.tsx @@ -54,7 +54,6 @@ const PORTFOLIO_NAV: NavItem[] = [ id: "design", label: "Design", icon: , children: [ { id: "design-brochure", label: "Brochure" }, - { id: "design-microsite", label: "Microsite Templates" }, { id: "design-badges", label: "Badges" }, { id: "design-emailers", label: "Emailers" }, { id: "design-whatsapp-templates", label: "WhatsApp Templates" }, @@ -459,28 +458,154 @@ function BottomUser({ collapsed, onNavigate }: { collapsed: boolean; onNavigate: )} {collapsed ? ( - - + + + + + + ) : ( +
+ - - ) : ( -
-
- {initials} -
-
-

{displayName}

-

{email || "—"}

-
-
diff --git a/src/app/components/TopBar.tsx b/src/app/components/TopBar.tsx index 4090dba6..726c6a67 100644 --- a/src/app/components/TopBar.tsx +++ b/src/app/components/TopBar.tsx @@ -1,16 +1,17 @@ import React, { useState, useRef, useEffect, useMemo } from "react"; -import { Bell, Search, Menu, X, ArrowRight, MapPin, Calendar, ChevronRight, CreditCard } from "lucide-react"; +import { Bell, Search, Menu, X, ArrowRight, MapPin, Calendar, ChevronRight, CreditCard, ChevronDown } from "lucide-react"; import { useBreakpoint } from "../hooks/useBreakpoint"; import { useAuth } from "../contexts/AuthContext"; import { useAppNav } from "../contexts/AppNavContext"; -import { organizationApi, subscriptionApi } from "../../services/api"; +import { organizationApi, subscriptionApi, eventsApi } from "../../services/api"; import type { OrganizationRecord, SubscriptionPlanCatalog } from "../../services/api/types"; +import { portfolioListOrganizationId } from "../../services/api/scopedOrganization"; import { ORGANIZATION_SUBSCRIPTION_UPDATED } from "../constants/appEvents"; import { - doSearch, SUGGESTED_SHORTCUTS, - STATUS_BADGE, LOC_ICON, + doSearch, + STATUS_BADGE, + mapApiEventToSearchEvent, SearchEvent, SearchPage, SearchAction, SearchResults, - ALL_EVENTS, } from "./search/searchIndex"; /* ───────────────────────────────────────── @@ -76,72 +77,9 @@ function SearchDropdown({
); - /* ── Suggested shortcuts (empty query) ── */ + /* ── Empty query: no menu (avoid static Suggested / Recent Events panel) ── */ if (!trimmed) { - return ( -
- {/* Suggested chips */} -
-

- Suggested -

-
- {SUGGESTED_SHORTCUTS.map(s => ( - - ))} -
-
- - {/* Divider */} -
- - {/* Recent events */} -
-

- Recent Events -

- {ALL_EVENTS.slice(0, 3).map(e => { - const badge = STATUS_BADGE[e.status]; - return ( - - ); - })} -
-
- ); + return null; } /* ── No results ── */ @@ -363,12 +301,138 @@ function formatRenewalSuffix(expiresAt: string | undefined): { desktop: string; }; } +function NotificationsBell() { + const [open, setOpen] = useState(false); + const [expanded, setExpanded] = useState(true); + const wrapRef = useRef(null); + + useEffect(() => { + if (!open) return; + const onDoc = (e: MouseEvent) => { + if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener("mousedown", onDoc); + return () => document.removeEventListener("mousedown", onDoc); + }, [open]); + + return ( +
+ + {open && ( +
+ + {expanded && ( +
+

+ No new notifications +

+
+ )} +
+ )} +
+ ); +} + function LoggedInUserButton({ isMobile }: { isMobile: boolean }) { const { user } = useAuth(); + const { setActiveNav } = useAppNav(); const name = userDisplayName(user ?? null); const initials = userInitials(user ?? null); return ( ) : ( @@ -634,8 +726,8 @@ export function TopBar({ onMenuClick, onNavigate, onEventSelect, onOpenSearchPag )}
- {/* ── Dropdown ── */} - {dropdownOpen && ( + {/* ── Dropdown (only when there is a query) ── */} + {dropdownOpen && query.trim() && (
)} - {/* Bell */} - +
@@ -786,18 +869,20 @@ export function TopBar({ onMenuClick, onNavigate, onEventSelect, onOpenSearchPag
- {/* Dropdown results */} -
- -
+ {/* Dropdown results — only when typing */} + {query.trim() ? ( +
+ +
+ ) : null}
)} diff --git a/src/app/components/activities/ActivitiesPage.tsx b/src/app/components/activities/ActivitiesPage.tsx index d0d41a9a..df8cdf48 100644 --- a/src/app/components/activities/ActivitiesPage.tsx +++ b/src/app/components/activities/ActivitiesPage.tsx @@ -4,7 +4,7 @@ import React, { useState, useMemo, useEffect, useCallback } from "react"; import { Search, X, LayoutGrid, List, Calendar as CalIcon, - MessageCircle, BarChart3, HelpCircle, Users, CheckCircle2, + MessageCircle, BarChart3, HelpCircle, Users, CheckCircle2, Star, } from "lucide-react"; import { useBreakpoint } from "../../hooks/useBreakpoint"; import { useAuth } from "../../contexts/AuthContext"; @@ -22,6 +22,7 @@ import { } from "../quiz/AddQuizPollModal"; import type { Question } from "../quiz/AddQuestionModal"; import { modalQuestionToApi } from "../quiz/quizQuestionMappers"; +import { formatActivityRating, weightedActivityRating } from "./activityRatingDisplay"; function mapQuestionForQuizPollApi(q: Question, kind: "quiz" | "poll") { return modalQuestionToApi(q, kind); @@ -41,6 +42,10 @@ interface ActivityRow { participants: number; questions: number; responses: number; + eventRatingAverage: number; + eventRatingCount: number; + sessionRatingAverage: number; + sessionRatingCount: number; subtype: "qna" | "quiz" | "poll"; raw: Event; } @@ -131,6 +136,10 @@ function eventToRow(ev: Event): ActivityRow { participants: st?.participantCount ?? 0, questions: st?.questionCount ?? 0, responses: st?.responseCount ?? 0, + eventRatingAverage: st?.eventRatingAverage ?? 0, + eventRatingCount: st?.eventRatingCount ?? 0, + sessionRatingAverage: st?.sessionRatingAverage ?? 0, + sessionRatingCount: st?.sessionRatingCount ?? 0, subtype: (ev.activitySubtype as "qna" | "quiz" | "poll") || "qna", raw: ev, }; @@ -391,11 +400,19 @@ export function ActivitiesPage({ const totals = useMemo(() => { const base = rows; + const eventRating = weightedActivityRating( + base.map((a) => ({ average: a.eventRatingAverage, count: a.eventRatingCount })), + ); + const sessionRating = weightedActivityRating( + base.map((a) => ({ average: a.sessionRatingAverage, count: a.sessionRatingCount })), + ); return { count: base.length, active: base.filter(a => a.status === "Active").length, participants: base.reduce((s, a) => s + a.participants, 0), responses: base.reduce((s, a) => s + a.responses, 0), + eventRating, + sessionRating, }; }, [rows]); @@ -474,11 +491,23 @@ export function ActivitiesPage({ {!loading && orgId ? ( <> -
+
+ +
@@ -704,7 +733,7 @@ export function ActivitiesPage({ ); } -function StatCard({ label, value, color }: { label: string; value: string; color?: string }) { +function StatCard({ label, value, color, sublabel }: { label: string; value: string; color?: string; sublabel?: string }) { return (
@@ -713,6 +742,11 @@ function StatCard({ label, value, color }: { label: string; value: string; color
{label}
+ {sublabel ? ( +
+ {sublabel} +
+ ) : null}
); } @@ -753,7 +787,7 @@ function ActivityCard({ activity, onClick }: { activity: ActivityRow; onClick: (

) : null} -
+
{activity.participants} @@ -766,6 +800,18 @@ function ActivityCard({ activity, onClick }: { activity: ActivityRow; onClick: ( {activity.responses}
+
+ + + {formatActivityRating(activity.eventRatingAverage, activity.eventRatingCount)} + +
+
+ + + {formatActivityRating(activity.sessionRatingAverage, activity.sessionRatingCount)} + +
); @@ -802,10 +848,16 @@ function ActivityListRow({ activity, isLast, onClick }: { activity: ActivityRow; {activity.type}
-
+
{activity.participants} participants
+
+ Event {formatActivityRating(activity.eventRatingAverage, activity.eventRatingCount)} +
+
+ Session {formatActivityRating(activity.sessionRatingAverage, activity.sessionRatingCount)} +
diff --git a/src/app/components/activities/ActivityDetailPage.tsx b/src/app/components/activities/ActivityDetailPage.tsx index 6b77fe67..2b71919a 100644 --- a/src/app/components/activities/ActivityDetailPage.tsx +++ b/src/app/components/activities/ActivityDetailPage.tsx @@ -2,7 +2,7 @@ * Activity detail — single-session activity shell with tabs (Questions, Quiz & Polls, Participants, Settings). */ import React, { useCallback, useEffect, useMemo, useState } from "react"; -import { QrCode } from "lucide-react"; +import { QrCode, Users, HelpCircle, CheckCircle2, Star } from "lucide-react"; import { useAppNav } from "../../contexts/AppNavContext"; import { useBreakpoint } from "../../hooks/useBreakpoint"; import { eventsApi } from "../../../services/api/events"; @@ -23,6 +23,7 @@ import { } from "../quiz/AddQuizPollModal"; import type { Question } from "../quiz/AddQuestionModal"; import { modalQuestionToApi } from "../quiz/quizQuestionMappers"; +import { formatActivityRating } from "./activityRatingDisplay"; function mapQuestionForQuizPollApi(q: Question, kind: "quiz" | "poll") { return modalQuestionToApi(q, kind); @@ -61,7 +62,7 @@ export function ActivityDetailPage() { setLoadError(null); try { const [evRes, sesRes] = await Promise.all([ - eventsApi.get(selectedEventId), + eventsApi.get(selectedEventId, { enrich: true }), sessionsApi.list(selectedEventId), ]); if (evRes.error || !evRes.data) { @@ -105,6 +106,7 @@ export function ActivityDetailPage() { }, [tabs, activeTab]); const progress = event ? activityProgress(event) : 0; + const activityStats = event?.activityStats; const enableQnAOn = session ? effectiveEnableQnA(session) : true; const enableQnPOn = session ? effectiveEnableQnP(session) : true; const subtype = event?.activitySubtype || "qna"; @@ -272,6 +274,35 @@ export function ActivityDetailPage() {
+ {activityStats ? ( +
+ } /> + } /> + } /> + } + accent="#d97706" + /> + } + accent="#d97706" + /> + +
+ ) : null} +
{tabs.map((tab) => ( @@ -435,3 +466,50 @@ export function ActivityDetailPage() {
); } + +function ActivityMetric({ + label, + value, + sublabel, + icon, + accent, +}: { + label: string; + value: string; + sublabel?: string; + icon?: React.ReactNode; + accent?: string; +}) { + return ( +
+
+ {icon ? {icon} : null} + + {value} + +
+
+ {label} +
+ {sublabel ? ( +
+ {sublabel} +
+ ) : null} +
+ ); +} diff --git a/src/app/components/activities/ActivityEventSettings.tsx b/src/app/components/activities/ActivityEventSettings.tsx index a14506a1..ea0da0b0 100644 --- a/src/app/components/activities/ActivityEventSettings.tsx +++ b/src/app/components/activities/ActivityEventSettings.tsx @@ -520,12 +520,24 @@ export function ActivityEventSettings({ - + + + +
+ )} + {c.status === "scheduled" && ( + + )} + {c.status !== "stopped" && ( + + )} +
+
+ )) + )} + + + + + + {statsCampaign && ( +
setStatsCampaign(null)} + > +
e.stopPropagation()} + > +
+

Campaign stats

+ +
+

{statsCampaign.name}

+

+ Status: {statsCampaign.status} +

+ + {statsCampaign.sentAt && ( +

+ Sent: {new Date(statsCampaign.sentAt).toLocaleString()} +

+ )} +
+
+ )} + + ); +} diff --git a/src/app/components/dashboard/EventHeader.tsx b/src/app/components/dashboard/EventHeader.tsx index 42e8970a..31dfd8d5 100644 --- a/src/app/components/dashboard/EventHeader.tsx +++ b/src/app/components/dashboard/EventHeader.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from "react"; -import { Calendar, MapPin, ExternalLink, Pencil } from "lucide-react"; +import { Calendar, MapPin, ExternalLink, Pencil, Settings } from "lucide-react"; import { useBreakpoint } from "../../hooks/useBreakpoint"; import type { Event } from "../../../services/api/types"; import { defaultMicrositePageUrl, micrositePublicPageUrl } from "../../utils/micrositePublicUrl"; @@ -63,11 +63,13 @@ export function EventHeader({ event, loading = false, onEditEvent, + onWebsiteSettings, canEdit = true, }: { event: Event | null; loading?: boolean; onEditEvent?: () => void; + onWebsiteSettings?: () => void; canEdit?: boolean; }) { const bp = useBreakpoint(); @@ -165,6 +167,32 @@ export function EventHeader({ View Website ) : null} + {!isMobile && event ? ( + + ) : null} + {canWriteBadges ? ( + ) : null} } /> + ) : null} + {canReadTicketing ? ( } title="Ticket configuration" @@ -929,6 +990,8 @@ export function EventHomeModules() { } /> + ) : null} + {canReadMicrosite ? ( } title="Microsite" @@ -948,6 +1011,8 @@ export function EventHomeModules() { } /> + ) : null} + {canReadRewards ? ( } title="Reward Points Config" @@ -967,6 +1032,8 @@ export function EventHomeModules() { } /> + ) : null} + {canReadWebCheckIn ? ( } title="Web check-in" @@ -976,12 +1043,12 @@ export function EventHomeModules() { webCheckInEnabled ? ( } /> + ) : null} + {canReadEmailCampaigns ? ( + } + title="Email Campaign" + meta={emailCampaignMeta} + actions={ + <> + + + + } + /> + ) : null} {formShareTarget ? ( @@ -1127,6 +1242,23 @@ export function EventHomeModules() { onClose={() => setShowDocumentsPanel(false)} /> ) : null} + + {showEmailCampaignsPanel && selectedEventId ? ( + setShowEmailCampaignsPanel(false)} + onChanged={() => void loadEmailCampaignMeta()} + /> + ) : null} + + {showCreateEmailCampaign && selectedEventId ? ( + setShowCreateEmailCampaign(false)} + onSuccess={() => void loadEmailCampaignMeta()} + /> + ) : null} ); } diff --git a/src/app/components/global/GlobalStubPage.tsx b/src/app/components/global/GlobalStubPage.tsx index e5ec9505..87bfe3b1 100644 --- a/src/app/components/global/GlobalStubPage.tsx +++ b/src/app/components/global/GlobalStubPage.tsx @@ -3,7 +3,7 @@ import { Palette, Mic2, Users, FileText, BookOpen, MessageSquare, Shield, Image, Layout, Tag, Mail, Plus, Search, X, MoreHorizontal, Upload, - Download, Trash2, Pencil, Copy, ChevronDown, ChevronLeft, ChevronRight, RefreshCw, + Download, Trash2, Pencil, Copy, ChevronDown, ChevronLeft, ChevronRight, RefreshCw, RotateCcw, Link2, List, Check, Share2, } from "lucide-react"; import { useBreakpoint } from "../../hooks/useBreakpoint"; @@ -46,6 +46,7 @@ import type { EventMicrosite, } from "../../../services/api"; import { LoadingState } from "../shared"; +import { buildEmailerPreviewHtml } from "../../utils/emailerPreview"; import { ConfirmModal } from "../shared"; import { AddSpeakerPanel } from "../panels/AddSpeakerPanel"; import { CreateCustomListPanel } from "../panels/CreateCustomListPanel"; @@ -147,6 +148,7 @@ function formatLastEdited(updatedAt?: string): string { export function DesignPage({ subPage }: { subPage?: string }) { const { user } = useAuth(); + const { canModule } = usePermission(); const bp = useBreakpoint(); const isMobile = bp === "mobile"; const activeTab = subPage === "design-brochure" ? "Brochure" @@ -156,6 +158,8 @@ export function DesignPage({ subPage }: { subPage?: string }) { : "Brochure"; const apiType = (subPage && SUBPAGE_TO_TYPE[subPage]) || "brochure"; const isSuperAdmin = user?.role === "super_admin"; + const canWriteEmailers = isSuperAdmin || canModule("email_outreach_campaigns", "write"); + const canManageEmailers = apiType === "emailer" ? canWriteEmailers : isSuperAdmin; const canManageMicrosite = apiType === "microsite" && user && @@ -371,6 +375,9 @@ export function DesignPage({ subPage }: { subPage?: string }) { if (c != null && typeof c === "object" && "html" in c && typeof (c as { html?: unknown }).html === "string") { html = String((c as { html: string }).html || ""); } + if (!html.trim() && c != null && typeof c === "object") { + html = buildEmailerPreviewHtml(c as Record); + } if (!html.trim()) { html = '

No HTML saved for this template yet. Use Edit to build the emailer.

'; @@ -462,7 +469,7 @@ export function DesignPage({ subPage }: { subPage?: string }) { Import )} - {(apiType === "microsite" ? canManageMicrosite : apiType === "badge" ? canManageBadgeBatch : isSuperAdmin) && ( + {(apiType === "microsite" ? canManageMicrosite : apiType === "badge" ? canManageBadgeBatch : canManageEmailers) && ( )} {apiType === "brochure" ? ( @@ -552,7 +559,7 @@ export function DesignPage({ subPage }: { subPage?: string }) { {emailerPreviewBusyId === t._id ? "…" : "Preview"} ) : null} - {isSuperAdmin && ( + {((isSuperAdmin && apiType !== "emailer") || (apiType === "emailer" && canWriteEmailers && t.canDelete !== false && !t.isSystemTemplate)) && ( )} @@ -561,13 +568,21 @@ export function DesignPage({ subPage }: { subPage?: string }) {

{t.name}

- {t.category || "—"} + + {t.category || "—"} + {apiType === "emailer" && t.isSystemTemplate ? ( + Global + ) : null} + {apiType === "emailer" && t.isOrgOverride ? ( + Customized + ) : null} + {formatLastEdited(t.updatedAt)}
))} - {isSuperAdmin && ( + {canManageEmailers && (
(null); + const [showDeleted, setShowDeleted] = useState(false); const [hovered, setHovered] = useState(null); const [focused, setFocused] = useState(false); const [loading, setLoading] = useState(true); @@ -1100,6 +1121,7 @@ export function AllParticipantsPage() { const [tagOptions, setTagOptions] = useState([]); const [editParticipantId, setEditParticipantId] = useState(null); const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null); + const [restoringId, setRestoringId] = useState(null); const searchDebounce = useRef | undefined>(undefined); const { @@ -1131,17 +1153,18 @@ export function AllParticipantsPage() { return () => { if (searchDebounce.current) clearTimeout(searchDebounce.current); }; }, [searchInput]); - useEffect(() => { setPage(1); }, [search, activeTag]); + useEffect(() => { setPage(1); }, [search, activeTag, showDeleted]); useEffect(() => { if (!listOrgId) { setTagOptions([]); return; } + if (showDeleted) return; participantsApi.listDistinctTags({ organization: listOrgId }).then((r) => { if (r.data?.tags?.length) setTagOptions(r.data.tags); }); - }, [listOrgId, listNonce]); + }, [listOrgId, listNonce, showDeleted]); useEffect(() => { if (!listOrgId) { @@ -1157,7 +1180,8 @@ export function AllParticipantsPage() { page, limit, q: search || undefined, - tag: activeTag ?? undefined, + tag: showDeleted ? undefined : (activeTag ?? undefined), + deleted: showDeleted || undefined, }) .then((res) => { setLoading(false); @@ -1167,7 +1191,7 @@ export function AllParticipantsPage() { } }) .catch(() => setLoading(false)); - }, [listOrgId, page, limit, search, activeTag, listNonce]); + }, [listOrgId, page, limit, search, activeTag, listNonce, showDeleted]); const clearFilters = () => { setSearchInput(""); setSearch(""); setActiveTag(null); setPage(1); }; @@ -1214,12 +1238,24 @@ export function AllParticipantsPage() { setImportMsg(res.error); return; } - setImportMsg(`Removed ${name}.`); + setImportMsg(`Moved ${name} to Deleted.`); setSelectAllMatchingActive(false); setSelectedIds((prev) => prev.filter((x) => x !== id)); setListNonce((n) => n + 1); }; + const restoreOne = async (id: string, name: string) => { + setRestoringId(id); + const res = await participantsApi.restore(id); + setRestoringId(null); + if (res.error) { + setImportMsg(res.error); + return; + } + setImportMsg(`Restored ${name}.`); + setListNonce((n) => n + 1); + }; + return (
{importMsg && ( @@ -1233,25 +1269,57 @@ export function AllParticipantsPage() {

Portfolio

Participants

- {total} in your organization (Including people not yet assigned to an event) · Page {page} of {Math.max(1, Math.ceil(total / limit))} + {total} {showDeleted ? "deleted" : "active"} in your organization · Page {page} of {Math.max(1, Math.ceil(total / limit))}

- - + {canWriteParticipants && !showDeleted && ( + + )} - + {canWriteParticipants && ( + + )}
+ {/* Active / Deleted toggle */} +
+ + +
+ {/* Filter row */}
+ {!showDeleted && ( void bulk.openBulkDialog("addTags")} - onRemoveTags={() => void bulk.openBulkDialog("removeTags")} - onAssignEvents={() => void bulk.openBulkDialog("assignEvents")} - onNewList={() => void bulk.openBulkDialog("newList")} - onAddToList={() => void bulk.openBulkDialog("addToList")} - onDeleteSelected={() => { if (window.confirm(`Delete ${selectedIds.length} participant(s) permanently?`)) void bulk.runBulkDelete(); }} + onAddTags={canWriteParticipants ? () => void bulk.openBulkDialog("addTags") : undefined} + onRemoveTags={canWriteParticipants ? () => void bulk.openBulkDialog("removeTags") : undefined} + onAssignEvents={canWriteParticipants ? () => void bulk.openBulkDialog("assignEvents") : undefined} + onNewList={canWriteParticipants ? () => void bulk.openBulkDialog("newList") : undefined} + onAddToList={canWriteParticipants ? () => void bulk.openBulkDialog("addToList") : undefined} + onDeleteSelected={ + canDeleteParticipants + ? () => { if (window.confirm(`Move ${selectedIds.length} participant(s) to Deleted?`)) void bulk.runBulkDelete(); } + : undefined + } onClearSelection={clearSelection} /> + )} {/* Table */}
- + Name Email Tags @@ -1309,10 +1383,12 @@ export function AllParticipantsPage() {

- No participants found + {showDeleted ? "No deleted participants" : "No participants found"}

- {(search || activeTag) ? "Try adjusting your filters or import participants." : "No one in your organization yet. Import a CSV or add people from an event."} + {showDeleted + ? "Soft-deleted people will appear here so you can restore them." + : (search || activeTag) ? "Try adjusting your filters or import participants." : "No one in your organization yet. Import a CSV or add people from an event."}

{(search || activeTag) && (
{p.eventName}
- - + {showDeleted ? ( + canDeleteParticipants ? ( + + ) : null + ) : ( + <> + {canWriteParticipants && ( + + )} + {canDeleteParticipants && ( + + )} + + )}
))} @@ -1420,7 +1516,7 @@ export function AllParticipantsPage() { {deleteTarget && ( void confirmDeleteOne()} onCancel={() => setDeleteTarget(null)} @@ -1435,6 +1531,7 @@ export function AllParticipantsPage() { onToggleTag={bulk.toggleSelectedTag} availableTags={tagOptions} removeTagOptions={Array.from(new Set(list.filter((participant) => selectedIds.includes(participant.id)).flatMap((participant) => participant.tags)))} + tagScope="global" newListName={bulk.newListName} onNewListNameChange={bulk.setNewListName} newListDesc={bulk.newListDesc} @@ -1478,6 +1575,7 @@ function formatListMemberStatus(s: string | undefined): string { if (raw === "checked_in") return "Checked In"; if (raw === "confirmed") return "Confirmed"; if (raw === "pending") return "Pending"; + if (raw === "rejected") return "Rejected"; return String(s); } diff --git a/src/app/components/global/SettingsPage.tsx b/src/app/components/global/SettingsPage.tsx index 574f251f..423ee78f 100644 --- a/src/app/components/global/SettingsPage.tsx +++ b/src/app/components/global/SettingsPage.tsx @@ -1392,15 +1392,40 @@ function EmailDeliverySection() { {err &&

{err}

} {msg &&

{msg}

} +

+ Microsoft 365 / Outlook: host smtp.office365.com, port 587, SSL off. + Username = full mailbox email. Authenticated SMTP must be enabled for that user; with MFA use an App Password. + Save settings, then re-enter the password if login still fails. +

- +
); case 'array': @@ -153,6 +293,7 @@ function FieldInput({ value={Array.isArray(value) ? value : []} onChange={onChange} eventId={eventId} + depth={arrayDepth} /> ); default: @@ -173,18 +314,32 @@ function ArrayFieldEditor({ value, onChange, eventId, + depth = 0, }: { field: MicrositeV2FieldSchema; value: Record[]; onChange: (v: Record[]) => void; eventId: string; + depth?: number; }) { const itemFields = field.itemFields || []; + const isTopicsList = field.key === 'topics'; + const itemLabel = isTopicsList ? 'Topic' : field.label || 'item'; + const nestedStyle: React.CSSProperties = isTopicsList + ? { + borderLeft: '3px solid color-mix(in srgb, var(--primary) 35%, var(--border))', + paddingLeft: '12px', + marginTop: '4px', + } + : {}; const addRow = () => { const row: Record = {}; for (const f of itemFields) { - if (f.key) row[f.key] = f.default ?? ''; + if (f.key) { + if (f.type === 'array') row[f.key] = []; + else row[f.key] = f.default ?? ''; + } } onChange([...value, row]); }; @@ -199,7 +354,7 @@ function ArrayFieldEditor({ }; return ( -
+
{value.map((row, idx) => (
- Item {idx + 1} + + {itemLabel} {idx + 1} + @@ -226,6 +383,7 @@ function ArrayFieldEditor({ value={row[f.key]} onChange={(v) => updateRow(idx, f.key, v)} eventId={eventId} + arrayDepth={depth + 1} />
))} @@ -249,7 +407,7 @@ function ArrayFieldEditor({ }} > - Add {field.label || 'item'} + Add {isTopicsList ? 'Session Topic' : field.label || 'item'}
); diff --git a/src/app/components/microsite-v2/eventMicrositeAutofill.ts b/src/app/components/microsite-v2/eventMicrositeAutofill.ts new file mode 100644 index 00000000..dab9b8a1 --- /dev/null +++ b/src/app/components/microsite-v2/eventMicrositeAutofill.ts @@ -0,0 +1,275 @@ +import type { + Event, + FormWithSubmissionCount, + MicrositeV2TemplateSchema, + StaticMicrositeConfig, +} from '../../../services/api/types'; +import { getNestedValue, setNestedValue } from './configUtils'; + +export interface EventMicrositeAutofillOptions { + registrationUrl?: string | null; + feedbackUrl?: string | null; + contactFormId?: string | null; +} + +function publicWebBase(): string { + const fromEnv = + (typeof import.meta.env.VITE_PUBLIC_WEB_BASE === 'string' && import.meta.env.VITE_PUBLIC_WEB_BASE.trim()) || + (typeof import.meta.env.VITE_EVENTBUZZ_PUBLIC_URL === 'string' && + import.meta.env.VITE_EVENTBUZZ_PUBLIC_URL.trim()); + if (fromEnv) return fromEnv.replace(/\/+$/, ''); + if (import.meta.env.DEV) return 'http://localhost:5175'; + return (typeof window !== 'undefined' ? window.location.origin : '').replace(/\/+$/, ''); +} + +function isBlank(value: unknown): boolean { + if (value == null) return true; + if (typeof value === 'string') return value.trim() === ''; + if (Array.isArray(value)) return value.length === 0; + return false; +} + +function isDefaultOrBlank(value: unknown, schemaDefault: unknown): boolean { + if (isBlank(value)) return true; + if (schemaDefault !== undefined && value === schemaDefault) return true; + if (value === '#') return true; + return false; +} + +function fieldDefault(schema: MicrositeV2TemplateSchema, sectionId: string, fieldKey: string): unknown { + const section = schema.sections.find((s) => s.id === sectionId); + const field = section?.fields.find((f) => f.key === fieldKey); + return field?.default; +} + +function parseDate(value: string | undefined): Date | null { + if (!value) return null; + const d = new Date(value); + return Number.isNaN(d.getTime()) ? null : d; +} + +function toYmd(d: Date): string { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; +} + +function toHm(d: Date): string { + return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; +} + +function formatDateRangeShort(start: Date, end: Date | null): string { + const monthDay: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric' }; + if ( + !end || + (start.getFullYear() === end.getFullYear() && + start.getMonth() === end.getMonth() && + start.getDate() === end.getDate()) + ) { + return start.toLocaleDateString('en-US', { ...monthDay, year: 'numeric' }); + } + if (start.getFullYear() === end.getFullYear() && start.getMonth() === end.getMonth()) { + const month = start.toLocaleDateString('en-US', { month: 'short' }); + return `${month} ${start.getDate()}–${end.getDate()}, ${start.getFullYear()}`; + } + const s = start.toLocaleDateString('en-US', { ...monthDay, year: 'numeric' }); + const e = end.toLocaleDateString('en-US', { ...monthDay, year: 'numeric' }); + return `${s} – ${e}`; +} + +function heroEventName(event: Event): string { + if (event.subtitle?.trim()) return event.subtitle.trim(); + return event.name?.trim() || ''; +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function descriptionToHtml(description: string): string { + const trimmed = description.trim(); + if (!trimmed) return ''; + if (/<[a-z][\s\S]*>/i.test(trimmed)) return trimmed; + return trimmed + .split(/\n\n+/) + .filter(Boolean) + .map((paragraph) => `

${escapeHtml(paragraph.trim().replace(/\n/g, '
'))}

`) + .join(''); +} + +function shouldFill( + sectionData: Record, + fieldKey: string, + schema: MicrositeV2TemplateSchema, + sectionId: string, +): boolean { + const current = fieldKey.includes('.') + ? getNestedValue(sectionData, fieldKey) + : sectionData[fieldKey]; + return isDefaultOrBlank(current, fieldDefault(schema, sectionId, fieldKey)); +} + +function fillIfEmpty( + sectionData: Record, + path: string, + value: unknown, + schema: MicrositeV2TemplateSchema, + sectionId: string, +): Record { + if (value == null || (typeof value === 'string' && !value.trim())) return sectionData; + if (!shouldFill(sectionData, path, schema, sectionId)) return sectionData; + return setNestedValue(sectionData, path, value); +} + +export function buildEventRegistrationUrl(event: Event, forms: FormWithSubmissionCount[] = []): string { + const base = publicWebBase(); + const websiteUrl = event.website?.registrationUrl?.trim(); + if (websiteUrl) return websiteUrl; + + const websiteFormId = event.website?.registrationFormId?.trim(); + if (websiteFormId) return `${base}/event/${event._id}/form/${websiteFormId}`; + + const registrationForm = forms.find((f) => f.type === 'registration' && f.settings?.isActive !== false); + if (registrationForm) return `${base}/event/${event._id}/form/${registrationForm._id}`; + + return `${base}/event/${event._id}/register`; +} + +export function buildEventFeedbackUrl(event: Event, forms: FormWithSubmissionCount[] = []): string | null { + const base = publicWebBase(); + const feedbackForm = forms.find((f) => f.type === 'feedback' && f.settings?.isActive !== false); + if (!feedbackForm) return null; + return `${base}/event/${event._id}/form/${feedbackForm._id}`; +} + +export function findContactFormId(forms: FormWithSubmissionCount[] = []): string | null { + const contact = forms.find( + (f) => f.type === 'other' && /contact/i.test(f.name) && f.settings?.isActive !== false, + ); + return contact?._id ?? null; +} + +/** Fill empty / template-default microsite fields from the event record; never overwrites user edits. */ +export function applyEventDetailsToMicrositeConfig( + config: StaticMicrositeConfig, + event: Event, + schema: MicrositeV2TemplateSchema, + options: EventMicrositeAutofillOptions = {}, +): StaticMicrositeConfig { + const next: StaticMicrositeConfig = { + meta: { ...(config.meta || {}) }, + theme: { ...(config.theme || {}) }, + sections: { ...(config.sections || {}) }, + }; + + const metaTitleDefault = schema.meta?.title; + if (isDefaultOrBlank(next.meta?.title, metaTitleDefault) && event.name?.trim()) { + next.meta = { ...next.meta, title: event.name.trim() }; + } + if (isBlank(next.meta?.eventId) && event._id) { + next.meta = { ...next.meta, eventId: event._id }; + } + + const start = parseDate(event.startDate); + const end = parseDate(event.endDate); + const location = event.location?.trim() || ''; + const bannerUrl = event.bannerImage?.trim() || event.image?.trim() || ''; + const dateShort = start ? formatDateRangeShort(start, end) : ''; + const countdownDate = start ? toYmd(start) : ''; + const countdownTime = start ? toHm(start) : '12:00'; + + let hero = { ...((next.sections?.hero as Record) || {}) }; + hero = fillIfEmpty(hero, 'event_name', heroEventName(event), schema, 'hero'); + hero = fillIfEmpty(hero, 'date_venue', location, schema, 'hero'); + if (bannerUrl) hero = fillIfEmpty(hero, 'image.url', bannerUrl, schema, 'hero'); + if (countdownDate) hero = fillIfEmpty(hero, 'countdown.date', countdownDate, schema, 'hero'); + if (countdownTime) hero = fillIfEmpty(hero, 'countdown.time', countdownTime, schema, 'hero'); + if (options.registrationUrl) { + hero = fillIfEmpty(hero, 'cta.url', options.registrationUrl, schema, 'hero'); + hero = fillIfEmpty(hero, 'cta.target', '_blank', schema, 'hero'); + } + next.sections = { ...next.sections, hero }; + + let about = { ...((next.sections?.about as Record) || {}) }; + const aboutHtml = event.description ? descriptionToHtml(event.description) : ''; + if (aboutHtml) about = fillIfEmpty(about, 'content', aboutHtml, schema, 'about'); + next.sections = { ...next.sections, about }; + + let info = { ...((next.sections?.informationBar as Record) || {}) }; + let items = Array.isArray(info.items) ? [...(info.items as Record[])] : []; + + if (items.length === 0 && (location || dateShort)) { + items = [ + { icon: 'lni-map-marker', label: 'Location', value: location, action: 'maps' }, + { + icon: 'lni-calendar', + label: 'Date & Time', + value: dateShort, + action: 'calendar', + calendar: { + title: event.name?.trim() || '', + start: countdownDate, + end: end ? toYmd(end) : countdownDate, + location, + }, + }, + ]; + } else { + items = items.map((item) => { + const copy = { ...item }; + if (copy.label === 'Location' && isBlank(copy.value) && location) { + copy.value = location; + } + if (copy.label === 'Date & Time') { + if (isBlank(copy.value) && dateShort) copy.value = dateShort; + const cal = + copy.calendar && typeof copy.calendar === 'object' + ? { ...(copy.calendar as Record) } + : {}; + if (isBlank(cal.title) && event.name?.trim()) cal.title = event.name.trim(); + if (isBlank(cal.start) && countdownDate) cal.start = countdownDate; + if (isBlank(cal.end) && end) cal.end = toYmd(end); + if (isBlank(cal.location) && location) cal.location = location; + if (Object.keys(cal).length > 0) copy.calendar = cal; + } + return copy; + }); + } + info = { ...info, items }; + next.sections = { ...next.sections, informationBar: info }; + + if (options.registrationUrl) { + const footer = { ...((next.sections?.footer as Record) || {}) }; + if (Array.isArray(footer.links)) { + footer.links = (footer.links as Record[]).map((link) => { + if (link.text && /register/i.test(String(link.text)) && isBlank(link.url)) { + return { ...link, url: options.registrationUrl, target: link.target || '_blank' }; + } + return link; + }); + next.sections = { ...next.sections, footer }; + } + } + + if (options.contactFormId) { + const contact = { ...((next.sections?.contact as Record) || {}) }; + if (isBlank(contact.formId)) { + contact.formId = options.contactFormId; + if (contact.show == null) contact.show = true; + next.sections = { ...next.sections, contact }; + } + } + + if (options.feedbackUrl) { + let feedback = { ...((next.sections?.feedback as Record) || {}) }; + feedback = fillIfEmpty(feedback, 'url', options.feedbackUrl, schema, 'feedback'); + next.sections = { ...next.sections, feedback }; + } + + return next; +} diff --git a/src/app/components/microsite-v2/scheduleSync.ts b/src/app/components/microsite-v2/scheduleSync.ts index acc4f9dc..68178db8 100644 --- a/src/app/components/microsite-v2/scheduleSync.ts +++ b/src/app/components/microsite-v2/scheduleSync.ts @@ -14,11 +14,22 @@ export interface ScheduleSession { time: string; type: string; title: string; + /** Session name for block rows with topics */ + name?: string; + /** Nested session topics */ + topics?: ScheduleTopic[]; /** Agenda session _id for synced rows; absent for manually added rows. */ sourceId?: string; [key: string]: unknown; } +export interface ScheduleTopic { + type?: string; + duration?: string; + title?: string; + role?: string; +} + export interface ScheduleDay { label: string; date: string; diff --git a/src/app/components/panels/BadgeDesignerPreview.tsx b/src/app/components/panels/BadgeDesignerPreview.tsx index c2c4c847..051b60ea 100644 --- a/src/app/components/panels/BadgeDesignerPreview.tsx +++ b/src/app/components/panels/BadgeDesignerPreview.tsx @@ -1,11 +1,13 @@ /** * Live badge preview canvas — draggable elements, resize handles, sample data. */ -import React, { useCallback, useEffect, useRef } from "react"; +import React, { useCallback, useEffect, useRef, type RefObject } from "react"; import QRCode from "qrcode"; import { ImageIcon } from "lucide-react"; -import type { BadgeLayoutElement } from "./badgeLayout"; +import type { BadgeBackgroundImageLayout, BadgeLayoutElement } from "./badgeLayout"; import { + BADGE_BACKGROUND_IMAGE_ID, + applyBackgroundImageLayoutPatch, applyElementSize, clampElementPosition, elementSizePct, @@ -39,11 +41,27 @@ type ResizeState = { origW: number; }; +type BgDragState = { + startX: number; + startY: number; + origX: number; + origY: number; +}; + +type BgResizeState = { + startX: number; + startY: number; + origX: number; + origY: number; + origW: number; + origH: number; +}; + export function BadgeDesignerPreview({ aspectRatio, - backgroundMode, backgroundColor, backgroundImageUrl, + backgroundImageLayout, layoutElements, customImages, badgeTypeLabel, @@ -51,31 +69,44 @@ export function BadgeDesignerPreview({ selectedId, onSelect, onLayoutChange, + onBackgroundImageLayoutChange, + surfaceRef, }: { aspectRatio: number; badgeAspectRatio: number; - backgroundMode: "image" | "color"; backgroundColor: string; backgroundImageUrl: string; + backgroundImageLayout: BadgeBackgroundImageLayout; layoutElements: BadgeLayoutElement[]; customImages: { id: string; imageUrl: string }[]; badgeTypeLabel: string; selectedId: string | null; onSelect: (id: string | null) => void; onLayoutChange: (next: BadgeLayoutElement[]) => void; + onBackgroundImageLayoutChange: (next: BadgeBackgroundImageLayout) => void; + /** Canvas + bg-image handles — clicks outside this (and the inspector) dismiss selection */ + surfaceRef?: RefObject; }) { const canvasRef = useRef(null); const layoutRef = useRef(layoutElements); + const bgLayoutRef = useRef(backgroundImageLayout); const onLayoutChangeRef = useRef(onLayoutChange); + const onBgLayoutChangeRef = useRef(onBackgroundImageLayoutChange); const dragRef = useRef(null); const resizeRef = useRef(null); + const bgDragRef = useRef(null); + const bgResizeRef = useRef(null); const qrUrlRef = useRef(""); const [, setTick] = React.useState(0); layoutRef.current = layoutElements; + bgLayoutRef.current = backgroundImageLayout; onLayoutChangeRef.current = onLayoutChange; + onBgLayoutChangeRef.current = onBackgroundImageLayoutChange; const sample = samplePreviewData(badgeTypeLabel); + const hasBgImage = Boolean(backgroundImageUrl.trim()); + const bgSelected = selectedId === BADGE_BACKGROUND_IMAGE_ID; useEffect(() => { QRCode.toDataURL("eventbuzz:preview:sample", { width: 120, margin: 1 }) @@ -99,6 +130,36 @@ export function BadgeDesignerPreview({ const box = canvasRef.current?.getBoundingClientRect(); if (!box?.width || !box.height) return; + const bgDrag = bgDragRef.current; + if (bgDrag) { + e.preventDefault(); + const dx = ((e.clientX - bgDrag.startX) / box.width) * 100; + const dy = ((e.clientY - bgDrag.startY) / box.height) * 100; + const next = applyBackgroundImageLayoutPatch(bgLayoutRef.current, { + x: bgDrag.origX + dx, + y: bgDrag.origY + dy, + }); + bgLayoutRef.current = next; + onBgLayoutChangeRef.current(next); + return; + } + + const bgResize = bgResizeRef.current; + if (bgResize) { + e.preventDefault(); + const dx = ((e.clientX - bgResize.startX) / box.width) * 100; + const dy = ((e.clientY - bgResize.startY) / box.height) * 100; + const next = applyBackgroundImageLayoutPatch(bgLayoutRef.current, { + x: bgResize.origX, + y: bgResize.origY, + w: bgResize.origW + dx, + h: bgResize.origH + dy, + }); + bgLayoutRef.current = next; + onBgLayoutChangeRef.current(next); + return; + } + const drag = dragRef.current; if (drag) { const el = layoutRef.current.find((x) => x.id === drag.id); @@ -106,7 +167,7 @@ export function BadgeDesignerPreview({ e.preventDefault(); const dx = ((e.clientX - drag.startX) / box.width) * 100; const dy = ((e.clientY - drag.startY) / box.height) * 100; - const pos = clampElementPosition(el, drag.origX + dx, drag.origY + dy); + const pos = clampElementPosition(el, drag.origX + dx, drag.origY + dy, badgeAspectRatio); patchElement(drag.id, pos); return; } @@ -126,6 +187,8 @@ export function BadgeDesignerPreview({ const end = () => { dragRef.current = null; resizeRef.current = null; + bgDragRef.current = null; + bgResizeRef.current = null; }; window.addEventListener("pointermove", onMove, { passive: false }); @@ -136,10 +199,10 @@ export function BadgeDesignerPreview({ window.removeEventListener("pointerup", end); window.removeEventListener("pointercancel", end); }; - }, [patchElement]); + }, [patchElement, badgeAspectRatio]); const startDrag = (e: React.PointerEvent, el: BadgeLayoutElement) => { - if (resizeRef.current) return; + if (resizeRef.current || bgDragRef.current || bgResizeRef.current) return; e.preventDefault(); e.stopPropagation(); onSelect(el.id); @@ -165,52 +228,151 @@ export function BadgeDesignerPreview({ }; }; - const bgStyle: React.CSSProperties = - backgroundMode === "image" && backgroundImageUrl.trim() - ? { - backgroundImage: `url(${backgroundImageUrl})`, - backgroundSize: "cover", - backgroundPosition: "center", - } - : { backgroundColor }; + const startBgDrag = (e: React.PointerEvent) => { + if (bgResizeRef.current || resizeRef.current) return; + e.preventDefault(); + e.stopPropagation(); + onSelect(BADGE_BACKGROUND_IMAGE_ID); + bgDragRef.current = { + startX: e.clientX, + startY: e.clientY, + origX: backgroundImageLayout.x, + origY: backgroundImageLayout.y, + }; + }; + + const startBgResize = (e: React.PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + onSelect(BADGE_BACKGROUND_IMAGE_ID); + bgDragRef.current = null; + bgResizeRef.current = { + startX: e.clientX, + startY: e.clientY, + origX: backgroundImageLayout.x, + origY: backgroundImageLayout.y, + origW: backgroundImageLayout.w, + origH: backgroundImageLayout.h, + }; + }; return (

- Live preview — drag to move; drag corner handle to resize + Live preview — drag fields to move; drag corner handles to resize + {hasBgImage + ? "; drag the background image to pan (it may extend outside the badge), resize to zoom" + : ""}

onSelect(null)} + ref={surfaceRef} style={{ + position: "relative", width: "100%", maxWidth: 420, - aspectRatio: String(aspectRatio), - position: "relative", - borderRadius: "var(--radius-button)", - border: "1px solid var(--border)", - boxShadow: "0 4px 20px rgba(0,0,0,0.12)", - overflow: "hidden", - containerType: "size", - touchAction: "none", - ...bgStyle, - cursor: "default", - userSelect: "none", }} > - {layoutElements.map((el) => ( - - ))} +
onSelect(null)} + style={{ + width: "100%", + aspectRatio: String(aspectRatio), + position: "relative", + borderRadius: "var(--radius-button)", + border: "1px solid var(--border)", + boxShadow: "0 4px 20px rgba(0,0,0,0.12)", + overflow: "hidden", + containerType: "size", + touchAction: "none", + backgroundColor, + cursor: "default", + userSelect: "none", + }} + > + {hasBgImage ? ( +
{ + e.stopPropagation(); + onSelect(BADGE_BACKGROUND_IMAGE_ID); + startBgDrag(e); + }} + onClick={(e) => e.stopPropagation()} + style={{ + position: "absolute", + left: `${backgroundImageLayout.x}%`, + top: `${backgroundImageLayout.y}%`, + width: `${backgroundImageLayout.w}%`, + height: `${backgroundImageLayout.h}%`, + boxSizing: "border-box", + cursor: bgSelected ? "grab" : "pointer", + touchAction: "none", + zIndex: 0, + overflow: "hidden", + }} + > + +
+ ) : null} + {layoutElements.map((el) => ( + + ))} +
+ {hasBgImage && bgSelected ? ( +
+
+
+ ) : null}
); @@ -252,6 +414,7 @@ function PreviewElement({ cursor: "grab", boxSizing: "border-box", touchAction: "none", + zIndex: 1, ...extra, }} > @@ -299,6 +462,7 @@ function PreviewElement({ touchAction: "none", overflow: "hidden", background: "#fff", + zIndex: 1, }} > {url ? ( @@ -481,6 +645,7 @@ function TextPreviewBlock({ boxSizing: "border-box", textAlign: align, touchAction: "none", + zIndex: 1, }} >
) => void; onRemoveCustom?: () => void; onCustomImageUploaded?: (id: string, url: string, imageAspectRatio: number) => void; @@ -76,6 +78,7 @@ export function BadgeFieldInspector({ element={element} customImages={customImages} badgeAspectRatio={badgeAspectRatio} + mediaEventId={mediaEventId} onChange={onChange} onRemoveCustom={onRemoveCustom} onCustomImageUploaded={onCustomImageUploaded} @@ -224,6 +227,7 @@ function ResizableInspector({ element, customImages, badgeAspectRatio, + mediaEventId = "portfolio", onChange, onRemoveCustom, onCustomImageUploaded, @@ -231,6 +235,7 @@ function ResizableInspector({ element: BadgeLayoutElement; customImages: { id: string; imageUrl: string }[]; badgeAspectRatio: number; + mediaEventId?: string; onChange: (patch: Partial) => void; onRemoveCustom?: () => void; onCustomImageUploaded?: (id: string, url: string, imageAspectRatio: number) => void; @@ -250,7 +255,7 @@ function ResizableInspector({ setUploading(true); try { const imageAspectRatio = await readFileImageAspectRatio(file); - const r = await uploadMediaFile(file, { eventId: "portfolio", module: "badges/fixed-images" }); + const r = await uploadMediaFile(file, { eventId: mediaEventId, module: "badges/fixed-images" }); if (r.url) { const w = element.w ?? 25; const h = heightPctForImageWidth(w, imageAspectRatio, badgeAspectRatio); diff --git a/src/app/components/panels/CreateBadgePanel.tsx b/src/app/components/panels/CreateBadgePanel.tsx index 8925b0d9..78ac9f5c 100644 --- a/src/app/components/panels/CreateBadgePanel.tsx +++ b/src/app/components/panels/CreateBadgePanel.tsx @@ -19,18 +19,21 @@ import { import type { Event as ApiEvent, ParticipantListRecord } from "../../../services/api"; import { BadgeDesignerPreview, useBadgeAspectRatio } from "./BadgeDesignerPreview"; import { BadgeFieldInspector } from "./BadgeFieldInspector"; -import type { BadgeLayoutElement } from "./badgeLayout"; +import type { BadgeBackgroundImageLayout, BadgeLayoutElement } from "./badgeLayout"; import { + BADGE_BACKGROUND_IMAGE_ID, MAX_CUSTOM_IMAGE_FIELDS, MAX_CUSTOM_TEXT_FIELDS, applyElementSize, buildCustomImageElement, clampElementPosition, + defaultBackgroundImageLayout, defaultLayoutForFields, heightPctForImageWidth, newCustomImageId, newCustomTextId, readFileImageAspectRatio, + squareElementHeightPct, syncLayoutWithFields, } from "./badgeLayout"; import { formStateFromBadgeBatch } from "./badgeBatchFormState"; @@ -179,9 +182,11 @@ export function CreateBadgePanel({ const [includeParticipants, setIncludeParticipants] = useState(true); const [participantScope, setParticipantScope] = useState<"all" | "participant_list">("all"); const [participantListId, setParticipantListId] = useState(""); - const [backgroundMode, setBackgroundMode] = useState<"image" | "color">("color"); const [backgroundColor, setBackgroundColor] = useState("#0B4DD1"); const [backgroundImageUrl, setBackgroundImageUrl] = useState(""); + const [backgroundImageLayout, setBackgroundImageLayout] = useState( + defaultBackgroundImageLayout, + ); const [backgroundPreviewUrl, setBackgroundPreviewUrl] = useState(""); const [enabledFields, setEnabledFields] = useState(["name", "title", "org", "qr"]); const [layoutElements, setLayoutElements] = useState(() => @@ -199,6 +204,9 @@ export function CreateBadgePanel({ const [headerBusy, setHeaderBusy] = useState(false); const [hydrating, setHydrating] = useState(Boolean(duplicateFromBatchId)); + const badgeSurfaceRef = useRef(null); + const badgeInspectorRef = useRef(null); + const aspectRatio = useBadgeAspectRatio(sizePreset, orientation, customW, customH); const isDuplicate = Boolean(duplicateFromBatchId); const displayBgUrl = backgroundPreviewUrl || backgroundImageUrl; @@ -208,6 +216,18 @@ export function CreateBadgePanel({ [layoutElements, selectedElementId], ); + useEffect(() => { + if (!selectedElementId) return; + const dismissSelection = (event: PointerEvent) => { + const target = event.target as Node; + if (badgeSurfaceRef.current?.contains(target)) return; + if (badgeInspectorRef.current?.contains(target)) return; + setSelectedElementId(null); + }; + document.addEventListener("pointerdown", dismissSelection); + return () => document.removeEventListener("pointerdown", dismissSelection); + }, [selectedElementId]); + useEffect(() => { return () => { if (backgroundPreviewUrl.startsWith("blob:")) URL.revokeObjectURL(backgroundPreviewUrl); @@ -220,14 +240,14 @@ export function CreateBadgePanel({ let merged = { ...el }; if (el.field === "qr") { const w = el.w ?? 28; - merged = { ...merged, w, h: w }; + merged = { ...merged, w, h: squareElementHeightPct(w, aspectRatio) }; } else if (el.field === "photo" && el.w != null) { - merged = { ...merged, h: el.w }; + merged = { ...merged, h: squareElementHeightPct(el.w, aspectRatio) }; } else if (el.kind === "custom_image" && el.imageAspectRatio) { const w = el.w ?? 25; merged = { ...merged, h: heightPctForImageWidth(w, el.imageAspectRatio, aspectRatio) }; } - return { ...merged, ...clampElementPosition(merged, merged.x, merged.y) }; + return { ...merged, ...clampElementPosition(merged, merged.x, merged.y, aspectRatio) }; }), ); }, [sizePreset, orientation, customW, customH, aspectRatio]); @@ -258,9 +278,9 @@ export function CreateBadgePanel({ setIncludeParticipants(s.includeParticipants); setParticipantScope(s.participantScope); setParticipantListId(s.participantListId); - setBackgroundMode(s.backgroundMode); setBackgroundColor(s.backgroundColor); setBackgroundImageUrl(s.backgroundImageUrl); + setBackgroundImageLayout(s.backgroundImageLayout); setBackgroundPreviewUrl(""); setEnabledFields(s.enabledFields); setLayoutElements(s.layoutElements); @@ -302,7 +322,7 @@ export function CreateBadgePanel({ : [...enabledFields, id]; setEnabledFields(next); setLayoutElements((prev) => - syncLayoutWithFields(prev, next, customTexts, customImages, backgroundColor, backgroundMode, aspectRatio), + syncLayoutWithFields(prev, next, customTexts, customImages, backgroundColor, "color", aspectRatio), ); if (!next.includes(id) && selectedElementId === id) setSelectedElementId(null); }; @@ -314,11 +334,13 @@ export function CreateBadgePanel({ const nextCustom = [...customTexts, { id, text }]; setCustomTexts(nextCustom); setLayoutElements((prev) => - syncLayoutWithFields(prev, enabledFields, nextCustom, customImages, backgroundColor, backgroundMode, aspectRatio), + syncLayoutWithFields(prev, enabledFields, nextCustom, customImages, backgroundColor, "color", aspectRatio), ); setSelectedElementId(id); }; + const badgeMediaEventId = formEventId.trim() || "portfolio"; + const addCustomImage = async (file: File) => { if (customImages.length >= MAX_CUSTOM_IMAGE_FIELDS) return; if (!file.type.startsWith("image/")) { @@ -330,7 +352,7 @@ export function CreateBadgePanel({ setErr(""); try { const imageAspectRatio = await readFileImageAspectRatio(file); - const r = await uploadMediaFile(file, { eventId: "portfolio", module: "badges/fixed-images" }); + const r = await uploadMediaFile(file, { eventId: badgeMediaEventId, module: "badges/fixed-images" }); if (!r.url) throw new Error("Upload failed"); const nextImages = [...customImages, { id, imageUrl: r.url }]; setCustomImages(nextImages); @@ -398,11 +420,13 @@ export function CreateBadgePanel({ setBackgroundPreviewUrl(local); setHeaderBusy(true); setErr(""); + const hadBgImage = Boolean(backgroundImageUrl.trim()); try { - const r = await uploadMediaFile(file, { eventId: "portfolio", module: "badges/backgrounds" }); + const r = await uploadMediaFile(file, { eventId: badgeMediaEventId, module: "badges/backgrounds" }); if (r.url) { setBackgroundImageUrl(r.url); - setBackgroundMode("image"); + if (!hadBgImage) setBackgroundImageLayout(defaultBackgroundImageLayout()); + setSelectedElementId(BADGE_BACKGROUND_IMAGE_ID); } } catch (e) { setErr(e instanceof Error ? e.message : "Upload failed"); @@ -434,10 +458,6 @@ export function CreateBadgePanel({ setErr("Select a contact list."); return; } - if (backgroundMode === "image" && !backgroundImageUrl.trim()) { - setErr("Upload a background image or switch to solid colour."); - return; - } const hasCustom = layoutElements.some((el) => el.kind === "custom_text") || layoutElements.some((el) => el.kind === "custom_image"); @@ -495,8 +515,9 @@ export function CreateBadgePanel({ includeParticipants, participantScope, ...(includeParticipants && participantScope === "participant_list" ? { participantListId } : {}), - backgroundMode, - backgroundImageUrl: backgroundMode === "image" ? backgroundImageUrl.trim() : "", + backgroundMode: backgroundImageUrl.trim() ? "image" : "color", + backgroundImageUrl: backgroundImageUrl.trim(), + ...(backgroundImageUrl.trim() ? { backgroundImageLayout } : {}), backgroundColor, fields: enabledFields, layout: layoutPayload, @@ -548,21 +569,25 @@ export function CreateBadgePanel({ - + c.id === selectedElementId)?.text} customImages={customImages} badgeAspectRatio={aspectRatio} + mediaEventId={badgeMediaEventId} onChange={updateSelectedElement} onRemoveCustom={ selectedElement?.kind === "custom_text" @@ -576,6 +601,7 @@ export function CreateBadgePanel({ updateSelectedElement({ imageUrl: url, imageAspectRatio }); }} /> +
@@ -722,34 +748,53 @@ export function CreateBadgePanel({ nextLabel="Output" > - - {backgroundMode === "color" && ( - - - )} - {backgroundMode === "image" && ( - -
+ + -