Expand website theme settings and related console updates.
Allow editing full website.theme palette/pattern options from Event Home, plus accompanying dashboard and API client changes. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
c803b9d253
commit
24f8edc05d
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@
|
|||
rel="stylesheet"
|
||||
/>
|
||||
<script src="https://accounts.google.com/gsi/client" async defer></script>
|
||||
<script type="module" crossorigin src="/assets/index-Dj8crrlq.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-1SgcqEoJ.css">
|
||||
<script type="module" crossorigin src="/assets/index-hF8UUtq5.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-0HOFP88J.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -0,0 +1,47 @@
|
|||
import type { CSSProperties } from "react";
|
||||
import logoUrl from "../../assets/logo.png";
|
||||
import logoSmallUrl from "../../assets/logo-small.png";
|
||||
|
||||
type EventBuzzBrandLogoProps = {
|
||||
/** `full` = wordmark (`logo.png`); `small` = mark (`logo-small.png`). */
|
||||
variant?: "full" | "small";
|
||||
/** Visual height in px (width scales with image aspect). */
|
||||
height?: number;
|
||||
/** Optional max width clamp. */
|
||||
maxWidth?: number;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
alt?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Product logo from `src/assets/logo.png` / `logo-small.png`.
|
||||
*/
|
||||
export function EventBuzzBrandLogo({
|
||||
variant = "full",
|
||||
height = 28,
|
||||
maxWidth,
|
||||
className,
|
||||
style,
|
||||
alt = "EventBuzz",
|
||||
}: EventBuzzBrandLogoProps) {
|
||||
const src = variant === "small" ? logoSmallUrl : logoUrl;
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={className}
|
||||
style={{
|
||||
height,
|
||||
width: "auto",
|
||||
maxWidth: maxWidth ?? "100%",
|
||||
display: "block",
|
||||
objectFit: "contain",
|
||||
flexShrink: 0,
|
||||
...style,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { logoUrl as eventBuzzLogoUrl, logoSmallUrl as eventBuzzLogoSmallUrl };
|
||||
|
|
@ -38,6 +38,9 @@ const EVENT_TABS: EventTab[] = [
|
|||
{ id: "analytics", label: "Analytics", shortLabel: "Analytics", icon: <LineChart size={13} /> },
|
||||
];
|
||||
|
||||
const EVENT_NAV_TEXT = "#484B57";
|
||||
const EVENT_NAV_TEXT_MUTED = "rgba(72, 75, 87, 0.72)";
|
||||
|
||||
/** Event sub-routes that keep the event NavBar visible (includes hidden tabs like the website builder). */
|
||||
export const EVENT_NAV_IDS = new Set([...EVENT_TABS.map((t) => t.id), "website", "permissions", "ticketing", "questions", "quiz"]);
|
||||
|
||||
|
|
@ -59,7 +62,6 @@ export function NavBar({ activeItem, onNavigate }: NavBarProps) {
|
|||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const bp = useBreakpoint();
|
||||
const isMobile = bp === "mobile";
|
||||
const isTablet = bp === "tablet";
|
||||
|
||||
// H1: Track scroll position to show fade indicator for overflow tabs
|
||||
const [canScrollRight, setCanScrollRight] = useState(false);
|
||||
|
|
@ -136,12 +138,12 @@ export function NavBar({ activeItem, onNavigate }: NavBarProps) {
|
|||
borderRightColor: "transparent",
|
||||
borderBottomWidth: "2px",
|
||||
borderBottomStyle: "solid",
|
||||
borderBottomColor: isActive ? "var(--primary)" : "transparent",
|
||||
borderBottomColor: isActive ? EVENT_NAV_TEXT : "transparent",
|
||||
cursor: "pointer",
|
||||
fontSize: "var(--text-sm)",
|
||||
fontFamily: "var(--font-noto-sans)",
|
||||
fontWeight: isActive ? "var(--font-weight-medium)" : "var(--font-weight-normal)",
|
||||
color: isActive ? "var(--primary)" : "var(--card-foreground)",
|
||||
color: isActive ? EVENT_NAV_TEXT : EVENT_NAV_TEXT_MUTED,
|
||||
whiteSpace: "nowrap",
|
||||
flexShrink: 0,
|
||||
transition: "color 0.12s, border-color 0.12s, background 0.12s",
|
||||
|
|
@ -149,17 +151,17 @@ export function NavBar({ activeItem, onNavigate }: NavBarProps) {
|
|||
onMouseEnter={e => {
|
||||
if (!isActive) {
|
||||
(e.currentTarget as HTMLElement).style.background = "var(--secondary)";
|
||||
(e.currentTarget as HTMLElement).style.color = "var(--foreground)";
|
||||
(e.currentTarget as HTMLElement).style.color = EVENT_NAV_TEXT;
|
||||
}
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
if (!isActive) {
|
||||
(e.currentTarget as HTMLElement).style.background = "transparent";
|
||||
(e.currentTarget as HTMLElement).style.color = "var(--card-foreground)";
|
||||
(e.currentTarget as HTMLElement).style.color = EVENT_NAV_TEXT_MUTED;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span style={{ color: isActive ? "var(--primary)" : "var(--muted)", display: "flex", flexShrink: 0 }}>
|
||||
<span style={{ color: isActive ? EVENT_NAV_TEXT : EVENT_NAV_TEXT_MUTED, display: "flex", flexShrink: 0 }}>
|
||||
{tab.icon}
|
||||
</span>
|
||||
{/* On mobile show short labels */}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { useNavigate } from "react-router-dom";
|
|||
import { useTheme } from "../contexts/ThemeContext";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { navItemAllowed, usePermission } from "../contexts/PermissionContext";
|
||||
import { EventBuzzBrandLogo } from "./EventBuzzBrandLogo";
|
||||
|
||||
/* ─────────────────────────────────────
|
||||
Types
|
||||
|
|
@ -267,7 +268,7 @@ function NavList({
|
|||
onMouseLeave={e => { if (!cActive) (e.currentTarget as HTMLElement).style.background = "transparent"; }}
|
||||
>
|
||||
{child.isAction && <Plus size={11} style={{ color: "var(--primary)", flexShrink: 0 }} />}
|
||||
<span style={{ fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-xs)", color: cActive ? "var(--sidebar-accent-foreground)" : child.isAction ? "var(--primary)" : "var(--card-foreground)" }}>
|
||||
<span style={{ fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-xs)", color: cActive ? "var(--sidebar-accent-foreground)" : child.isAction ? "var(--primary)" : "var(--sidebar-foreground)" }}>
|
||||
{child.label}
|
||||
</span>
|
||||
</button>
|
||||
|
|
@ -686,12 +687,7 @@ export function Sidebar({
|
|||
}}>
|
||||
{/* Header */}
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "14px 14px 14px 16px", borderBottom: "1px solid var(--sidebar-border)", flexShrink: 0, minHeight: "52px" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "9px" }}>
|
||||
<div style={{ width: "28px", height: "28px", borderRadius: "6px", background: "var(--primary)", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<span style={{ color: "#fff", fontSize: "11px", fontFamily: "var(--font-inter)", fontWeight: "var(--font-weight-medium)" }}>EB</span>
|
||||
</div>
|
||||
<span style={{ fontFamily: "var(--font-inter)", fontWeight: "var(--font-weight-medium)", fontSize: "var(--text-base)", color: "var(--sidebar-foreground)" }}>EventBuzz</span>
|
||||
</div>
|
||||
<EventBuzzBrandLogo height={28} maxWidth={150} />
|
||||
<button onClick={onDrawerClose}
|
||||
style={{ width: "28px", height: "28px", display: "flex", alignItems: "center", justifyContent: "center", background: "none", border: "none", cursor: "pointer", borderRadius: "var(--radius-button)", color: "var(--muted)" }}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.background = "var(--accent)"; }}
|
||||
|
|
@ -735,11 +731,12 @@ export function Sidebar({
|
|||
borderBottom: "1px solid var(--sidebar-border)",
|
||||
flexShrink: 0, minHeight: "52px",
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "9px", overflow: "hidden" }}>
|
||||
<div style={{ width: "28px", height: "28px", borderRadius: "6px", background: "var(--primary)", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
|
||||
<span style={{ color: "#fff", fontSize: "11px", fontFamily: "var(--font-inter)", fontWeight: "var(--font-weight-medium)" }}>EB</span>
|
||||
</div>
|
||||
{!collapsed && <span style={{ fontFamily: "var(--font-inter)", fontWeight: "var(--font-weight-medium)", fontSize: "var(--text-base)", color: "var(--sidebar-foreground)", whiteSpace: "nowrap", overflow: "hidden" }}>EventBuzz</span>}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "9px", overflow: "hidden", minWidth: 0, flex: collapsed ? undefined : 1 }}>
|
||||
{collapsed ? (
|
||||
<EventBuzzBrandLogo variant="small" height={28} maxWidth={36} />
|
||||
) : (
|
||||
<EventBuzzBrandLogo height={28} maxWidth={140} />
|
||||
)}
|
||||
</div>
|
||||
{/* H3: Always show collapse/expand button — critical for user control */}
|
||||
<button onClick={onToggleCollapse} title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
mapApiEventToSearchEvent,
|
||||
SearchEvent, SearchPage, SearchAction, SearchResults,
|
||||
} from "./search/searchIndex";
|
||||
import { EventBuzzBrandLogo } from "./EventBuzzBrandLogo";
|
||||
|
||||
/* ─────────────────────────────────────────
|
||||
Props
|
||||
|
|
@ -676,14 +677,7 @@ export function TopBar({ onMenuClick, onNavigate, onEventSelect, onOpenSearchPag
|
|||
)}
|
||||
|
||||
{/* Logo — mobile only */}
|
||||
{isMobile && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "7px" }}>
|
||||
<div style={{ width: "26px", height: "26px", borderRadius: "6px", background: "var(--primary)", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<span style={{ color: "#fff", fontSize: "10px", fontFamily: "var(--font-inter)", fontWeight: "var(--font-weight-medium)" }}>EB</span>
|
||||
</div>
|
||||
<span style={{ fontFamily: "var(--font-inter)", fontWeight: "var(--font-weight-medium)", fontSize: "var(--text-sm)", color: "var(--foreground)" }}>EventBuzz</span>
|
||||
</div>
|
||||
)}
|
||||
{isMobile && <EventBuzzBrandLogo height={24} maxWidth={130} />}
|
||||
|
||||
{/* ── Desktop search with dropdown ── */}
|
||||
{!isMobile && (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -13,10 +13,18 @@ import {
|
|||
} from "../../../services/api";
|
||||
import type { FormWithSubmissionCount } from "../../../services/api/types";
|
||||
import { FlatpickrDatetimeInput } from "../ui/FlatpickrDatetimeInput";
|
||||
import {
|
||||
DEFAULT_KEY_PERSON_ROLE,
|
||||
KEY_PERSON_ROLES,
|
||||
normalizeKeyPersonRole,
|
||||
type KeyPersonRole,
|
||||
} from "../../constants/keyPersonRoles";
|
||||
|
||||
export const UNASSIGNED_TRACK_ID = "__unassigned__";
|
||||
const NEW_TRACK_VALUE = "__new__";
|
||||
|
||||
type KeyPersonRow = { speakerId: string; role: KeyPersonRole };
|
||||
|
||||
type SessionType = "Presentation" | "Panel" | "Keynote" | "Workshop" | "Qna" | "Demo" | "Debate" | "Symposium" | "Other";
|
||||
type SessionStatus = "Scheduled" | "Live" | "Completed" | "Cancelled";
|
||||
|
||||
|
|
@ -165,6 +173,27 @@ function extractSpeakerIds(s?: ApiSession | null): string[] {
|
|||
);
|
||||
}
|
||||
|
||||
function extractKeyPersons(s?: ApiSession | null): KeyPersonRow[] {
|
||||
if (s?.keyPersons?.length) {
|
||||
const rows: KeyPersonRow[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const kp of s.keyPersons) {
|
||||
const speakerId =
|
||||
typeof kp.speaker === "object" && kp.speaker && "_id" in kp.speaker
|
||||
? String(kp.speaker._id)
|
||||
: String(kp.speaker || "");
|
||||
if (!speakerId || seen.has(speakerId)) continue;
|
||||
seen.add(speakerId);
|
||||
rows.push({ speakerId, role: normalizeKeyPersonRole(kp.role) });
|
||||
}
|
||||
if (rows.length) return rows;
|
||||
}
|
||||
return extractSpeakerIds(s).map((speakerId) => ({
|
||||
speakerId,
|
||||
role: DEFAULT_KEY_PERSON_ROLE,
|
||||
}));
|
||||
}
|
||||
|
||||
function sessionLinkedFormId(s: ApiSession | null | undefined, key: "audienceFeedbackForm" | "audienceRatingsForm"): string {
|
||||
if (!s) return "";
|
||||
const v = s[key];
|
||||
|
|
@ -263,7 +292,8 @@ export function AgendaSessionPanel({
|
|||
|
||||
const [speakerCatalog, setSpeakerCatalog] = useState<Speaker[]>([]);
|
||||
const [speakersLoading, setSpeakersLoading] = useState(true);
|
||||
const [selectedSpeakerIds, setSelectedSpeakerIds] = useState<string[]>(() => extractSpeakerIds(session));
|
||||
const [keyPersons, setKeyPersons] = useState<KeyPersonRow[]>(() => extractKeyPersons(session));
|
||||
const selectedSpeakerIds = useMemo(() => keyPersons.map((k) => k.speakerId), [keyPersons]);
|
||||
const [speakerSearch, setSpeakerSearch] = useState("");
|
||||
const [showNewSpeaker, setShowNewSpeaker] = useState(false);
|
||||
const [nsFirst, setNsFirst] = useState("");
|
||||
|
|
@ -271,6 +301,7 @@ export function AgendaSessionPanel({
|
|||
const [nsEmail, setNsEmail] = useState("");
|
||||
const [nsPhone, setNsPhone] = useState("");
|
||||
const [nsTitle, setNsTitle] = useState("");
|
||||
const [nsRole, setNsRole] = useState<KeyPersonRole>(DEFAULT_KEY_PERSON_ROLE);
|
||||
const [nsErr, setNsErr] = useState<SectionErr>({});
|
||||
const [nsSaving, setNsSaving] = useState(false);
|
||||
|
||||
|
|
@ -330,12 +361,18 @@ export function AgendaSessionPanel({
|
|||
[speakerCatalog]
|
||||
);
|
||||
|
||||
const addSpeakerId = (id: string) => {
|
||||
const addSpeakerId = (id: string, role: KeyPersonRole = DEFAULT_KEY_PERSON_ROLE) => {
|
||||
if (!id || selectedSpeakerIds.includes(id)) return;
|
||||
setSelectedSpeakerIds((p) => [...p, id]);
|
||||
setKeyPersons((p) => [...p, { speakerId: id, role: normalizeKeyPersonRole(role) }]);
|
||||
};
|
||||
|
||||
const removeSpeakerId = (id: string) => setSelectedSpeakerIds((p) => p.filter((x) => x !== id));
|
||||
const removeSpeakerId = (id: string) => setKeyPersons((p) => p.filter((x) => x.speakerId !== id));
|
||||
|
||||
const setKeyPersonRole = (id: string, role: KeyPersonRole) => {
|
||||
setKeyPersons((p) =>
|
||||
p.map((row) => (row.speakerId === id ? { ...row, role: normalizeKeyPersonRole(role) } : row)),
|
||||
);
|
||||
};
|
||||
|
||||
const createSpeakerAndAttach = async () => {
|
||||
const e: SectionErr = {};
|
||||
|
|
@ -361,12 +398,13 @@ export function AgendaSessionPanel({
|
|||
}
|
||||
const created = res.data as Speaker;
|
||||
setSpeakerCatalog((prev) => [created, ...prev.filter((x) => x._id !== created._id)]);
|
||||
addSpeakerId(created._id);
|
||||
addSpeakerId(created._id, nsRole);
|
||||
setNsFirst("");
|
||||
setNsLast("");
|
||||
setNsEmail("");
|
||||
setNsPhone("");
|
||||
setNsTitle("");
|
||||
setNsRole(DEFAULT_KEY_PERSON_ROLE);
|
||||
setNsErr({});
|
||||
setShowNewSpeaker(false);
|
||||
};
|
||||
|
|
@ -489,6 +527,10 @@ export function AgendaSessionPanel({
|
|||
status: UI_TO_API_STATUS[status],
|
||||
track: trackId === UNASSIGNED_TRACK_ID ? null : trackId,
|
||||
speakers: selectedSpeakerIds,
|
||||
keyPersons: keyPersons.map((k) => ({
|
||||
speaker: k.speakerId,
|
||||
role: normalizeKeyPersonRole(k.role),
|
||||
})),
|
||||
topics: normalizedTopics.length ? normalizedTopics : [],
|
||||
audienceFeedbackEnabled,
|
||||
audienceRatingsEnabled,
|
||||
|
|
@ -517,7 +559,7 @@ export function AgendaSessionPanel({
|
|||
{ n: 2, title: "Session details", subtitle: "Title, format, and status." },
|
||||
{ n: 3, title: "Schedule", subtitle: "Start and end date/time." },
|
||||
{ n: 4, title: "Session topics", subtitle: "Optional programme items within this session (type, duration, title, role)." },
|
||||
{ n: 5, title: "Speakers & notes", subtitle: "Faculty from your organization; add new speakers as needed." },
|
||||
{ n: 5, title: "Key Persons & notes", subtitle: "Faculty from your organization; assign a role for each person." },
|
||||
];
|
||||
|
||||
const SaveBtn = () => (
|
||||
|
|
@ -908,34 +950,56 @@ export function AgendaSessionPanel({
|
|||
{sec.n === 5 && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "14px" }}>
|
||||
<div>
|
||||
<label style={{ display: "block", fontSize: "var(--text-sm)", fontWeight: "var(--font-weight-medium)", marginBottom: "6px", fontFamily: "var(--font-noto-sans)" }}>Speakers</label>
|
||||
<label style={{ display: "block", fontSize: "var(--text-sm)", fontWeight: "var(--font-weight-medium)", marginBottom: "6px", fontFamily: "var(--font-noto-sans)" }}>Key Persons</label>
|
||||
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: "0 0 8px", fontFamily: "var(--font-noto-sans)" }}>
|
||||
Select from your organization'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).
|
||||
</p>
|
||||
{selectedSpeakerIds.length > 0 && (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "6px", marginBottom: "10px" }}>
|
||||
{selectedSpeakerIds.map((id) => (
|
||||
<span
|
||||
key={id}
|
||||
{keyPersons.length > 0 && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "8px", marginBottom: "10px" }}>
|
||||
{keyPersons.map((row) => (
|
||||
<div
|
||||
key={row.speakerId}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
display: "grid",
|
||||
gridTemplateColumns: "minmax(0, 1fr) minmax(140px, 200px) auto",
|
||||
gap: "8px",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
padding: "4px 8px 4px 10px",
|
||||
padding: "8px 10px",
|
||||
borderRadius: "var(--radius-button)",
|
||||
background: "var(--secondary)",
|
||||
border: "1px solid var(--border)",
|
||||
fontSize: "var(--text-xs)",
|
||||
fontFamily: "var(--font-noto-sans)",
|
||||
}}
|
||||
>
|
||||
{speakerLabel(id)}
|
||||
<span
|
||||
style={{
|
||||
fontSize: "var(--text-sm)",
|
||||
fontFamily: "var(--font-noto-sans)",
|
||||
fontWeight: "var(--font-weight-medium)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{speakerLabel(row.speakerId)}
|
||||
</span>
|
||||
<select
|
||||
value={row.role}
|
||||
onChange={(e) => setKeyPersonRole(row.speakerId, e.target.value as KeyPersonRole)}
|
||||
aria-label={`Role for ${speakerLabel(row.speakerId)}`}
|
||||
style={{ ...IS, padding: "6px 8px", fontSize: "var(--text-xs)" }}
|
||||
>
|
||||
{KEY_PERSON_ROLES.map((role) => (
|
||||
<option key={role} value={role}>
|
||||
{role}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeSpeakerId(id)}
|
||||
onClick={() => removeSpeakerId(row.speakerId)}
|
||||
style={{
|
||||
display: "flex",
|
||||
padding: 0,
|
||||
padding: "4px",
|
||||
border: "none",
|
||||
background: "none",
|
||||
cursor: "pointer",
|
||||
|
|
@ -943,9 +1007,9 @@ export function AgendaSessionPanel({
|
|||
}}
|
||||
aria-label="Remove"
|
||||
>
|
||||
<X size={12} />
|
||||
<X size={14} />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -984,14 +1048,14 @@ export function AgendaSessionPanel({
|
|||
if (speakerCatalog.length === 0) {
|
||||
return (
|
||||
<p style={{ padding: "12px", fontSize: "var(--text-xs)", color: "var(--muted)", margin: 0, fontFamily: "var(--font-noto-sans)" }}>
|
||||
No speakers in the directory yet. Add one below.
|
||||
No faculty in the directory yet. Add one below.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (available.length === 0) {
|
||||
return (
|
||||
<p style={{ padding: "12px", fontSize: "var(--text-xs)", color: "var(--muted)", margin: 0, fontFamily: "var(--font-noto-sans)" }}>
|
||||
Everyone listed is already on this session. Add a new speaker or remove someone above.
|
||||
Everyone listed is already on this session. Add a new key person or remove someone above.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
|
@ -1060,7 +1124,7 @@ export function AgendaSessionPanel({
|
|||
color: "var(--primary)",
|
||||
}}
|
||||
>
|
||||
<UserPlus size={14} /> {showNewSpeaker ? "Hide new speaker form" : "Add new speaker"}
|
||||
<UserPlus size={14} /> {showNewSpeaker ? "Hide new person form" : "Add new key person"}
|
||||
</button>
|
||||
{showNewSpeaker && (
|
||||
<div
|
||||
|
|
@ -1101,10 +1165,24 @@ export function AgendaSessionPanel({
|
|||
<input value={nsPhone} onChange={(e) => setNsPhone(e.target.value)} style={IS} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: "var(--text-xs)", display: "block", marginBottom: "4px", fontFamily: "var(--font-noto-sans)" }}>Title / role</label>
|
||||
<label style={{ fontSize: "var(--text-xs)", display: "block", marginBottom: "4px", fontFamily: "var(--font-noto-sans)" }}>Title</label>
|
||||
<input value={nsTitle} onChange={(e) => setNsTitle(e.target.value)} placeholder="e.g. Prof. of Medicine" style={IS} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: "var(--text-xs)", display: "block", marginBottom: "4px", fontFamily: "var(--font-noto-sans)" }}>Session role</label>
|
||||
<select
|
||||
value={nsRole}
|
||||
onChange={(e) => setNsRole(e.target.value as KeyPersonRole)}
|
||||
style={IS}
|
||||
>
|
||||
{KEY_PERSON_ROLES.map((role) => (
|
||||
<option key={role} value={role}>
|
||||
{role}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={nsSaving}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
/** Public landing URL for QR codes: session, track, or event `joinCode` (resolved in that order server-side). */
|
||||
export function eventBuzzUrl(code: string): string {
|
||||
return `https://eventbuzz.live/code/${code}`;
|
||||
const base =
|
||||
(typeof import.meta.env.VITE_EVENTBUZZ_PUBLIC_URL === "string" &&
|
||||
import.meta.env.VITE_EVENTBUZZ_PUBLIC_URL.trim()) ||
|
||||
"https://join.eventbuzz.live";
|
||||
return `${base.replace(/\/$/, "")}/code/${code}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,8 +15,11 @@ import {
|
|||
} from "lucide-react";
|
||||
import { useAppNav } from "../../contexts/AppNavContext";
|
||||
import { usePermission } from "../../contexts/PermissionContext";
|
||||
import { attendancesApi } from "../../../services/api";
|
||||
import { attendancesApi, participantsApi, participantListsApi } from "../../../services/api";
|
||||
import type { ParticipantListRecord } from "../../../services/api/participantLists";
|
||||
import type {
|
||||
AttendanceAudienceMode,
|
||||
AttendanceMembershipStatus,
|
||||
AttendanceRecord,
|
||||
AttendanceSearchParticipant,
|
||||
AttendanceCheckInRow,
|
||||
|
|
@ -38,11 +41,55 @@ function formatDt(iso?: string | null) {
|
|||
});
|
||||
}
|
||||
|
||||
const MEMBERSHIP_STATUS_OPTIONS: { value: AttendanceMembershipStatus; label: string }[] = [
|
||||
{ value: "pending", label: "Pending" },
|
||||
{ value: "confirmed", label: "Confirmed" },
|
||||
{ value: "checked_in", label: "Checked in" },
|
||||
{ value: "cancelled", label: "Cancelled" },
|
||||
{ value: "rejected", label: "Rejected" },
|
||||
];
|
||||
|
||||
const AUDIENCE_MODE_OPTIONS: { value: AttendanceAudienceMode; label: string; hint: string }[] = [
|
||||
{ value: "all", label: "All participants", hint: "Everyone registered for this event" },
|
||||
{ value: "statuses", label: "By status", hint: "Only selected membership statuses" },
|
||||
{ value: "tags", label: "By tags", hint: "Participants with any selected tag" },
|
||||
{
|
||||
value: "participant_lists",
|
||||
label: "By contact list",
|
||||
hint: "Members of selected contact lists (also on this event)",
|
||||
},
|
||||
];
|
||||
|
||||
function audienceSummary(s: AttendanceRecord): string {
|
||||
const mode = s.settings?.audience?.mode || "all";
|
||||
if (mode === "all") return "All participants";
|
||||
if (mode === "statuses") {
|
||||
const statuses = s.settings?.audience?.statuses || [];
|
||||
if (!statuses.length) return "By status";
|
||||
return `Status: ${statuses
|
||||
.map((st) => MEMBERSHIP_STATUS_OPTIONS.find((o) => o.value === st)?.label || st)
|
||||
.join(", ")}`;
|
||||
}
|
||||
if (mode === "tags") {
|
||||
const tags = s.settings?.audience?.tags || [];
|
||||
return tags.length ? `Tags: ${tags.join(", ")}` : "By tags";
|
||||
}
|
||||
if (mode === "participant_lists") {
|
||||
const n = s.settings?.audience?.participantListIds?.length || 0;
|
||||
return n ? `${n} contact list${n === 1 ? "" : "s"}` : "By contact list";
|
||||
}
|
||||
return "All participants";
|
||||
}
|
||||
|
||||
interface FormState {
|
||||
name: string;
|
||||
description: string;
|
||||
allowDuplicateCheckIn: boolean;
|
||||
publicLinkEnabled: boolean;
|
||||
audienceMode: AttendanceAudienceMode;
|
||||
includeStatuses: AttendanceMembershipStatus[];
|
||||
includeTags: string[];
|
||||
includeParticipantListIds: string[];
|
||||
}
|
||||
|
||||
const emptyForm: FormState = {
|
||||
|
|
@ -50,6 +97,10 @@ const emptyForm: FormState = {
|
|||
description: "",
|
||||
allowDuplicateCheckIn: false,
|
||||
publicLinkEnabled: true,
|
||||
audienceMode: "all",
|
||||
includeStatuses: ["confirmed", "checked_in"],
|
||||
includeTags: [],
|
||||
includeParticipantListIds: [],
|
||||
};
|
||||
|
||||
export function AttendancePage() {
|
||||
|
|
@ -76,9 +127,28 @@ export function AttendancePage() {
|
|||
const [checkIns, setCheckIns] = useState<AttendanceCheckInRow[]>([]);
|
||||
const [checkInsTotal, setCheckInsTotal] = useState(0);
|
||||
const [checkInsLoading, setCheckInsLoading] = useState(false);
|
||||
const [eventTags, setEventTags] = useState<string[]>([]);
|
||||
const [contactLists, setContactLists] = useState<ParticipantListRecord[]>([]);
|
||||
|
||||
const selected = sessions.find((s) => s._id === selectedId) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedEventId || !panelOpen) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const [tagsRes, listsRes] = await Promise.all([
|
||||
participantsApi.listDistinctTagsForEvent(selectedEventId),
|
||||
participantListsApi.list(),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
if (tagsRes.data?.tags) setEventTags(tagsRes.data.tags);
|
||||
if (listsRes.data?.lists) setContactLists(listsRes.data.lists);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedEventId, panelOpen]);
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
if (!selectedEventId || !canReadAttendance) return;
|
||||
setLoading(true);
|
||||
|
|
@ -134,17 +204,71 @@ export function AttendancePage() {
|
|||
|
||||
const openEdit = (s: AttendanceRecord) => {
|
||||
setEditingId(s._id);
|
||||
const audience = s.settings?.audience;
|
||||
setForm({
|
||||
name: s.name,
|
||||
description: s.description || "",
|
||||
allowDuplicateCheckIn: Boolean(s.settings?.allowDuplicateCheckIn),
|
||||
publicLinkEnabled: s.settings?.publicLinkEnabled !== false,
|
||||
audienceMode: audience?.mode || "all",
|
||||
includeStatuses:
|
||||
audience?.statuses && audience.statuses.length
|
||||
? audience.statuses
|
||||
: ["confirmed", "checked_in"],
|
||||
includeTags: audience?.tags || [],
|
||||
includeParticipantListIds: (audience?.participantListIds || []).map(String),
|
||||
});
|
||||
setPanelOpen(true);
|
||||
};
|
||||
|
||||
const toggleStatus = (status: AttendanceMembershipStatus) => {
|
||||
setForm((f) => {
|
||||
const has = f.includeStatuses.includes(status);
|
||||
return {
|
||||
...f,
|
||||
includeStatuses: has
|
||||
? f.includeStatuses.filter((s) => s !== status)
|
||||
: [...f.includeStatuses, status],
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const toggleTag = (tag: string) => {
|
||||
setForm((f) => {
|
||||
const has = f.includeTags.includes(tag);
|
||||
return {
|
||||
...f,
|
||||
includeTags: has ? f.includeTags.filter((t) => t !== tag) : [...f.includeTags, tag],
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const toggleList = (listId: string) => {
|
||||
setForm((f) => {
|
||||
const has = f.includeParticipantListIds.includes(listId);
|
||||
return {
|
||||
...f,
|
||||
includeParticipantListIds: has
|
||||
? f.includeParticipantListIds.filter((id) => id !== listId)
|
||||
: [...f.includeParticipantListIds, listId],
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const saveSession = async () => {
|
||||
if (!selectedEventId || !form.name.trim()) return;
|
||||
if (form.audienceMode === "statuses" && !form.includeStatuses.length) {
|
||||
setError("Select at least one status for the attendance list.");
|
||||
return;
|
||||
}
|
||||
if (form.audienceMode === "tags" && !form.includeTags.length) {
|
||||
setError("Select at least one tag for the attendance list.");
|
||||
return;
|
||||
}
|
||||
if (form.audienceMode === "participant_lists" && !form.includeParticipantListIds.length) {
|
||||
setError("Select at least one contact list for the attendance list.");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
|
|
@ -152,6 +276,13 @@ export function AttendancePage() {
|
|||
settings: {
|
||||
allowDuplicateCheckIn: form.allowDuplicateCheckIn,
|
||||
publicLinkEnabled: form.publicLinkEnabled,
|
||||
audience: {
|
||||
mode: form.audienceMode,
|
||||
statuses: form.audienceMode === "statuses" ? form.includeStatuses : [],
|
||||
tags: form.audienceMode === "tags" ? form.includeTags : [],
|
||||
participantListIds:
|
||||
form.audienceMode === "participant_lists" ? form.includeParticipantListIds : [],
|
||||
},
|
||||
},
|
||||
};
|
||||
const res = editingId
|
||||
|
|
@ -348,6 +479,9 @@ export function AttendancePage() {
|
|||
{s.totalCheckedIn ?? 0} check-in{(s.totalCheckedIn ?? 0) === 1 ? "" : "s"} ·{" "}
|
||||
{s.uniqueParticipants ?? 0} people
|
||||
</div>
|
||||
<div style={{ fontSize: "11px", color: "var(--muted)", marginTop: 4 }}>
|
||||
Includes: {audienceSummary(s)}
|
||||
</div>
|
||||
{s.status === "archived" && (
|
||||
<span style={{ fontSize: "10px", color: "var(--muted)" }}>Archived</span>
|
||||
)}
|
||||
|
|
@ -383,6 +517,9 @@ export function AttendancePage() {
|
|||
{selected.description}
|
||||
</p>
|
||||
)}
|
||||
<p style={{ margin: "6px 0 0", fontSize: "var(--text-sm)", color: "var(--muted)" }}>
|
||||
Includes: {audienceSummary(selected)}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
{canWriteAttendance && (
|
||||
|
|
@ -565,7 +702,9 @@ export function AttendancePage() {
|
|||
background: "var(--card)",
|
||||
borderRadius: "var(--radius-card)",
|
||||
width: "100%",
|
||||
maxWidth: 440,
|
||||
maxWidth: 520,
|
||||
maxHeight: "90vh",
|
||||
overflow: "auto",
|
||||
boxShadow: "var(--elevation-sm)",
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
|
|
@ -593,6 +732,163 @@ export function AttendancePage() {
|
|||
style={{ ...inputStyle, minHeight: 72, resize: "vertical" }}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<div style={{ ...labelStyle, marginBottom: 8 }}>Who to include in attendance list</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{AUDIENCE_MODE_OPTIONS.map((opt) => (
|
||||
<label
|
||||
key={opt.value}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: 8,
|
||||
fontSize: "var(--text-sm)",
|
||||
cursor: "pointer",
|
||||
padding: "8px 10px",
|
||||
borderRadius: "var(--radius-button)",
|
||||
border:
|
||||
form.audienceMode === opt.value
|
||||
? "1px solid var(--primary)"
|
||||
: "1px solid var(--border)",
|
||||
background:
|
||||
form.audienceMode === opt.value ? "var(--secondary)" : "transparent",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="audienceMode"
|
||||
checked={form.audienceMode === opt.value}
|
||||
onChange={() => setForm((f) => ({ ...f, audienceMode: opt.value }))}
|
||||
style={{ marginTop: 2 }}
|
||||
/>
|
||||
<span>
|
||||
<span style={{ fontWeight: 600 }}>{opt.label}</span>
|
||||
<span style={{ display: "block", fontSize: "11px", color: "var(--muted)" }}>
|
||||
{opt.hint}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{form.audienceMode === "statuses" && (
|
||||
<div>
|
||||
<div style={{ ...labelStyle, marginBottom: 8 }}>Statuses</div>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
|
||||
{MEMBERSHIP_STATUS_OPTIONS.map((opt) => (
|
||||
<label
|
||||
key={opt.value}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
fontSize: "var(--text-sm)",
|
||||
cursor: "pointer",
|
||||
padding: "6px 10px",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--radius-button)",
|
||||
background: form.includeStatuses.includes(opt.value)
|
||||
? "var(--secondary)"
|
||||
: "var(--card)",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.includeStatuses.includes(opt.value)}
|
||||
onChange={() => toggleStatus(opt.value)}
|
||||
/>
|
||||
{opt.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.audienceMode === "tags" && (
|
||||
<div>
|
||||
<div style={{ ...labelStyle, marginBottom: 8 }}>Tags</div>
|
||||
{eventTags.length === 0 ? (
|
||||
<p style={{ margin: 0, fontSize: "var(--text-sm)", color: "var(--muted)" }}>
|
||||
No tags found for this event yet.
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, maxHeight: 160, overflow: "auto" }}>
|
||||
{eventTags.map((tag) => (
|
||||
<label
|
||||
key={tag}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
fontSize: "var(--text-sm)",
|
||||
cursor: "pointer",
|
||||
padding: "6px 10px",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--radius-button)",
|
||||
background: form.includeTags.includes(tag)
|
||||
? "var(--secondary)"
|
||||
: "var(--card)",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.includeTags.includes(tag)}
|
||||
onChange={() => toggleTag(tag)}
|
||||
/>
|
||||
{tag}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.audienceMode === "participant_lists" && (
|
||||
<div>
|
||||
<div style={{ ...labelStyle, marginBottom: 8 }}>Contact lists</div>
|
||||
{contactLists.length === 0 ? (
|
||||
<p style={{ margin: 0, fontSize: "var(--text-sm)", color: "var(--muted)" }}>
|
||||
No contact lists available.
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6, maxHeight: 180, overflow: "auto" }}>
|
||||
{contactLists.map((list) => (
|
||||
<label
|
||||
key={list._id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
fontSize: "var(--text-sm)",
|
||||
cursor: "pointer",
|
||||
padding: "8px 10px",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--radius-button)",
|
||||
background: form.includeParticipantListIds.includes(list._id)
|
||||
? "var(--secondary)"
|
||||
: "var(--card)",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.includeParticipantListIds.includes(list._id)}
|
||||
onChange={() => toggleList(list._id)}
|
||||
/>
|
||||
<span>
|
||||
<span style={{ fontWeight: 500 }}>{list.name}</span>
|
||||
<span style={{ color: "var(--muted)", marginLeft: 6, fontSize: "11px" }}>
|
||||
{list.type === "fixed" ? "Fixed" : "Rule-based"}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 8, fontSize: "var(--text-sm)", cursor: "pointer" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
|
|
|
|||
|
|
@ -1,100 +1,17 @@
|
|||
import React, { useState, useRef, useEffect } from "react";
|
||||
import type { LoginResult } from "../../contexts/AuthContext";
|
||||
import svgPaths from "../../../imports/svg-n2hu9pt0vm";
|
||||
import { Eye, EyeOff, Calendar, Users, BarChart2 } from "lucide-react";
|
||||
import { EventBuzzBrandLogo } from "../EventBuzzBrandLogo";
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
BRAND CONSTANTS (kept only for gradient / orange-buzz – not for
|
||||
BRAND CONSTANTS (kept only for gradient – not for
|
||||
anything that the design system already covers via CSS vars)
|
||||
───────────────────────────────────────────────────────────────── */
|
||||
const BRAND_GRADIENT =
|
||||
"linear-gradient(135deg, rgb(11,77,209) 0%, rgb(29,102,239) 45%, rgb(107,53,210) 100%)";
|
||||
const BUZZ_ORANGE = "#EF8D1D"; // buzz wordmark – brand constant, no CSS var
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
SHARED: EventBuzz Logo
|
||||
variant="light" → Inter/event wordmark white, buzz orange, icon white+orange
|
||||
variant="color" → Inter/event wordmark in --foreground (#0F3072), buzz orange
|
||||
───────────────────────────────────────────────────────────────── */
|
||||
function EventBuzzLogo({
|
||||
variant = "color",
|
||||
scale = 1,
|
||||
}: {
|
||||
variant?: "light" | "color";
|
||||
scale?: number;
|
||||
}) {
|
||||
const wc = variant === "light" ? "white" : "#0F3072"; /* --foreground value */
|
||||
const w = Math.round(320 * scale);
|
||||
const h = Math.round(60 * scale);
|
||||
return (
|
||||
<div style={{ position: "relative", width: w, height: h, flexShrink: 0 }}>
|
||||
{/* ── icon cluster ── */}
|
||||
<svg
|
||||
style={{ position: "absolute", left: 0, top: 0 }}
|
||||
fill="none"
|
||||
preserveAspectRatio="none"
|
||||
viewBox="0 0 79.3139 60.1372"
|
||||
width={Math.round(79 * scale)}
|
||||
height={Math.round(60 * scale)}
|
||||
>
|
||||
<path d={svgPaths.pd494300} fill={BUZZ_ORANGE} />
|
||||
<path d={svgPaths.p33b7c800} fill={BUZZ_ORANGE} />
|
||||
<path d={svgPaths.p142b6380} fill={wc} />
|
||||
<path d={svgPaths.p2c694f80} fill={wc} />
|
||||
<circle cx="39.8711" cy="7.36343" r="6.01221" fill={wc} transform="rotate(75 39.8711 7.36343)" />
|
||||
<circle cx="17.7246" cy="12.6819" r="5.28744" fill={BUZZ_ORANGE} transform="rotate(75 17.7246 12.6819)" />
|
||||
<circle cx="5.28744" cy="5.28744" r="5.28744" fill={BUZZ_ORANGE}
|
||||
transform="matrix(-0.258819 0.965926 0.965926 0.258819 58.2047 6.20616)" />
|
||||
<path d={svgPaths.p1674a600} fill={wc} />
|
||||
<path d={svgPaths.p6970200} fill={wc} />
|
||||
<path d={svgPaths.p37f9b00} fill={wc} />
|
||||
</svg>
|
||||
|
||||
{/* ── "event" wordmark ── */}
|
||||
<svg
|
||||
style={{ position: "absolute", left: Math.round(91 * scale), top: Math.round(13 * scale) }}
|
||||
fill="none"
|
||||
preserveAspectRatio="none"
|
||||
viewBox="0 0 106.275 33.7343"
|
||||
width={Math.round(106 * scale)}
|
||||
height={Math.round(34 * scale)}
|
||||
>
|
||||
<path d={svgPaths.pdd63b00} fill={wc} />
|
||||
<path d={svgPaths.p1945f480} fill={wc} />
|
||||
<path d={svgPaths.p48e8300} fill={wc} />
|
||||
<path d={svgPaths.p3763d600} fill={wc} />
|
||||
<path d={svgPaths.p6535700} fill={wc} />
|
||||
</svg>
|
||||
|
||||
{/* ── "buzz" wordmark ── */}
|
||||
<svg
|
||||
style={{ position: "absolute", left: Math.round(197 * scale), top: Math.round(8 * scale) }}
|
||||
fill="none"
|
||||
preserveAspectRatio="none"
|
||||
viewBox="0 0 105.33 38.8275"
|
||||
width={Math.round(106 * scale)}
|
||||
height={Math.round(39 * scale)}
|
||||
>
|
||||
<path d={svgPaths.p30ec5580} fill={BUZZ_ORANGE} />
|
||||
<path d={svgPaths.p2fc98c00} fill={BUZZ_ORANGE} />
|
||||
<path d={svgPaths.p10df6080} fill={BUZZ_ORANGE} />
|
||||
<path d={svgPaths.p24dd2380} fill={BUZZ_ORANGE} />
|
||||
</svg>
|
||||
|
||||
{/* ── wifi arcs ── */}
|
||||
<svg
|
||||
style={{ position: "absolute", left: Math.round(298 * scale), top: Math.round(9 * scale) }}
|
||||
fill="none"
|
||||
preserveAspectRatio="none"
|
||||
viewBox="0 0 21.5276 21.5276"
|
||||
width={Math.round(22 * scale)}
|
||||
height={Math.round(22 * scale)}
|
||||
>
|
||||
<path d={svgPaths.p1e5a9f00} fill={BUZZ_ORANGE} />
|
||||
<path d={svgPaths.p5358df0} fill={BUZZ_ORANGE} />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
function EventBuzzLogo({ scale = 1 }: { variant?: "light" | "color"; scale?: number }) {
|
||||
return <EventBuzzBrandLogo height={Math.round(48 * scale)} maxWidth={Math.round(240 * scale)} />;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export function EventBadgesPanel({
|
|||
const [badges, setBadges] = useState<BadgeBatchRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [downloadingId, setDownloadingId] = useState<string | null>(null);
|
||||
|
||||
const loadBadges = useCallback(async () => {
|
||||
setLoading(true);
|
||||
|
|
@ -297,10 +298,26 @@ export function EventBadgesPanel({
|
|||
</button>
|
||||
) : null}
|
||||
{b.status === "ready" && b.zipUrl ? (
|
||||
<a
|
||||
href={b.zipUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<button
|
||||
type="button"
|
||||
disabled={downloadingId === b._id}
|
||||
onClick={async () => {
|
||||
setDownloadingId(b._id);
|
||||
setError("");
|
||||
try {
|
||||
const org =
|
||||
typeof b.organization === "string"
|
||||
? b.organization
|
||||
: undefined;
|
||||
await badgeBatchesApi.downloadZip(b, org);
|
||||
} catch (e) {
|
||||
setError(
|
||||
e instanceof Error ? e.message : "Download failed.",
|
||||
);
|
||||
} finally {
|
||||
setDownloadingId(null);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
|
|
@ -309,14 +326,17 @@ export function EventBadgesPanel({
|
|||
borderRadius: "var(--radius-button)",
|
||||
background: "var(--primary)",
|
||||
color: "var(--primary-foreground)",
|
||||
textDecoration: "none",
|
||||
border: "none",
|
||||
fontSize: "var(--text-xs)",
|
||||
fontFamily: "var(--font-noto-sans)",
|
||||
fontWeight: "var(--font-weight-medium)",
|
||||
cursor: downloadingId === b._id ? "wait" : "pointer",
|
||||
opacity: downloadingId === b._id ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
<Download size={13} /> Download zip
|
||||
</a>
|
||||
<Download size={13} />{" "}
|
||||
{downloadingId === b._id ? "Downloading…" : "Download zip"}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,367 @@
|
|||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Pencil, Plus, Search, Store, Trash2 } from "lucide-react";
|
||||
import { exhibitorsApi, type ApiExhibitor } from "../../../services/api";
|
||||
import { PanelShell } from "../panels/PanelShell";
|
||||
|
||||
const btnSecondary: React.CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "6px 10px",
|
||||
borderRadius: "var(--radius-button)",
|
||||
background: "var(--secondary)",
|
||||
color: "var(--card-foreground)",
|
||||
border: "1px solid var(--border)",
|
||||
cursor: "pointer",
|
||||
fontSize: "var(--text-xs)",
|
||||
fontFamily: "var(--font-noto-sans)",
|
||||
};
|
||||
|
||||
const btnDanger: React.CSSProperties = {
|
||||
...btnSecondary,
|
||||
color: "var(--destructive)",
|
||||
borderColor: "color-mix(in srgb, var(--destructive) 45%, var(--border))",
|
||||
};
|
||||
|
||||
function statusLabel(status?: ApiExhibitor["status"]): string {
|
||||
if (status === "draft") return "Draft";
|
||||
if (status === "hidden") return "Hidden";
|
||||
return "Published";
|
||||
}
|
||||
|
||||
export function EventExhibitorsPanel({
|
||||
eventId,
|
||||
eventName,
|
||||
canWrite,
|
||||
onClose,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onChanged,
|
||||
}: {
|
||||
eventId: string;
|
||||
eventName?: string;
|
||||
canWrite?: boolean;
|
||||
onClose: () => void;
|
||||
onAdd: () => void;
|
||||
onEdit: (exhibitor: ApiExhibitor) => void;
|
||||
onChanged?: () => void;
|
||||
}) {
|
||||
const [exhibitors, setExhibitors] = useState<ApiExhibitor[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await exhibitorsApi.list(eventId);
|
||||
if (res.error) {
|
||||
setError(typeof res.error === "string" ? res.error : "Failed to load exhibitors.");
|
||||
setExhibitors([]);
|
||||
} else {
|
||||
setExhibitors(res.data?.exhibitors ?? []);
|
||||
}
|
||||
} catch {
|
||||
setError("Failed to load exhibitors.");
|
||||
setExhibitors([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [eventId]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return exhibitors;
|
||||
return exhibitors.filter((ex) => {
|
||||
const hay = [
|
||||
ex.name,
|
||||
ex.category,
|
||||
ex.boothNumber,
|
||||
ex.hall,
|
||||
ex.contactName,
|
||||
ex.contactEmail,
|
||||
ex.status,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return hay.includes(q);
|
||||
});
|
||||
}, [exhibitors, search]);
|
||||
|
||||
const remove = async (ex: ApiExhibitor) => {
|
||||
if (!window.confirm(`Delete exhibitor "${ex.name}"?`)) return;
|
||||
setDeletingId(ex._id);
|
||||
const res = await exhibitorsApi.remove(ex._id);
|
||||
setDeletingId(null);
|
||||
if (res.error) {
|
||||
setError(typeof res.error === "string" ? res.error : "Could not delete exhibitor.");
|
||||
return;
|
||||
}
|
||||
await load();
|
||||
onChanged?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<PanelShell
|
||||
onClose={onClose}
|
||||
title="Exhibitors"
|
||||
breadcrumb={eventName ? `Event Home · ${eventName}` : "Event Home · Exhibitors"}
|
||||
description="Manage companies exhibiting at this event."
|
||||
completedCount={0}
|
||||
totalSections={1}
|
||||
ctaLabel="Add exhibitor"
|
||||
ctaDisabled={!canWrite}
|
||||
onCtaClick={canWrite ? onAdd : undefined}
|
||||
showProgress={false}
|
||||
hidePrimaryCta={!canWrite}
|
||||
panelWidth="min(780px, 100vw)"
|
||||
>
|
||||
<div style={{ padding: "16px 20px", display: "flex", flexDirection: "column", gap: 14 }}>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
<div style={{ position: "relative", flex: 1 }}>
|
||||
<Search
|
||||
size={14}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 10,
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
color: "var(--muted)",
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search by name, booth, category…"
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px 8px 32px",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--radius-button)",
|
||||
background: "var(--input-background)",
|
||||
fontSize: "var(--text-sm)",
|
||||
fontFamily: "var(--font-noto-sans)",
|
||||
color: "var(--card-foreground)",
|
||||
outline: "none",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{canWrite ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAdd}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "8px 12px",
|
||||
borderRadius: "var(--radius-button)",
|
||||
border: "none",
|
||||
background: "var(--primary)",
|
||||
color: "var(--primary-foreground)",
|
||||
fontSize: "var(--text-xs)",
|
||||
fontFamily: "var(--font-noto-sans)",
|
||||
fontWeight: "var(--font-weight-medium)",
|
||||
cursor: "pointer",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
<Plus size={14} /> Add exhibitor
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", margin: 0 }}>
|
||||
Loading exhibitors…
|
||||
</p>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--destructive)", margin: 0 }}>
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{!loading && !error && filtered.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
textAlign: "center",
|
||||
padding: "48px 24px",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<Store size={36} style={{ color: "var(--border)" }} />
|
||||
<p
|
||||
style={{
|
||||
fontFamily: "var(--font-inter)",
|
||||
fontSize: "var(--text-base)",
|
||||
fontWeight: "var(--font-weight-medium)",
|
||||
color: "var(--foreground)",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{exhibitors.length === 0 ? "No exhibitors yet" : "No matches"}
|
||||
</p>
|
||||
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", margin: 0 }}>
|
||||
{exhibitors.length === 0
|
||||
? "Add companies exhibiting at this event to build the exhibitor directory."
|
||||
: "Try another search."}
|
||||
</p>
|
||||
{canWrite && exhibitors.length === 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAdd}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
marginTop: 4,
|
||||
padding: "8px 14px",
|
||||
borderRadius: "var(--radius-button)",
|
||||
border: "none",
|
||||
background: "var(--primary)",
|
||||
color: "var(--primary-foreground)",
|
||||
fontSize: "var(--text-sm)",
|
||||
fontFamily: "var(--font-noto-sans)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<Plus size={14} /> Add exhibitor
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!loading && filtered.length > 0 ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{filtered.map((ex) => (
|
||||
<div
|
||||
key={ex._id}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "minmax(0, 1fr) auto",
|
||||
gap: 12,
|
||||
alignItems: "center",
|
||||
padding: "12px 14px",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--radius-card)",
|
||||
background: "var(--card)",
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0, display: "flex", gap: 12, alignItems: "center" }}>
|
||||
{ex.logo ? (
|
||||
<img
|
||||
src={ex.logo}
|
||||
alt=""
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
objectFit: "contain",
|
||||
borderRadius: "var(--radius-button)",
|
||||
border: "1px solid var(--border)",
|
||||
background: "var(--secondary)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: "var(--radius-button)",
|
||||
background: "color-mix(in srgb, var(--primary) 12%, transparent)",
|
||||
color: "var(--primary)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Store size={18} />
|
||||
</div>
|
||||
)}
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontFamily: "var(--font-inter)",
|
||||
fontSize: "var(--text-sm)",
|
||||
fontWeight: "var(--font-weight-medium)",
|
||||
color: "var(--foreground)",
|
||||
}}
|
||||
>
|
||||
{ex.name}
|
||||
{ex.isFeatured ? (
|
||||
<span style={{ marginLeft: 6, fontSize: "var(--text-xs)", color: "var(--primary)" }}>
|
||||
Featured
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "11px",
|
||||
fontFamily: "var(--font-noto-sans)",
|
||||
padding: "2px 6px",
|
||||
borderRadius: 999,
|
||||
background: "var(--secondary)",
|
||||
color: "var(--muted)",
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
{statusLabel(ex.status)}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
margin: "4px 0 0",
|
||||
fontSize: "var(--text-xs)",
|
||||
fontFamily: "var(--font-noto-sans)",
|
||||
color: "var(--muted)",
|
||||
}}
|
||||
>
|
||||
{[
|
||||
ex.boothNumber ? `Booth ${ex.boothNumber}` : null,
|
||||
ex.hall || null,
|
||||
ex.category || null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ") || "No booth details yet"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{canWrite ? (
|
||||
<div style={{ display: "flex", gap: 6, flexShrink: 0 }}>
|
||||
<button type="button" style={btnSecondary} onClick={() => onEdit(ex)}>
|
||||
<Pencil size={12} /> Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
style={btnDanger}
|
||||
disabled={deletingId === ex._id}
|
||||
onClick={() => void remove(ex)}
|
||||
>
|
||||
<Trash2 size={12} /> {deletingId === ex._id ? "…" : "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</PanelShell>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { BarChart2, CalendarDays, Check, FileText, Globe, Link2, Mail, Plus, Share2, ShieldCheck, Tag, Ticket, Trophy, Upload } from "lucide-react";
|
||||
import { BarChart2, CalendarDays, Check, FileText, Globe, Link2, Mail, Plus, Share2, ShieldCheck, Store, Tag, Ticket, Trophy, Upload } from "lucide-react";
|
||||
import { useBreakpoint } from "../../hooks/useBreakpoint";
|
||||
import { useAppNav } from "../../contexts/AppNavContext";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
|
|
@ -20,11 +20,21 @@ import { CreateBadgePanel } from "../panels/CreateBadgePanel";
|
|||
import { EventBadgesPanel } from "./EventBadgesPanel";
|
||||
import { EventDocumentsPanel } from "./EventDocumentsPanel";
|
||||
import { EventEmailCampaignsPanel } from "./EventEmailCampaignsPanel";
|
||||
import { EventExhibitorsPanel } from "./EventExhibitorsPanel";
|
||||
import { AddExhibitorPanel } from "../panels/AddExhibitorPanel";
|
||||
import { AddQuizPollModal, buildQuizPollSettingsPayload } from "../quiz/AddQuizPollModal";
|
||||
import type { QuizPollModalForm } from "../quiz/AddQuizPollModal";
|
||||
import type { Question } from "../quiz/AddQuestionModal";
|
||||
import { modalQuestionToApi } from "../quiz/quizQuestionMappers";
|
||||
import { sessionsApi, tracksApi, formsApi, staticMicrositeApi, staticMicrositePublicUrl } from "../../../services/api";
|
||||
import {
|
||||
sessionsApi,
|
||||
tracksApi,
|
||||
formsApi,
|
||||
staticMicrositeApi,
|
||||
staticMicrositePublicUrl,
|
||||
exhibitorsApi,
|
||||
type ApiExhibitor,
|
||||
} from "../../../services/api";
|
||||
import type { FormWithSubmissionCount } from "../../../services/api/types";
|
||||
import { publicFormPageUrl } from "../../utils/publicFormUrl";
|
||||
|
||||
|
|
@ -222,6 +232,8 @@ export function EventHomeModules() {
|
|||
const canReadDocuments = canModule("content_management", "read");
|
||||
const canReadEmailCampaigns = canModule("email_outreach_campaigns", "read");
|
||||
const canWriteEmailCampaigns = canModule("email_outreach_campaigns", "write");
|
||||
const canReadExhibitors = canModule("exhibitors", "read");
|
||||
const canWriteExhibitors = canModule("exhibitors", "write");
|
||||
const bp = useBreakpoint();
|
||||
const isMobile = bp === "mobile";
|
||||
const isTablet = bp === "tablet";
|
||||
|
|
@ -267,6 +279,11 @@ export function EventHomeModules() {
|
|||
const [emailCampaignLoading, setEmailCampaignLoading] = useState(false);
|
||||
const [showCreateEmailCampaign, setShowCreateEmailCampaign] = useState(false);
|
||||
const [showEmailCampaignsPanel, setShowEmailCampaignsPanel] = useState(false);
|
||||
const [exhibitorCount, setExhibitorCount] = useState(0);
|
||||
const [exhibitorLoading, setExhibitorLoading] = useState(false);
|
||||
const [showExhibitorsPanel, setShowExhibitorsPanel] = useState(false);
|
||||
const [showAddExhibitor, setShowAddExhibitor] = useState(false);
|
||||
const [editingExhibitor, setEditingExhibitor] = useState<ApiExhibitor | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedEventId) {
|
||||
|
|
@ -495,6 +512,27 @@ export function EventHomeModules() {
|
|||
void loadEmailCampaignMeta();
|
||||
}, [loadEmailCampaignMeta]);
|
||||
|
||||
const loadExhibitorMeta = useCallback(async () => {
|
||||
if (!selectedEventId) {
|
||||
setExhibitorCount(0);
|
||||
setExhibitorLoading(false);
|
||||
return;
|
||||
}
|
||||
setExhibitorLoading(true);
|
||||
try {
|
||||
const res = await exhibitorsApi.list(selectedEventId);
|
||||
setExhibitorCount(res.data?.total ?? res.data?.exhibitors?.length ?? 0);
|
||||
} catch {
|
||||
setExhibitorCount(0);
|
||||
} finally {
|
||||
setExhibitorLoading(false);
|
||||
}
|
||||
}, [selectedEventId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadExhibitorMeta();
|
||||
}, [loadExhibitorMeta]);
|
||||
|
||||
const handleBadgeSuccess = () => {
|
||||
setShowAddBadge(false);
|
||||
setDuplicateBadgeBatchId(null);
|
||||
|
|
@ -578,6 +616,11 @@ export function EventHomeModules() {
|
|||
: emailCampaignLoading
|
||||
? "Loading…"
|
||||
: `${emailCampaignCount} email campaign${emailCampaignCount === 1 ? "" : "s"}`;
|
||||
const exhibitorMeta = !selectedEventId
|
||||
? "Select an event to manage exhibitors"
|
||||
: exhibitorLoading
|
||||
? "Loading…"
|
||||
: `${exhibitorCount} exhibitor${exhibitorCount === 1 ? "" : "s"}`;
|
||||
const webCheckInMeta = !selectedEventId
|
||||
? "Select an event to configure web check-in"
|
||||
: webCheckInLoading
|
||||
|
|
@ -1142,6 +1185,55 @@ export function EventHomeModules() {
|
|||
}
|
||||
/>
|
||||
) : null}
|
||||
{canReadExhibitors ? (
|
||||
<ModuleCard
|
||||
icon={<Store size={18} />}
|
||||
title="Exhibitors"
|
||||
meta={exhibitorMeta}
|
||||
description="Companies and booths for this event"
|
||||
actions={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={actionsDisabled}
|
||||
style={{
|
||||
...moduleButtonStyle(),
|
||||
opacity: actionsDisabled ? 0.55 : 1,
|
||||
cursor: actionsDisabled ? "not-allowed" : "pointer",
|
||||
}}
|
||||
onClick={() => setShowExhibitorsPanel(true)}
|
||||
onMouseEnter={(e) => {
|
||||
if (actionsDisabled) return;
|
||||
(e.currentTarget as HTMLElement).style.borderColor = "var(--primary)";
|
||||
(e.currentTarget as HTMLElement).style.color = "var(--primary)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (actionsDisabled) return;
|
||||
(e.currentTarget as HTMLElement).style.borderColor = "var(--border)";
|
||||
(e.currentTarget as HTMLElement).style.color = "var(--card-foreground)";
|
||||
}}
|
||||
>
|
||||
Manage
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={actionsDisabled || !canWriteExhibitors}
|
||||
style={{
|
||||
...moduleButtonStyle(true),
|
||||
opacity: actionsDisabled || !canWriteExhibitors ? 0.55 : 1,
|
||||
cursor: actionsDisabled || !canWriteExhibitors ? "not-allowed" : "pointer",
|
||||
}}
|
||||
onClick={() => {
|
||||
setEditingExhibitor(null);
|
||||
setShowAddExhibitor(true);
|
||||
}}
|
||||
>
|
||||
Add exhibitor
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{formShareTarget ? (
|
||||
|
|
@ -1259,6 +1351,43 @@ export function EventHomeModules() {
|
|||
onSuccess={() => void loadEmailCampaignMeta()}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showExhibitorsPanel && selectedEventId ? (
|
||||
<EventExhibitorsPanel
|
||||
eventId={selectedEventId}
|
||||
eventName={eventName ?? undefined}
|
||||
canWrite={canWriteExhibitors}
|
||||
onClose={() => setShowExhibitorsPanel(false)}
|
||||
onAdd={() => {
|
||||
setShowExhibitorsPanel(false);
|
||||
setEditingExhibitor(null);
|
||||
setShowAddExhibitor(true);
|
||||
}}
|
||||
onEdit={(ex) => {
|
||||
setShowExhibitorsPanel(false);
|
||||
setEditingExhibitor(ex);
|
||||
setShowAddExhibitor(true);
|
||||
}}
|
||||
onChanged={() => void loadExhibitorMeta()}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showAddExhibitor && selectedEventId ? (
|
||||
<AddExhibitorPanel
|
||||
key={editingExhibitor?._id ?? "create"}
|
||||
eventId={selectedEventId}
|
||||
eventName={eventName ?? undefined}
|
||||
exhibitor={editingExhibitor}
|
||||
onClose={() => {
|
||||
setShowAddExhibitor(false);
|
||||
setEditingExhibitor(null);
|
||||
}}
|
||||
onSuccess={() => {
|
||||
void loadExhibitorMeta();
|
||||
setShowExhibitorsPanel(true);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -180,6 +180,8 @@ export function DesignPage({ subPage }: { subPage?: string }) {
|
|||
const [showMicrosite, setShowMicrosite] = useState(false);
|
||||
const [showBadge, setShowBadge] = useState(false);
|
||||
const [duplicateBadgeBatchId, setDuplicateBadgeBatchId] = useState<string | null>(null);
|
||||
const [badgeDeleteConfirm, setBadgeDeleteConfirm] = useState<BadgeBatchRecord | null>(null);
|
||||
const [badgeDeleting, setBadgeDeleting] = useState(false);
|
||||
const [showEmailer, setShowEmailer] = useState(false);
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
const [editTemplateId, setEditTemplateId] = useState<string | null>(null);
|
||||
|
|
@ -340,6 +342,17 @@ export function DesignPage({ subPage }: { subPage?: string }) {
|
|||
if (!res.error) fetchDesignList();
|
||||
};
|
||||
|
||||
const handleBadgeDeleteConfirm = async () => {
|
||||
if (!badgeDeleteConfirm || badgeDeleting) return;
|
||||
const id = badgeDeleteConfirm._id;
|
||||
setBadgeDeleting(true);
|
||||
const res = await badgeBatchesApi.remove(id, badgeListOrgId);
|
||||
setBadgeDeleting(false);
|
||||
setBadgeDeleteConfirm(null);
|
||||
if (!res.error) fetchDesignList();
|
||||
else setError(res.error || "Failed to delete badge batch");
|
||||
};
|
||||
|
||||
const handleBrochurePdf = async (t: DesignTemplate, mode: "preview" | "download") => {
|
||||
if (apiType !== "brochure") return;
|
||||
setBrochurePdfBusyId(t._id);
|
||||
|
|
@ -651,6 +664,31 @@ export function DesignPage({ subPage }: { subPage?: string }) {
|
|||
<Copy size={13} /> Duplicate
|
||||
</button>
|
||||
)}
|
||||
{canManageBadgeBatch && (
|
||||
<button
|
||||
type="button"
|
||||
title={b.status === "processing" ? "Cannot delete while processing" : "Delete badge export"}
|
||||
disabled={b.status === "processing" || badgeDeleting}
|
||||
onClick={() => setBadgeDeleteConfirm(b)}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
padding: "6px 12px",
|
||||
borderRadius: "var(--radius-button)",
|
||||
border: "1px solid var(--border)",
|
||||
background: "var(--card)",
|
||||
color: b.status === "processing" ? "var(--muted)" : "var(--destructive)",
|
||||
fontSize: "var(--text-xs)",
|
||||
fontFamily: "var(--font-noto-sans)",
|
||||
fontWeight: "var(--font-weight-medium)",
|
||||
cursor: b.status === "processing" || badgeDeleting ? "not-allowed" : "pointer",
|
||||
opacity: b.status === "processing" ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
<Trash2 size={13} /> Delete
|
||||
</button>
|
||||
)}
|
||||
{b.status === "ready" && b.zipUrl ? (
|
||||
<a
|
||||
href={b.zipUrl}
|
||||
|
|
@ -873,6 +911,18 @@ export function DesignPage({ subPage }: { subPage?: string }) {
|
|||
variant="danger"
|
||||
/>
|
||||
)}
|
||||
{badgeDeleteConfirm && (
|
||||
<ConfirmModal
|
||||
title="Delete badge export?"
|
||||
message={`Delete "${badgeDeleteConfirm.name}"? The generated zip will be removed. This cannot be undone.`}
|
||||
confirmLabel={badgeDeleting ? "Deleting…" : "Delete"}
|
||||
onConfirm={handleBadgeDeleteConfirm}
|
||||
onCancel={() => {
|
||||
if (!badgeDeleting) setBadgeDeleteConfirm(null);
|
||||
}}
|
||||
variant="danger"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1313,6 +1313,7 @@ function EmailDeliverySection() {
|
|||
const [hasPassword, setHasPassword] = useState(false);
|
||||
const [fromName, setFromName] = useState("");
|
||||
const [fromAddress, setFromAddress] = useState("");
|
||||
const [replyTo, setReplyTo] = useState("");
|
||||
const [testTo, setTestTo] = useState("");
|
||||
const [msg, setMsg] = useState("");
|
||||
const [err, setErr] = useState("");
|
||||
|
|
@ -1333,6 +1334,7 @@ function EmailDeliverySection() {
|
|||
setHasPassword(d.hasPassword);
|
||||
setFromName(d.fromName || "");
|
||||
setFromAddress(d.fromAddress || "");
|
||||
setReplyTo(d.replyTo || "");
|
||||
}
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : "Failed to load");
|
||||
|
|
@ -1358,6 +1360,7 @@ function EmailDeliverySection() {
|
|||
password: password || undefined,
|
||||
fromName,
|
||||
fromAddress,
|
||||
replyTo,
|
||||
});
|
||||
if (r.error) throw new Error(r.error);
|
||||
setPassword("");
|
||||
|
|
@ -1388,7 +1391,10 @@ function EmailDeliverySection() {
|
|||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "16px", maxWidth: 520 }}>
|
||||
<Card title="SMTP (bring your own)" subtitle="Campaign and system emails use this server when enabled. Password is stored encrypted on the server.">
|
||||
<Card
|
||||
title="SMTP (bring your own)"
|
||||
subtitle="Turn on Use custom SMTP to send via your own mail server. From name and Reply-To are used for organisation emails even when custom SMTP is off (mail still goes through the platform SMTP)."
|
||||
>
|
||||
{err && <p style={{ color: "var(--destructive)", fontSize: "var(--text-sm)", margin: 0 }}>{err}</p>}
|
||||
{msg && <p style={{ color: "#15803d", fontSize: "var(--text-sm)", margin: 0 }}>{msg}</p>}
|
||||
<ToggleRow value={enabled} onChange={setEnabled} label="Use custom SMTP" />
|
||||
|
|
@ -1438,12 +1444,41 @@ function EmailDeliverySection() {
|
|||
<Field>
|
||||
<Label text="From name" />
|
||||
<input style={IS} value={fromName} onChange={(e) => setFromName(e.target.value)} onFocus={focusIn} onBlur={focusOut} />
|
||||
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: "4px 0 0" }}>
|
||||
Used even when custom SMTP is off.
|
||||
</p>
|
||||
</Field>
|
||||
<Field>
|
||||
<Label text="From email" required />
|
||||
<input style={IS} value={fromAddress} onChange={(e) => setFromAddress(e.target.value)} placeholder="hello@yourdomain.com" onFocus={focusIn} onBlur={focusOut} />
|
||||
<Label text="From email" required={enabled} />
|
||||
<input
|
||||
style={IS}
|
||||
value={fromAddress}
|
||||
onChange={(e) => setFromAddress(e.target.value)}
|
||||
placeholder="hello@yourdomain.com"
|
||||
onFocus={focusIn}
|
||||
onBlur={focusOut}
|
||||
disabled={!enabled}
|
||||
/>
|
||||
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: "4px 0 0" }}>
|
||||
Required when custom SMTP is on. Ignored when off (platform From email is used).
|
||||
</p>
|
||||
</Field>
|
||||
</Grid2>
|
||||
<Field>
|
||||
<Label text="Reply-To email" />
|
||||
<input
|
||||
style={IS}
|
||||
type="email"
|
||||
value={replyTo}
|
||||
onChange={(e) => setReplyTo(e.target.value)}
|
||||
placeholder="replies@yourdomain.com"
|
||||
onFocus={focusIn}
|
||||
onBlur={focusOut}
|
||||
/>
|
||||
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: "4px 0 0" }}>
|
||||
Used even when custom SMTP is off. When custom SMTP is on and this is blank, defaults to From email.
|
||||
</p>
|
||||
</Field>
|
||||
<SaveAction onSave={save} disabled={enabled && (!host.trim() || !fromAddress.trim())} />
|
||||
<Divider />
|
||||
<Field>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,513 @@
|
|||
import React, { useMemo, useRef, useState } from "react";
|
||||
import { ChevronDown, Upload } from "lucide-react";
|
||||
import {
|
||||
exhibitorsApi,
|
||||
uploadMediaFile,
|
||||
type ApiExhibitor,
|
||||
type ExhibitorPayload,
|
||||
type ExhibitorStatus,
|
||||
} from "../../../services/api";
|
||||
import { PanelShell } from "./PanelShell";
|
||||
|
||||
const IS: React.CSSProperties = {
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--radius-button)",
|
||||
background: "var(--input-background)",
|
||||
fontSize: "var(--text-sm)",
|
||||
fontFamily: "var(--font-noto-sans)",
|
||||
color: "var(--card-foreground)",
|
||||
outline: "none",
|
||||
boxSizing: "border-box",
|
||||
};
|
||||
|
||||
const SS: React.CSSProperties = {
|
||||
...IS,
|
||||
cursor: "pointer",
|
||||
appearance: "none",
|
||||
paddingRight: "32px",
|
||||
};
|
||||
|
||||
const STATUS_OPTIONS: { value: ExhibitorStatus; label: string }[] = [
|
||||
{ value: "published", label: "Published" },
|
||||
{ value: "draft", label: "Draft" },
|
||||
{ value: "hidden", label: "Hidden" },
|
||||
];
|
||||
|
||||
type FormState = {
|
||||
name: string;
|
||||
status: ExhibitorStatus;
|
||||
logo: string;
|
||||
shortDescription: string;
|
||||
description: string;
|
||||
category: string;
|
||||
boothNumber: string;
|
||||
hall: string;
|
||||
website: string;
|
||||
isFeatured: boolean;
|
||||
contactName: string;
|
||||
contactEmail: string;
|
||||
contactPhone: string;
|
||||
linkedin: string;
|
||||
twitter: string;
|
||||
instagram: string;
|
||||
socialWebsite: string;
|
||||
productsText: string;
|
||||
internalNotes: string;
|
||||
};
|
||||
|
||||
function fromExhibitor(ex?: ApiExhibitor | null): FormState {
|
||||
return {
|
||||
name: ex?.name || "",
|
||||
status: ex?.status || "published",
|
||||
logo: ex?.logo || "",
|
||||
shortDescription: ex?.shortDescription || "",
|
||||
description: ex?.description || "",
|
||||
category: ex?.category || "",
|
||||
boothNumber: ex?.boothNumber || "",
|
||||
hall: ex?.hall || "",
|
||||
website: ex?.website || "",
|
||||
isFeatured: Boolean(ex?.isFeatured),
|
||||
contactName: ex?.contactName || "",
|
||||
contactEmail: ex?.contactEmail || "",
|
||||
contactPhone: ex?.contactPhone || "",
|
||||
linkedin: ex?.socialLinks?.linkedin || "",
|
||||
twitter: ex?.socialLinks?.twitter || "",
|
||||
instagram: ex?.socialLinks?.instagram || "",
|
||||
socialWebsite: ex?.socialLinks?.website || "",
|
||||
productsText: Array.isArray(ex?.products) ? ex!.products!.join(", ") : "",
|
||||
internalNotes: ex?.internalNotes || "",
|
||||
};
|
||||
}
|
||||
|
||||
function Label({ text, required }: { text: string; required?: boolean }) {
|
||||
return (
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
fontSize: "var(--text-sm)",
|
||||
fontFamily: "var(--font-noto-sans)",
|
||||
fontWeight: "var(--font-weight-medium)",
|
||||
color: "var(--card-foreground)",
|
||||
marginBottom: "5px",
|
||||
}}
|
||||
>
|
||||
{required ? <span style={{ color: "var(--destructive)", marginRight: 3 }}>*</span> : null}
|
||||
{text}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ children, error }: { children: React.ReactNode; error?: string }) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column" }}>
|
||||
{children}
|
||||
{error ? (
|
||||
<p
|
||||
style={{
|
||||
fontSize: "var(--text-xs)",
|
||||
fontFamily: "var(--font-noto-sans)",
|
||||
color: "var(--destructive)",
|
||||
margin: "5px 0 0",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectWrap({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
{children}
|
||||
<ChevronDown
|
||||
size={13}
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 10,
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
color: "var(--muted)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AddExhibitorPanel({
|
||||
eventId,
|
||||
eventName,
|
||||
exhibitor,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: {
|
||||
eventId: string;
|
||||
eventName?: string;
|
||||
exhibitor?: ApiExhibitor | null;
|
||||
onClose: () => void;
|
||||
onSuccess?: () => void;
|
||||
}) {
|
||||
const isEdit = Boolean(exhibitor?._id);
|
||||
const [form, setForm] = useState<FormState>(() => fromExhibitor(exhibitor));
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveErr, setSaveErr] = useState<string | null>(null);
|
||||
const [uploadingLogo, setUploadingLogo] = useState(false);
|
||||
const [showOps, setShowOps] = useState(Boolean(exhibitor?.internalNotes));
|
||||
const logoInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const set = <K extends keyof FormState>(key: K, value: FormState[K]) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
if (errors[key]) setErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const completedCount = useMemo(() => (form.name.trim() ? 1 : 0), [form.name]);
|
||||
|
||||
const buildPayload = (): ExhibitorPayload => {
|
||||
const products = form.productsText
|
||||
.split(/[,;\n]/)
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
return {
|
||||
name: form.name.trim(),
|
||||
status: form.status,
|
||||
logo: form.logo.trim() || undefined,
|
||||
shortDescription: form.shortDescription.trim() || undefined,
|
||||
description: form.description.trim() || undefined,
|
||||
category: form.category.trim() || undefined,
|
||||
boothNumber: form.boothNumber.trim() || undefined,
|
||||
hall: form.hall.trim() || undefined,
|
||||
website: form.website.trim() || undefined,
|
||||
isFeatured: form.isFeatured,
|
||||
contactName: form.contactName.trim() || undefined,
|
||||
contactEmail: form.contactEmail.trim() || undefined,
|
||||
contactPhone: form.contactPhone.trim() || undefined,
|
||||
socialLinks: {
|
||||
linkedin: form.linkedin.trim() || undefined,
|
||||
twitter: form.twitter.trim() || undefined,
|
||||
instagram: form.instagram.trim() || undefined,
|
||||
website: form.socialWebsite.trim() || undefined,
|
||||
},
|
||||
products,
|
||||
internalNotes: form.internalNotes.trim() || undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const validate = () => {
|
||||
const next: Record<string, string> = {};
|
||||
if (!form.name.trim()) next.name = "Company / brand name is required.";
|
||||
if (form.contactEmail.trim() && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.contactEmail.trim())) {
|
||||
next.contactEmail = "Enter a valid email.";
|
||||
}
|
||||
setErrors(next);
|
||||
return Object.keys(next).length === 0;
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
setSaveErr(null);
|
||||
if (!validate()) return;
|
||||
setSaving(true);
|
||||
const payload = buildPayload();
|
||||
const res = isEdit
|
||||
? await exhibitorsApi.update(exhibitor!._id, payload)
|
||||
: await exhibitorsApi.create(eventId, payload);
|
||||
setSaving(false);
|
||||
if (res.error) {
|
||||
setSaveErr(typeof res.error === "string" ? res.error : "Could not save exhibitor.");
|
||||
return;
|
||||
}
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const uploadLogo = async (file: File | null) => {
|
||||
if (!file) return;
|
||||
setUploadingLogo(true);
|
||||
setSaveErr(null);
|
||||
try {
|
||||
const res = await uploadMediaFile(file, { eventId, module: "exhibitors/logo" });
|
||||
if (!res?.url) throw new Error("Upload did not return a URL");
|
||||
set("logo", res.url);
|
||||
} catch (e) {
|
||||
setSaveErr(e instanceof Error ? e.message : "Logo upload failed.");
|
||||
} finally {
|
||||
setUploadingLogo(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PanelShell
|
||||
onClose={onClose}
|
||||
title={isEdit ? "Edit exhibitor" : "Add exhibitor"}
|
||||
breadcrumb={eventName ? `Event Home · ${eventName}` : "Event Home · Exhibitors"}
|
||||
description="Company listing for this event’s exhibitor hall."
|
||||
completedCount={completedCount}
|
||||
totalSections={1}
|
||||
ctaLabel={saving ? "Saving…" : isEdit ? "Save changes" : "Add exhibitor"}
|
||||
ctaDisabled={saving || uploadingLogo}
|
||||
onCtaClick={() => void submit()}
|
||||
showProgress={false}
|
||||
panelWidth="min(640px, 100vw)"
|
||||
>
|
||||
<div style={{ padding: "16px 20px", display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
{saveErr ? (
|
||||
<p style={{ margin: 0, fontSize: "var(--text-sm)", color: "var(--destructive)", fontFamily: "var(--font-noto-sans)" }}>
|
||||
{saveErr}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Field error={errors.name}>
|
||||
<Label text="Company / brand name" required />
|
||||
<input value={form.name} onChange={(e) => set("name", e.target.value)} style={IS} placeholder="e.g. Acme Devices" />
|
||||
</Field>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
|
||||
<Field>
|
||||
<Label text="Status" />
|
||||
<SelectWrap>
|
||||
<select value={form.status} onChange={(e) => set("status", e.target.value as ExhibitorStatus)} style={SS}>
|
||||
{STATUS_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</SelectWrap>
|
||||
</Field>
|
||||
<Field>
|
||||
<Label text="Category / industry" />
|
||||
<input
|
||||
value={form.category}
|
||||
onChange={(e) => set("category", e.target.value)}
|
||||
style={IS}
|
||||
placeholder="e.g. Pharma, Devices"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field>
|
||||
<Label text="Logo" />
|
||||
<div style={{ display: "flex", gap: 10, alignItems: "center" }}>
|
||||
{form.logo ? (
|
||||
<img
|
||||
src={form.logo}
|
||||
alt=""
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
objectFit: "contain",
|
||||
borderRadius: "var(--radius-button)",
|
||||
border: "1px solid var(--border)",
|
||||
background: "var(--secondary)",
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
disabled={uploadingLogo}
|
||||
onClick={() => logoInputRef.current?.click()}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "8px 12px",
|
||||
borderRadius: "var(--radius-button)",
|
||||
border: "1px dashed var(--border)",
|
||||
background: "transparent",
|
||||
cursor: uploadingLogo ? "wait" : "pointer",
|
||||
fontSize: "var(--text-sm)",
|
||||
fontFamily: "var(--font-noto-sans)",
|
||||
color: "var(--primary)",
|
||||
}}
|
||||
>
|
||||
<Upload size={14} /> {uploadingLogo ? "Uploading…" : form.logo ? "Replace logo" : "Upload logo"}
|
||||
</button>
|
||||
<input
|
||||
ref={logoInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0] || null;
|
||||
e.target.value = "";
|
||||
void uploadLogo(file);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
value={form.logo}
|
||||
onChange={(e) => set("logo", e.target.value)}
|
||||
style={{ ...IS, marginTop: 8 }}
|
||||
placeholder="Or paste logo URL"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<Label text="Short description" />
|
||||
<input
|
||||
value={form.shortDescription}
|
||||
onChange={(e) => set("shortDescription", e.target.value)}
|
||||
style={IS}
|
||||
placeholder="1–2 lines for cards"
|
||||
maxLength={180}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<Label text="About / full description" />
|
||||
<textarea
|
||||
value={form.description}
|
||||
onChange={(e) => set("description", e.target.value)}
|
||||
rows={4}
|
||||
style={{ ...IS, resize: "vertical", minHeight: 96 }}
|
||||
placeholder="Longer company overview for attendees"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
|
||||
<Field>
|
||||
<Label text="Booth number" />
|
||||
<input value={form.boothNumber} onChange={(e) => set("boothNumber", e.target.value)} style={IS} placeholder="e.g. A12" />
|
||||
</Field>
|
||||
<Field>
|
||||
<Label text="Hall / zone / floor" />
|
||||
<input value={form.hall} onChange={(e) => set("hall", e.target.value)} style={IS} placeholder="e.g. Hall B" />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field>
|
||||
<Label text="Website" />
|
||||
<input value={form.website} onChange={(e) => set("website", e.target.value)} style={IS} placeholder="https://" />
|
||||
</Field>
|
||||
|
||||
<label
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
fontSize: "var(--text-sm)",
|
||||
fontFamily: "var(--font-noto-sans)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<input type="checkbox" checked={form.isFeatured} onChange={(e) => set("isFeatured", e.target.checked)} />
|
||||
Featured exhibitor
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<p
|
||||
style={{
|
||||
margin: "0 0 10px",
|
||||
fontSize: "var(--text-sm)",
|
||||
fontWeight: "var(--font-weight-medium)",
|
||||
fontFamily: "var(--font-inter)",
|
||||
}}
|
||||
>
|
||||
Primary contact
|
||||
</p>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
|
||||
<Field>
|
||||
<Label text="Contact name" />
|
||||
<input value={form.contactName} onChange={(e) => set("contactName", e.target.value)} style={IS} />
|
||||
</Field>
|
||||
<Field error={errors.contactEmail}>
|
||||
<Label text="Contact email" />
|
||||
<input
|
||||
type="email"
|
||||
value={form.contactEmail}
|
||||
onChange={(e) => set("contactEmail", e.target.value)}
|
||||
style={IS}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Field>
|
||||
<Label text="Contact phone" />
|
||||
<input value={form.contactPhone} onChange={(e) => set("contactPhone", e.target.value)} style={IS} />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p
|
||||
style={{
|
||||
margin: "0 0 10px",
|
||||
fontSize: "var(--text-sm)",
|
||||
fontWeight: "var(--font-weight-medium)",
|
||||
fontFamily: "var(--font-inter)",
|
||||
}}
|
||||
>
|
||||
Social links
|
||||
</p>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
|
||||
<Field>
|
||||
<Label text="LinkedIn" />
|
||||
<input value={form.linkedin} onChange={(e) => set("linkedin", e.target.value)} style={IS} />
|
||||
</Field>
|
||||
<Field>
|
||||
<Label text="Twitter / X" />
|
||||
<input value={form.twitter} onChange={(e) => set("twitter", e.target.value)} style={IS} />
|
||||
</Field>
|
||||
<Field>
|
||||
<Label text="Instagram" />
|
||||
<input value={form.instagram} onChange={(e) => set("instagram", e.target.value)} style={IS} />
|
||||
</Field>
|
||||
<Field>
|
||||
<Label text="Social website" />
|
||||
<input value={form.socialWebsite} onChange={(e) => set("socialWebsite", e.target.value)} style={IS} />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Field>
|
||||
<Label text="Products / offerings" />
|
||||
<input
|
||||
value={form.productsText}
|
||||
onChange={(e) => set("productsText", e.target.value)}
|
||||
style={IS}
|
||||
placeholder="Comma-separated tags"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowOps((v) => !v)}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
padding: 0,
|
||||
cursor: "pointer",
|
||||
fontSize: "var(--text-sm)",
|
||||
fontFamily: "var(--font-noto-sans)",
|
||||
color: "var(--primary)",
|
||||
marginBottom: showOps ? 10 : 0,
|
||||
}}
|
||||
>
|
||||
{showOps ? "Hide internal notes" : "Show internal notes (staff only)"}
|
||||
</button>
|
||||
{showOps ? (
|
||||
<Field>
|
||||
<Label text="Internal notes" />
|
||||
<textarea
|
||||
value={form.internalNotes}
|
||||
onChange={(e) => set("internalNotes", e.target.value)}
|
||||
rows={3}
|
||||
style={{ ...IS, resize: "vertical" }}
|
||||
placeholder="Not shown to attendees"
|
||||
/>
|
||||
</Field>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</PanelShell>
|
||||
);
|
||||
}
|
||||
|
|
@ -61,6 +61,7 @@ export function BadgeDesignerPreview({
|
|||
aspectRatio,
|
||||
backgroundColor,
|
||||
backgroundImageUrl,
|
||||
backgroundIsPdf = false,
|
||||
backgroundImageLayout,
|
||||
layoutElements,
|
||||
customImages,
|
||||
|
|
@ -76,6 +77,8 @@ export function BadgeDesignerPreview({
|
|||
badgeAspectRatio: number;
|
||||
backgroundColor: string;
|
||||
backgroundImageUrl: string;
|
||||
/** True when background is a PDF template (blob upload or .pdf URL). */
|
||||
backgroundIsPdf?: boolean;
|
||||
backgroundImageLayout: BadgeBackgroundImageLayout;
|
||||
layoutElements: BadgeLayoutElement[];
|
||||
customImages: { id: string; imageUrl: string }[];
|
||||
|
|
@ -312,17 +315,57 @@ export function BadgeDesignerPreview({
|
|||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={backgroundImageUrl}
|
||||
alt=""
|
||||
draggable={false}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
{backgroundIsPdf ? (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
position: "relative",
|
||||
pointerEvents: "none",
|
||||
background: "rgba(15,23,42,0.04)",
|
||||
}}
|
||||
>
|
||||
<object
|
||||
data={backgroundImageUrl}
|
||||
type="application/pdf"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
border: "none",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 12,
|
||||
textAlign: "center",
|
||||
fontSize: 11,
|
||||
color: "var(--muted)",
|
||||
fontFamily: "var(--font-noto-sans)",
|
||||
}}
|
||||
>
|
||||
PDF template — overlays previewed as fields
|
||||
</div>
|
||||
</object>
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={backgroundImageUrl}
|
||||
alt=""
|
||||
draggable={false}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{layoutElements.map((el) => (
|
||||
|
|
|
|||
|
|
@ -183,6 +183,30 @@ function TextInspector({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span style={{ fontSize: "var(--text-xs)", color: "var(--muted)" }}>Prefix</span>
|
||||
<input
|
||||
style={{ ...IS, marginTop: 4 }}
|
||||
value={element.prefix ?? ""}
|
||||
onChange={(e) => onChange({ prefix: e.target.value })}
|
||||
onFocus={focusIn}
|
||||
onBlur={focusOut}
|
||||
placeholder="e.g. Dr. "
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span style={{ fontSize: "var(--text-xs)", color: "var(--muted)" }}>Postfix</span>
|
||||
<input
|
||||
style={{ ...IS, marginTop: 4 }}
|
||||
value={element.postfix ?? ""}
|
||||
onChange={(e) => onChange({ postfix: e.target.value })}
|
||||
onFocus={focusIn}
|
||||
onBlur={focusOut}
|
||||
placeholder="e.g. , PhD"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span style={{ fontSize: "var(--text-xs)", color: "var(--muted)" }}>Font family</span>
|
||||
<SelectWrap>
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ function BackgroundUploadButton({
|
|||
<input
|
||||
ref={ref}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
accept="image/*,.pdf,application/pdf"
|
||||
style={{ display: "none" }}
|
||||
disabled={busy || disabled}
|
||||
onChange={(e) => {
|
||||
|
|
@ -188,6 +188,7 @@ export function CreateBadgePanel({
|
|||
defaultBackgroundImageLayout,
|
||||
);
|
||||
const [backgroundPreviewUrl, setBackgroundPreviewUrl] = useState("");
|
||||
const [backgroundIsPdf, setBackgroundIsPdf] = useState(false);
|
||||
const [enabledFields, setEnabledFields] = useState<string[]>(["name", "title", "org", "qr"]);
|
||||
const [layoutElements, setLayoutElements] = useState<BadgeLayoutElement[]>(() =>
|
||||
defaultLayoutForFields(["name", "title", "org", "qr"], "#0B4DD1", "color"),
|
||||
|
|
@ -197,7 +198,9 @@ export function CreateBadgePanel({
|
|||
const [imageUploadBusy, setImageUploadBusy] = useState(false);
|
||||
const [selectedElementId, setSelectedElementId] = useState<string | null>(null);
|
||||
const [badgeTypeLabel, setBadgeTypeLabel] = useState("Attendee");
|
||||
const [outputFormat, setOutputFormat] = useState<"pdf" | "png" | "jpeg">("pdf");
|
||||
const [outputFormat, setOutputFormat] = useState<"pdf" | "pdf_per_person" | "png" | "jpeg">("pdf");
|
||||
const [printDpi, setPrintDpi] = useState(300);
|
||||
const [colorSpace, setColorSpace] = useState<"rgb" | "cmyk">("rgb");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitMsg, setSubmitMsg] = useState<string | null>(null);
|
||||
const [err, setErr] = useState("");
|
||||
|
|
@ -282,11 +285,14 @@ export function CreateBadgePanel({
|
|||
setBackgroundImageUrl(s.backgroundImageUrl);
|
||||
setBackgroundImageLayout(s.backgroundImageLayout);
|
||||
setBackgroundPreviewUrl("");
|
||||
setBackgroundIsPdf(/\.pdf($|\?)/i.test(s.backgroundImageUrl));
|
||||
setEnabledFields(s.enabledFields);
|
||||
setLayoutElements(s.layoutElements);
|
||||
setCustomTexts(s.customTexts);
|
||||
setCustomImages(s.customImages);
|
||||
setOutputFormat(s.outputFormat);
|
||||
setPrintDpi(s.printDpi ?? 300);
|
||||
setColorSpace(s.colorSpace === "cmyk" ? "cmyk" : "rgb");
|
||||
setBadgeTypeLabel(s.badgeTypeLabel);
|
||||
setSelectedElementId(null);
|
||||
setHydrating(false);
|
||||
|
|
@ -412,12 +418,17 @@ export function CreateBadgePanel({
|
|||
};
|
||||
|
||||
const onHeaderFile = async (file: File | null | undefined) => {
|
||||
if (!file || !file.type.startsWith("image/")) {
|
||||
setErr("Choose an image file.");
|
||||
const isPdf =
|
||||
Boolean(file) &&
|
||||
(file!.type === "application/pdf" || /\.pdf$/i.test(file!.name));
|
||||
const isImage = Boolean(file) && file!.type.startsWith("image/");
|
||||
if (!file || (!isImage && !isPdf)) {
|
||||
setErr("Choose an image (SVG/PNG/JPEG) or PDF template.");
|
||||
return;
|
||||
}
|
||||
const local = URL.createObjectURL(file);
|
||||
setBackgroundPreviewUrl(local);
|
||||
setBackgroundIsPdf(isPdf);
|
||||
setHeaderBusy(true);
|
||||
setErr("");
|
||||
const hadBgImage = Boolean(backgroundImageUrl.trim());
|
||||
|
|
@ -425,6 +436,7 @@ export function CreateBadgePanel({
|
|||
const r = await uploadMediaFile(file, { eventId: badgeMediaEventId, module: "badges/backgrounds" });
|
||||
if (r.url) {
|
||||
setBackgroundImageUrl(r.url);
|
||||
setBackgroundIsPdf(isPdf || /\.pdf($|\?)/i.test(r.url));
|
||||
if (!hadBgImage) setBackgroundImageLayout(defaultBackgroundImageLayout());
|
||||
setSelectedElementId(BADGE_BACKGROUND_IMAGE_ID);
|
||||
}
|
||||
|
|
@ -524,6 +536,8 @@ export function CreateBadgePanel({
|
|||
customTexts: customTexts.map(({ id, text }) => ({ id, text })),
|
||||
customImages: customImages.map(({ id, imageUrl }) => ({ id, imageUrl })),
|
||||
outputFormat,
|
||||
printDpi,
|
||||
colorSpace,
|
||||
badgeTypeLabel: badgeTypeLabel.trim() || "Attendee",
|
||||
};
|
||||
|
||||
|
|
@ -571,6 +585,7 @@ export function CreateBadgePanel({
|
|||
badgeAspectRatio={aspectRatio}
|
||||
backgroundColor={backgroundColor}
|
||||
backgroundImageUrl={displayBgUrl}
|
||||
backgroundIsPdf={backgroundIsPdf || /\.pdf($|\?)/i.test(displayBgUrl)}
|
||||
backgroundImageLayout={backgroundImageLayout}
|
||||
layoutElements={layoutElements}
|
||||
customImages={customImages}
|
||||
|
|
@ -756,15 +771,18 @@ export function CreateBadgePanel({
|
|||
<HelperText text="Always applied behind badge fields. Shows through transparent areas of the background image." />
|
||||
</Field>
|
||||
<Field>
|
||||
<Label text="Background image (optional)" />
|
||||
<Label text="Background image / PDF template (optional)" />
|
||||
<input style={IS} value={backgroundImageUrl} onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setBackgroundImageUrl(v);
|
||||
setBackgroundIsPdf(/\.pdf($|\?)/i.test(v));
|
||||
if (!v.trim()) {
|
||||
setBackgroundPreviewUrl("");
|
||||
setBackgroundIsPdf(false);
|
||||
if (selectedElementId === BADGE_BACKGROUND_IMAGE_ID) setSelectedElementId(null);
|
||||
}
|
||||
}} placeholder="https://… or upload" onFocus={focusIn} onBlur={focusOut} />
|
||||
}} placeholder="https://… or upload image / PDF" onFocus={focusIn} onBlur={focusOut} />
|
||||
<HelperText text="PDF templates keep designer artwork and stamp name/QR on top. SVG/PNG/JPEG use the standard image path." />
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: 8 }}>
|
||||
<BackgroundUploadButton busy={headerBusy} onFile={(f) => void onHeaderFile(f)} />
|
||||
{backgroundImageUrl.trim() ? (
|
||||
|
|
@ -784,6 +802,7 @@ export function CreateBadgePanel({
|
|||
onClick={() => {
|
||||
setBackgroundImageUrl("");
|
||||
setBackgroundPreviewUrl("");
|
||||
setBackgroundIsPdf(false);
|
||||
if (selectedElementId === BADGE_BACKGROUND_IMAGE_ID) setSelectedElementId(null);
|
||||
}}
|
||||
style={{ ...SS, padding: "8px 12px", fontSize: "var(--text-sm)" }}
|
||||
|
|
@ -875,17 +894,95 @@ export function CreateBadgePanel({
|
|||
>
|
||||
<Field>
|
||||
<Label text="Format" required />
|
||||
<RadioGroup
|
||||
name="outFmt"
|
||||
value={outputFormat}
|
||||
onChange={(v) => setOutputFormat(v as "pdf" | "png" | "jpeg")}
|
||||
options={[
|
||||
{ label: "PDF (multi-page, one badge per page)", value: "pdf" },
|
||||
{ label: "PNG (one file per person)", value: "png" },
|
||||
{ label: "JPEG (one file per person)", value: "jpeg" },
|
||||
]}
|
||||
/>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8, paddingTop: 2 }}>
|
||||
{(
|
||||
[
|
||||
{ label: "PDF (multi-page, one badge per page)", value: "pdf" },
|
||||
{ label: "PDF (one file per person)", value: "pdf_per_person" },
|
||||
{ label: "PNG (one file per person)", value: "png" },
|
||||
{ label: "JPEG (one file per person)", value: "jpeg" },
|
||||
] as const
|
||||
).map((o) => (
|
||||
<label
|
||||
key={o.value}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
cursor: "pointer",
|
||||
fontSize: "var(--text-sm)",
|
||||
fontFamily: "var(--font-noto-sans)",
|
||||
color: "var(--card-foreground)",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="outFmt"
|
||||
value={o.value}
|
||||
checked={outputFormat === o.value}
|
||||
onChange={() => setOutputFormat(o.value)}
|
||||
style={{ accentColor: "var(--primary)", width: 16, height: 16, cursor: "pointer" }}
|
||||
/>
|
||||
{o.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
<Field>
|
||||
<Label text="Print DPI" />
|
||||
<SelectWrap>
|
||||
<select
|
||||
style={SS}
|
||||
value={String(printDpi)}
|
||||
onChange={(e) => setPrintDpi(Number(e.target.value) || 300)}
|
||||
onFocus={focusIn}
|
||||
onBlur={focusOut}
|
||||
>
|
||||
<option value="150">150 DPI (faster, draft)</option>
|
||||
<option value="200">200 DPI</option>
|
||||
<option value="300">300 DPI (default / print)</option>
|
||||
<option value="600">600 DPI (high quality, larger files)</option>
|
||||
</select>
|
||||
</SelectWrap>
|
||||
<HelperText text="Used for PNG/JPEG badge size and for rasterizing PNG/SVG backgrounds and QR/photos in PDF. Higher DPI = sharper and slower." />
|
||||
</Field>
|
||||
{(outputFormat === "pdf" || outputFormat === "pdf_per_person") && (
|
||||
<Field>
|
||||
<Label text="PDF color space" />
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8, paddingTop: 2 }}>
|
||||
{(
|
||||
[
|
||||
{ label: "RGB (screen / default)", value: "rgb" as const },
|
||||
{ label: "CMYK (print / press)", value: "cmyk" as const },
|
||||
]
|
||||
).map((o) => (
|
||||
<label
|
||||
key={o.value}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
cursor: "pointer",
|
||||
fontSize: "var(--text-sm)",
|
||||
fontFamily: "var(--font-noto-sans)",
|
||||
color: "var(--card-foreground)",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="colorSpace"
|
||||
value={o.value}
|
||||
checked={colorSpace === o.value}
|
||||
onChange={() => setColorSpace(o.value)}
|
||||
style={{ accentColor: "var(--primary)", width: 16, height: 16, cursor: "pointer" }}
|
||||
/>
|
||||
{o.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<HelperText text="CMYK draws text and fills in DeviceCMYK (black text as K100). Photos/QR stay RGB images. Ghostscript is not required." />
|
||||
</Field>
|
||||
)}
|
||||
<HelperText text="Run npm run badge-batch-worker on the server so jobs complete. AWS S3/CloudFront must be configured." />
|
||||
</AccordionSection>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState, useRef, useCallback, useEffect } from "react";
|
||||
import React, { useState, useRef, useCallback, useEffect, useMemo } from "react";
|
||||
import { X, ChevronDown, ChevronUp, Plus, Trash2, Check, Upload, Search, UserPlus } from "lucide-react";
|
||||
import { eventsApi, sessionsApi, tracksApi, speakersApi } from "../../../services/api";
|
||||
import type { Speaker } from "../../../services/api";
|
||||
|
|
@ -10,6 +10,14 @@ import {
|
|||
type CustomFieldForPayload,
|
||||
} from "./createEventPayload";
|
||||
import { FlatpickrDatetimeInput } from "../ui/FlatpickrDatetimeInput";
|
||||
import {
|
||||
DEFAULT_KEY_PERSON_ROLE,
|
||||
KEY_PERSON_ROLES,
|
||||
normalizeKeyPersonRole,
|
||||
type KeyPersonRole,
|
||||
} from "../../constants/keyPersonRoles";
|
||||
|
||||
type KeyPersonRow = { speakerId: string; role: KeyPersonRole };
|
||||
|
||||
const IS: React.CSSProperties = {
|
||||
width: "100%",
|
||||
|
|
@ -615,7 +623,8 @@ export function CreateEventSessionPanel({
|
|||
|
||||
const [speakerCatalog, setSpeakerCatalog] = useState<Speaker[]>([]);
|
||||
const [speakersLoading, setSpeakersLoading] = useState(true);
|
||||
const [selectedSpeakerIds, setSelectedSpeakerIds] = useState<string[]>([]);
|
||||
const [keyPersons, setKeyPersons] = useState<KeyPersonRow[]>([]);
|
||||
const selectedSpeakerIds = useMemo(() => keyPersons.map((k) => k.speakerId), [keyPersons]);
|
||||
const [speakerSearch, setSpeakerSearch] = useState("");
|
||||
const [showNewSpeaker, setShowNewSpeaker] = useState(false);
|
||||
const [nsFirst, setNsFirst] = useState("");
|
||||
|
|
@ -623,6 +632,7 @@ export function CreateEventSessionPanel({
|
|||
const [nsEmail, setNsEmail] = useState("");
|
||||
const [nsPhone, setNsPhone] = useState("");
|
||||
const [nsTitle, setNsTitle] = useState("");
|
||||
const [nsRole, setNsRole] = useState<KeyPersonRole>(DEFAULT_KEY_PERSON_ROLE);
|
||||
const [nsErr, setNsErr] = useState<SectionErr>({});
|
||||
const [nsSaving, setNsSaving] = useState(false);
|
||||
|
||||
|
|
@ -649,12 +659,18 @@ export function CreateEventSessionPanel({
|
|||
[speakerCatalog],
|
||||
);
|
||||
|
||||
const addSpeakerId = (id: string) => {
|
||||
const addSpeakerId = (id: string, role: KeyPersonRole = DEFAULT_KEY_PERSON_ROLE) => {
|
||||
if (!id || selectedSpeakerIds.includes(id)) return;
|
||||
setSelectedSpeakerIds(p => [...p, id]);
|
||||
setKeyPersons((p) => [...p, { speakerId: id, role: normalizeKeyPersonRole(role) }]);
|
||||
};
|
||||
|
||||
const removeSpeakerId = (id: string) => setSelectedSpeakerIds(p => p.filter(x => x !== id));
|
||||
const removeSpeakerId = (id: string) => setKeyPersons((p) => p.filter((x) => x.speakerId !== id));
|
||||
|
||||
const setKeyPersonRole = (id: string, role: KeyPersonRole) => {
|
||||
setKeyPersons((p) =>
|
||||
p.map((row) => (row.speakerId === id ? { ...row, role: normalizeKeyPersonRole(role) } : row)),
|
||||
);
|
||||
};
|
||||
|
||||
const createSpeakerAndAttach = async () => {
|
||||
const e: SectionErr = {};
|
||||
|
|
@ -680,12 +696,13 @@ export function CreateEventSessionPanel({
|
|||
}
|
||||
const created = res.data as Speaker;
|
||||
setSpeakerCatalog(prev => [created, ...prev.filter(x => x._id !== created._id)]);
|
||||
addSpeakerId(created._id);
|
||||
addSpeakerId(created._id, nsRole);
|
||||
setNsFirst("");
|
||||
setNsLast("");
|
||||
setNsEmail("");
|
||||
setNsPhone("");
|
||||
setNsTitle("");
|
||||
setNsRole(DEFAULT_KEY_PERSON_ROLE);
|
||||
setNsErr({});
|
||||
setShowNewSpeaker(false);
|
||||
};
|
||||
|
|
@ -912,7 +929,15 @@ export function CreateEventSessionPanel({
|
|||
track: trackId,
|
||||
location: sessionLocation,
|
||||
status: "scheduled",
|
||||
...(selectedSpeakerIds.length > 0 ? { speakers: selectedSpeakerIds } : {}),
|
||||
...(selectedSpeakerIds.length > 0
|
||||
? {
|
||||
speakers: selectedSpeakerIds,
|
||||
keyPersons: keyPersons.map((k) => ({
|
||||
speaker: k.speakerId,
|
||||
role: normalizeKeyPersonRole(k.role),
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
setSubmitting(false);
|
||||
if (sessRes.error != null || !sessRes.data) {
|
||||
|
|
@ -1209,7 +1234,7 @@ export function CreateEventSessionPanel({
|
|||
completed={completed.has(3)}
|
||||
onToggle={() => toggle(3)}
|
||||
onNext={() => tryAdvance(3)}
|
||||
nextLabel="Continue to Speakers & notes"
|
||||
nextLabel="Continue to Key Persons & notes"
|
||||
nextDisabled={submitting}
|
||||
>
|
||||
<Row>
|
||||
|
|
@ -1278,8 +1303,8 @@ export function CreateEventSessionPanel({
|
|||
|
||||
<AccordionSection
|
||||
n={4}
|
||||
title="Speakers & notes"
|
||||
subtitle="Faculty for this session; optional notes stored on the session"
|
||||
title="Key Persons & notes"
|
||||
subtitle="Faculty for this session with a role each; optional notes stored on the session"
|
||||
open={openSection === 4}
|
||||
completed={completed.has(4)}
|
||||
onToggle={() => toggle(4)}
|
||||
|
|
@ -1288,34 +1313,56 @@ export function CreateEventSessionPanel({
|
|||
nextDisabled={submitting}
|
||||
>
|
||||
<Field>
|
||||
<Label text="Speakers" />
|
||||
<Label text="Key Persons" />
|
||||
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: "0 0 8px", fontFamily: "var(--font-noto-sans)" }}>
|
||||
Select from your organization's directory or add someone new.
|
||||
Select from your organization's directory or add someone new. Assign a role for each person (defaults to Speaker).
|
||||
</p>
|
||||
{selectedSpeakerIds.length > 0 && (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "6px", marginBottom: "10px" }}>
|
||||
{selectedSpeakerIds.map(id => (
|
||||
<span
|
||||
key={id}
|
||||
{keyPersons.length > 0 && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "8px", marginBottom: "10px" }}>
|
||||
{keyPersons.map((row) => (
|
||||
<div
|
||||
key={row.speakerId}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
display: "grid",
|
||||
gridTemplateColumns: "minmax(0, 1fr) minmax(140px, 200px) auto",
|
||||
gap: "8px",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
padding: "4px 8px 4px 10px",
|
||||
padding: "8px 10px",
|
||||
borderRadius: "var(--radius-button)",
|
||||
background: "var(--secondary)",
|
||||
border: "1px solid var(--border)",
|
||||
fontSize: "var(--text-xs)",
|
||||
fontFamily: "var(--font-noto-sans)",
|
||||
}}
|
||||
>
|
||||
{speakerLabel(id)}
|
||||
<span
|
||||
style={{
|
||||
fontSize: "var(--text-sm)",
|
||||
fontFamily: "var(--font-noto-sans)",
|
||||
fontWeight: "var(--font-weight-medium)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{speakerLabel(row.speakerId)}
|
||||
</span>
|
||||
<select
|
||||
value={row.role}
|
||||
onChange={(e) => setKeyPersonRole(row.speakerId, e.target.value as KeyPersonRole)}
|
||||
aria-label={`Role for ${speakerLabel(row.speakerId)}`}
|
||||
style={{ ...IS, padding: "6px 8px", fontSize: "var(--text-xs)" }}
|
||||
>
|
||||
{KEY_PERSON_ROLES.map((role) => (
|
||||
<option key={role} value={role}>
|
||||
{role}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeSpeakerId(id)}
|
||||
onClick={() => removeSpeakerId(row.speakerId)}
|
||||
style={{
|
||||
display: "flex",
|
||||
padding: 0,
|
||||
padding: "4px",
|
||||
border: "none",
|
||||
background: "none",
|
||||
cursor: "pointer",
|
||||
|
|
@ -1323,9 +1370,9 @@ export function CreateEventSessionPanel({
|
|||
}}
|
||||
aria-label="Remove"
|
||||
>
|
||||
<X size={12} />
|
||||
<X size={14} />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -1440,7 +1487,7 @@ export function CreateEventSessionPanel({
|
|||
color: "var(--primary)",
|
||||
}}
|
||||
>
|
||||
<UserPlus size={14} /> {showNewSpeaker ? "Hide new speaker form" : "Add new speaker"}
|
||||
<UserPlus size={14} /> {showNewSpeaker ? "Hide new person form" : "Add new key person"}
|
||||
</button>
|
||||
{showNewSpeaker && (
|
||||
<div
|
||||
|
|
@ -1511,7 +1558,7 @@ export function CreateEventSessionPanel({
|
|||
<input value={nsPhone} onChange={e => setNsPhone(e.target.value)} style={IS} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: "var(--text-xs)", display: "block", marginBottom: "4px", fontFamily: "var(--font-noto-sans)" }}>Title / role</label>
|
||||
<label style={{ fontSize: "var(--text-xs)", display: "block", marginBottom: "4px", fontFamily: "var(--font-noto-sans)" }}>Title</label>
|
||||
<input
|
||||
value={nsTitle}
|
||||
onChange={e => setNsTitle(e.target.value)}
|
||||
|
|
@ -1520,6 +1567,20 @@ export function CreateEventSessionPanel({
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: "var(--text-xs)", display: "block", marginBottom: "4px", fontFamily: "var(--font-noto-sans)" }}>Session role</label>
|
||||
<select
|
||||
value={nsRole}
|
||||
onChange={(e) => setNsRole(e.target.value as KeyPersonRole)}
|
||||
style={IS}
|
||||
>
|
||||
{KEY_PERSON_ROLES.map((role) => (
|
||||
<option key={role} value={role}>
|
||||
{role}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={nsSaving}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { useAuth } from "../../contexts/AuthContext";
|
|||
import { usePermission } from "../../hooks/usePermission";
|
||||
import { eventsApi, formsApi, participantListsApi, designTemplatesApi, portfolioListOrganizationId } from "../../../services/api";
|
||||
import type { Form, FormField, ParticipantListRecord, DesignTemplate } from "../../../services/api";
|
||||
import { eventTicketsApi, type EventTicketRecord } from "../../../services/api/eventTickets";
|
||||
import { assignUniqueFieldNames } from "../../utils/formFieldNames";
|
||||
import {
|
||||
EVENT_HEADER_TOGGLE_LABELS,
|
||||
|
|
@ -489,7 +490,7 @@ interface FForm {
|
|||
anonymous: "Yes" | "No";
|
||||
publish: "Draft" | "Published";
|
||||
paymentEnabled: "Yes" | "No";
|
||||
amount: string;
|
||||
ticketIds: string[];
|
||||
confirmationEmail: "Yes" | "No";
|
||||
confirmationEmailTemplateId: string;
|
||||
onSubmitTags: string;
|
||||
|
|
@ -497,6 +498,7 @@ interface FForm {
|
|||
allowDuplicateEmail: "Yes" | "No";
|
||||
allowDuplicatePhone: "Yes" | "No";
|
||||
headerShowLogo: "Yes" | "No";
|
||||
headerShowBanner: "Yes" | "No";
|
||||
headerShowTitle: "Yes" | "No";
|
||||
headerShowDateRange: "Yes" | "No";
|
||||
headerShowEventType: "Yes" | "No";
|
||||
|
|
@ -549,7 +551,7 @@ const initialForm = (qTypes: string[], isFeedback: boolean): FForm => ({
|
|||
anonymous: isFeedback ? "Yes" : "No",
|
||||
publish: "Draft",
|
||||
paymentEnabled: "No",
|
||||
amount: "",
|
||||
ticketIds: [],
|
||||
confirmationEmail: "No",
|
||||
confirmationEmailTemplateId: "",
|
||||
onSubmitTags: "",
|
||||
|
|
@ -557,6 +559,7 @@ const initialForm = (qTypes: string[], isFeedback: boolean): FForm => ({
|
|||
allowDuplicateEmail: "Yes",
|
||||
allowDuplicatePhone: "Yes",
|
||||
headerShowLogo: "Yes",
|
||||
headerShowBanner: "No",
|
||||
headerShowTitle: "Yes",
|
||||
headerShowDateRange: "Yes",
|
||||
headerShowEventType: "Yes",
|
||||
|
|
@ -827,6 +830,8 @@ export function CreateFormPanel({
|
|||
});
|
||||
const [fixedLists, setFixedLists] = useState<ParticipantListRecord[]>([]);
|
||||
const [emailerTemplates, setEmailerTemplates] = useState<DesignTemplate[]>([]);
|
||||
const [eventTickets, setEventTickets] = useState<EventTicketRecord[]>([]);
|
||||
const [ticketsLoading, setTicketsLoading] = useState(false);
|
||||
const [sectionErrors, setSectionErrors] = useState<Record<number, string>>({});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [loading, setLoading] = useState(!!formId);
|
||||
|
|
@ -895,6 +900,35 @@ export function CreateFormPanel({
|
|||
};
|
||||
}, [isReg, listOrgId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isReg || !selectedEventId.trim()) {
|
||||
setEventTickets([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setTicketsLoading(true);
|
||||
void eventTicketsApi
|
||||
.list(selectedEventId)
|
||||
.then((tickets) => {
|
||||
if (cancelled) return;
|
||||
setEventTickets(tickets);
|
||||
const valid = new Set(tickets.map((t) => t._id));
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
ticketIds: prev.ticketIds.filter((id) => valid.has(id)),
|
||||
}));
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setEventTickets([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setTicketsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isReg, selectedEventId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!formId) return;
|
||||
let cancelled = false;
|
||||
|
|
@ -930,7 +964,7 @@ export function CreateFormPanel({
|
|||
anonymous: s.allowAnonymous ? "Yes" : (isFeedbackOrOther ? "Yes" : "No"),
|
||||
publish: s.isActive !== false ? "Published" : "Draft",
|
||||
paymentEnabled: s.paymentEnabled ? "Yes" : "No",
|
||||
amount: s.paymentAmount != null && s.paymentAmount !== "" ? String(s.paymentAmount) : "",
|
||||
ticketIds: Array.isArray(s.ticketIds) ? s.ticketIds.map((id) => String(id)) : [],
|
||||
confirmationEmail: s.sendConfirmationEmail === true ? "Yes" : "No",
|
||||
confirmationEmailTemplateId: s.confirmationEmailTemplateId
|
||||
? String(s.confirmationEmailTemplateId)
|
||||
|
|
@ -940,6 +974,7 @@ export function CreateFormPanel({
|
|||
allowDuplicateEmail: s.allowDuplicateEmail === false ? "No" : "Yes",
|
||||
allowDuplicatePhone: s.allowDuplicatePhone === false ? "No" : "Yes",
|
||||
headerShowLogo: header.showLogo,
|
||||
headerShowBanner: header.showBanner,
|
||||
headerShowTitle: header.showTitle,
|
||||
headerShowDateRange: header.showDateRange,
|
||||
headerShowEventType: header.showEventType,
|
||||
|
|
@ -997,10 +1032,12 @@ export function CreateFormPanel({
|
|||
const choicesOk = f.questions.every(q => isSectionFieldType(q.type) || !q.label.trim() || fieldChoicesAreValid(q));
|
||||
return anyLabel && choicesOk;
|
||||
}
|
||||
if (n === 3 && isReg) return true;
|
||||
if (n === 4 && isReg) {
|
||||
if (n === 3 && isReg) {
|
||||
if (f.confirmationEmail === "Yes" && !f.confirmationEmailTemplateId.trim()) return false;
|
||||
if (f.paymentEnabled === "Yes" && !f.amount.trim()) return false;
|
||||
return true;
|
||||
}
|
||||
if (n === 4 && isReg) {
|
||||
if (f.paymentEnabled === "Yes" && (!Array.isArray(f.ticketIds) || f.ticketIds.length === 0)) return false;
|
||||
return true;
|
||||
}
|
||||
if (n === (isReg ? 5 : 3)) return true;
|
||||
|
|
@ -1034,7 +1071,8 @@ export function CreateFormPanel({
|
|||
collectEmail: form.collectEmail === "Yes",
|
||||
allowAnonymous: form.anonymous === "Yes",
|
||||
paymentEnabled: form.paymentEnabled === "Yes",
|
||||
paymentAmount: form.paymentEnabled === "Yes" && form.amount.trim() ? form.amount.trim() : "",
|
||||
paymentAmount: "",
|
||||
ticketIds: form.paymentEnabled === "Yes" ? form.ticketIds : [],
|
||||
sendConfirmationEmail: form.confirmationEmail === "Yes",
|
||||
confirmationEmailTemplateId:
|
||||
form.confirmationEmail === "Yes" && form.confirmationEmailTemplateId.trim()
|
||||
|
|
@ -1045,6 +1083,7 @@ export function CreateFormPanel({
|
|||
captchaFailedMessage: form.captchaFailedMessage.trim(),
|
||||
eventHeader: eventHeaderToApi({
|
||||
showLogo: form.headerShowLogo,
|
||||
showBanner: form.headerShowBanner,
|
||||
showTitle: form.headerShowTitle,
|
||||
showDateRange: form.headerShowDateRange,
|
||||
showEventType: form.headerShowEventType,
|
||||
|
|
@ -1248,12 +1287,12 @@ export function CreateFormPanel({
|
|||
<AccordionSection
|
||||
n={3}
|
||||
title="Participant actions"
|
||||
subtitle="Tags and lists applied when someone submits"
|
||||
subtitle="Tags, lists, and confirmation email applied when someone submits"
|
||||
open={acc.open === 3}
|
||||
completed={acc.done.has(3)}
|
||||
onToggle={() => acc.toggle(3)}
|
||||
onSave={() => tryAdvance(3)}
|
||||
nextLabel="Payment"
|
||||
nextLabel="Tickets"
|
||||
sectionError={sectionErrors[3]}
|
||||
>
|
||||
<Field>
|
||||
|
|
@ -1294,33 +1333,6 @@ export function CreateFormPanel({
|
|||
/>
|
||||
<HelperText text="Compares the last 10 digits only (ignores +, spaces, country code)." />
|
||||
</Field>
|
||||
</Grid>
|
||||
</AccordionSection>
|
||||
)}
|
||||
|
||||
{isReg && (
|
||||
<AccordionSection
|
||||
n={4}
|
||||
title="Payment"
|
||||
subtitle="Configure payment settings for paid registrations"
|
||||
open={acc.open === 4}
|
||||
completed={acc.done.has(4)}
|
||||
onToggle={() => acc.toggle(4)}
|
||||
onSave={() => tryAdvance(4)}
|
||||
nextLabel="Publish Settings"
|
||||
sectionError={sectionErrors[4]}
|
||||
>
|
||||
<Grid cols={2}>
|
||||
<Field>
|
||||
<Label text="Enable Payment" required />
|
||||
<RadioGroup name="regPayment" value={form.paymentEnabled} onChange={v => set("paymentEnabled", v as FForm["paymentEnabled"])} options={[{ label: "Yes", value: "Yes" }, { label: "No", value: "No" }]} />
|
||||
</Field>
|
||||
{form.paymentEnabled === "Yes" && (
|
||||
<Field>
|
||||
<Label text="Registration Fee (₹)" required />
|
||||
<input type="number" style={IS} placeholder="0.00" value={form.amount} onChange={e => set("amount", e.target.value)} onFocus={focusIn} onBlur={focusOut} />
|
||||
</Field>
|
||||
)}
|
||||
<Field>
|
||||
<Label text="Confirmation Email" required />
|
||||
<RadioGroup
|
||||
|
|
@ -1371,6 +1383,129 @@ export function CreateFormPanel({
|
|||
</AccordionSection>
|
||||
)}
|
||||
|
||||
{isReg && (
|
||||
<AccordionSection
|
||||
n={4}
|
||||
title="Tickets"
|
||||
subtitle="Attach event tickets for paid registration (2-step public form)"
|
||||
open={acc.open === 4}
|
||||
completed={acc.done.has(4)}
|
||||
onToggle={() => acc.toggle(4)}
|
||||
onSave={() => tryAdvance(4)}
|
||||
nextLabel="Publish Settings"
|
||||
sectionError={sectionErrors[4]}
|
||||
>
|
||||
<Field>
|
||||
<Label text="Enable Ticket" required />
|
||||
<RadioGroup
|
||||
name="regPayment"
|
||||
value={form.paymentEnabled}
|
||||
onChange={(v) => {
|
||||
const next = v as FForm["paymentEnabled"];
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
paymentEnabled: next,
|
||||
ticketIds: next === "Yes" ? prev.ticketIds : [],
|
||||
}));
|
||||
}}
|
||||
options={[{ label: "Yes", value: "Yes" }, { label: "No", value: "No" }]}
|
||||
/>
|
||||
<HelperText text="When Yes, the public registration form is a 2-step process: details, then ticket selection and payment." />
|
||||
</Field>
|
||||
{form.paymentEnabled === "Yes" && (
|
||||
<Field>
|
||||
<Label text="Tickets for this form" required />
|
||||
{!selectedEventId.trim() ? (
|
||||
<HelperText text="Select an event in Form Details first." />
|
||||
) : ticketsLoading ? (
|
||||
<HelperText text="Loading tickets…" />
|
||||
) : eventTickets.length === 0 ? (
|
||||
<HelperText text="No tickets found for this event. Create tickets under the event’s Ticketing page, then return here." />
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: "flex", gap: 12, marginBottom: 8, flexWrap: "wrap" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => set("ticketIds", eventTickets.map((t) => t._id))}
|
||||
style={{
|
||||
padding: "4px 10px",
|
||||
borderRadius: "var(--radius-button)",
|
||||
border: "1px solid var(--border)",
|
||||
background: "var(--card)",
|
||||
cursor: "pointer",
|
||||
fontSize: "var(--text-sm)",
|
||||
}}
|
||||
>
|
||||
Select all
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => set("ticketIds", [])}
|
||||
style={{
|
||||
padding: "4px 10px",
|
||||
borderRadius: "var(--radius-button)",
|
||||
border: "1px solid var(--border)",
|
||||
background: "var(--card)",
|
||||
cursor: "pointer",
|
||||
fontSize: "var(--text-sm)",
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{eventTickets.map((t) => {
|
||||
const checked = form.ticketIds.includes(t._id);
|
||||
const price = (Number(t.basePriceMinor) || 0) / 100;
|
||||
return (
|
||||
<label
|
||||
key={t._id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: 10,
|
||||
padding: "10px 12px",
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${checked ? "var(--primary)" : "var(--border)"}`,
|
||||
background: checked ? "color-mix(in srgb, var(--primary) 8%, transparent)" : "var(--card)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
ticketIds: checked
|
||||
? prev.ticketIds.filter((id) => id !== t._id)
|
||||
: [...prev.ticketIds, t._id],
|
||||
}));
|
||||
}}
|
||||
style={{ marginTop: 3 }}
|
||||
/>
|
||||
<span style={{ flex: 1, minWidth: 0 }}>
|
||||
<span style={{ display: "block", fontWeight: 600 }}>
|
||||
{t.name}
|
||||
{!t.isActive ? " (inactive)" : ""}
|
||||
</span>
|
||||
<span style={{ display: "block", fontSize: "0.8125rem", color: "var(--muted)" }}>
|
||||
{t.currency || "INR"} {price.toFixed(2)}
|
||||
{t.description?.trim() ? ` · ${t.description.trim()}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<HelperText text="Registrants can choose from the tickets you select here." />
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
</AccordionSection>
|
||||
)}
|
||||
|
||||
<AccordionSection
|
||||
n={isReg ? 5 : 3}
|
||||
title="Publish Settings"
|
||||
|
|
@ -1517,11 +1652,12 @@ export function CreateFormPanel({
|
|||
|
||||
<Field>
|
||||
<Label text="Event header on public form" />
|
||||
<HelperText text="Choose which linked event details appear above the form on the public page." />
|
||||
<HelperText text="Choose which linked event details appear above the form on the public page. Logo and banner can both be shown when the event has both assets." />
|
||||
<Grid cols={2}>
|
||||
{(Object.keys(EVENT_HEADER_TOGGLE_LABELS) as EventHeaderToggleKey[]).map((key) => {
|
||||
const formKey = {
|
||||
showLogo: "headerShowLogo",
|
||||
showBanner: "headerShowBanner",
|
||||
showTitle: "headerShowTitle",
|
||||
showDateRange: "headerShowDateRange",
|
||||
showEventType: "headerShowEventType",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,413 @@
|
|||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Upload } from "lucide-react";
|
||||
import { PanelShell, Label, Field, HelperText, RadioGroup, IS, SS, focusIn, focusOut } from "./PanelShell";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import { participantsApi, participantListsApi, portfolioListOrganizationId } from "../../../services/api";
|
||||
import type { ParticipantListRecord } from "../../../services/api";
|
||||
|
||||
export type EventImportMode =
|
||||
| "NEW_INSERT"
|
||||
| "UPSERT"
|
||||
| "UPDATE"
|
||||
| "REPLACE"
|
||||
| "TAGS_ONLY"
|
||||
| "STATUS_UPDATE"
|
||||
| "REMOVE"
|
||||
| "SYNC";
|
||||
|
||||
export type EventImportResult = {
|
||||
created: number;
|
||||
updated: number;
|
||||
removed: number;
|
||||
skipped: number;
|
||||
skippedDuplicate: number;
|
||||
failed: number;
|
||||
errors: { line: number; message: string }[];
|
||||
listId?: string;
|
||||
listLinked?: number;
|
||||
};
|
||||
|
||||
const MODE_OPTIONS: { value: EventImportMode; label: string; help: string }[] = [
|
||||
{ value: "NEW_INSERT", label: "Insert new only", help: "Add new participants; skip emails already on this event." },
|
||||
{ value: "UPSERT", label: "Update & insert", help: "Update matching rows and insert new ones." },
|
||||
{ value: "UPDATE", label: "Update matching only", help: "Update matching rows; ignore emails not on this event." },
|
||||
{ value: "REPLACE", label: "Replace matching fields", help: "Overwrite matching profiles; empty cells clear optional fields; tags replaced." },
|
||||
{ value: "TAGS_ONLY", label: "Tags only", help: "Merge tags onto matching memberships; no profile create/update." },
|
||||
{ value: "STATUS_UPDATE", label: "Update status only", help: "Set membership status from the CSV status column for matching rows." },
|
||||
{ value: "REMOVE", label: "Remove from event", help: "Remove matching participants from this event." },
|
||||
{ value: "SYNC", label: "Sync roster", help: "Upsert CSV rows, then remove anyone on the event whose email is missing from the file." },
|
||||
];
|
||||
|
||||
type ListAction = "none" | "create" | "add_existing";
|
||||
type MembershipStatus = "pending" | "confirmed" | "rejected";
|
||||
|
||||
const STATUS_OPTIONS: { value: MembershipStatus; label: string }[] = [
|
||||
{ value: "pending", label: "Pending" },
|
||||
{ value: "confirmed", label: "Confirmed" },
|
||||
{ value: "rejected", label: "Rejected" },
|
||||
];
|
||||
|
||||
function parseBulkTags(raw: string): string[] {
|
||||
return raw
|
||||
.split(/[;,]/)
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function ImportEventParticipantsPanel({
|
||||
eventId,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: {
|
||||
eventId: string;
|
||||
onClose: () => void;
|
||||
onSuccess: (result: EventImportResult) => void;
|
||||
}) {
|
||||
const { user } = useAuth();
|
||||
const listOrgId = portfolioListOrganizationId(user);
|
||||
|
||||
const [csvText, setCsvText] = useState("");
|
||||
const [fileLabel, setFileLabel] = useState<string | null>(null);
|
||||
const [mode, setMode] = useState<EventImportMode>("NEW_INSERT");
|
||||
const [confirmSync, setConfirmSync] = useState(false);
|
||||
const [membershipStatus, setMembershipStatus] = useState<MembershipStatus>("pending");
|
||||
const [bulkTags, setBulkTags] = useState("");
|
||||
const [listAction, setListAction] = useState<ListAction>("none");
|
||||
const [listName, setListName] = useState("");
|
||||
const [listDescription, setListDescription] = useState("");
|
||||
const [existingListId, setExistingListId] = useState("");
|
||||
const [fixedLists, setFixedLists] = useState<ParticipantListRecord[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const showTags = mode !== "STATUS_UPDATE" && mode !== "REMOVE";
|
||||
const showLists = mode !== "REMOVE";
|
||||
const showStatus =
|
||||
mode === "NEW_INSERT" ||
|
||||
mode === "UPSERT" ||
|
||||
mode === "UPDATE" ||
|
||||
mode === "REPLACE" ||
|
||||
mode === "SYNC";
|
||||
const modeHelp = MODE_OPTIONS.find((m) => m.value === mode)?.help ?? "";
|
||||
|
||||
useEffect(() => {
|
||||
if (!listOrgId || !showLists) return;
|
||||
participantListsApi.list({ organization: listOrgId }).then((r) => {
|
||||
const lists = (r.data?.lists || []).filter((l) => l.type === "fixed");
|
||||
setFixedLists(lists);
|
||||
});
|
||||
}, [listOrgId, showLists]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === "REMOVE") setListAction("none");
|
||||
if (mode !== "SYNC") setConfirmSync(false);
|
||||
}, [mode]);
|
||||
|
||||
const onFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setErr(null);
|
||||
file
|
||||
.text()
|
||||
.then((text) => {
|
||||
setCsvText(text);
|
||||
setFileLabel(file.name);
|
||||
})
|
||||
.catch(() => setErr("Could not read file."));
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
const ctaDisabled = useMemo(() => {
|
||||
if (saving || !csvText.trim()) return true;
|
||||
if (mode === "SYNC" && !confirmSync) return true;
|
||||
if (showLists && listAction === "create" && !listName.trim()) return true;
|
||||
if (showLists && listAction === "add_existing" && !existingListId) return true;
|
||||
return false;
|
||||
}, [saving, csvText, mode, confirmSync, showLists, listAction, listName, existingListId]);
|
||||
|
||||
const submit = useCallback(async () => {
|
||||
setErr(null);
|
||||
if (!csvText.trim()) {
|
||||
setErr("Choose a CSV file first.");
|
||||
return;
|
||||
}
|
||||
if (mode === "SYNC" && !confirmSync) {
|
||||
setErr("Confirm that people missing from the CSV will be removed from the event.");
|
||||
return;
|
||||
}
|
||||
if (showLists && listAction === "create" && !listName.trim()) {
|
||||
setErr("Enter a list name.");
|
||||
return;
|
||||
}
|
||||
if (showLists && listAction === "add_existing" && !existingListId) {
|
||||
setErr("Select an existing list.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
const res = await participantsApi.importCsv(eventId, {
|
||||
csv: csvText,
|
||||
mode,
|
||||
confirmSync: mode === "SYNC" ? true : undefined,
|
||||
membershipStatus: showStatus ? membershipStatus : undefined,
|
||||
tags: showTags ? parseBulkTags(bulkTags) : undefined,
|
||||
listAction: showLists ? listAction : "none",
|
||||
listName: listAction === "create" ? listName.trim() : undefined,
|
||||
listDescription: listAction === "create" ? listDescription.trim() : undefined,
|
||||
existingListId: listAction === "add_existing" ? existingListId : undefined,
|
||||
});
|
||||
setSaving(false);
|
||||
if (res.error || !res.data) {
|
||||
setErr(res.error || "Import failed");
|
||||
return;
|
||||
}
|
||||
onSuccess(res.data);
|
||||
onClose();
|
||||
}, [
|
||||
csvText,
|
||||
mode,
|
||||
confirmSync,
|
||||
showStatus,
|
||||
membershipStatus,
|
||||
showTags,
|
||||
showLists,
|
||||
bulkTags,
|
||||
listAction,
|
||||
listName,
|
||||
listDescription,
|
||||
existingListId,
|
||||
eventId,
|
||||
onSuccess,
|
||||
onClose,
|
||||
]);
|
||||
|
||||
return (
|
||||
<PanelShell
|
||||
onClose={onClose}
|
||||
title="Import participants"
|
||||
breadcrumb="Participants · Import"
|
||||
description="Upload a CSV for this event. Choose how matching emails are handled, then optionally tag rows or save them to a list."
|
||||
completedCount={csvText.trim() ? 1 : 0}
|
||||
totalSections={1}
|
||||
ctaLabel={saving ? "Importing…" : "Import"}
|
||||
ctaDisabled={ctaDisabled}
|
||||
onCtaClick={submit}
|
||||
>
|
||||
<div style={{ padding: "0 20px 24px", display: "flex", flexDirection: "column", gap: "16px" }}>
|
||||
{err && (
|
||||
<p style={{ fontSize: "var(--text-sm)", color: "var(--destructive)", margin: 0, fontFamily: "var(--font-noto-sans)" }}>{err}</p>
|
||||
)}
|
||||
|
||||
<Field>
|
||||
<Label text="CSV format" />
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 14px",
|
||||
borderRadius: "var(--radius-button)",
|
||||
background: "var(--secondary)",
|
||||
border: "1px solid var(--border)",
|
||||
fontSize: "var(--text-xs)",
|
||||
fontFamily: "var(--font-noto-sans)",
|
||||
color: "var(--card-foreground)",
|
||||
lineHeight: 1.55,
|
||||
}}
|
||||
>
|
||||
<p style={{ margin: "0 0 8px" }}>
|
||||
Required: <code>email</code>
|
||||
{mode === "NEW_INSERT" || mode === "UPSERT" || mode === "SYNC" ? (
|
||||
<>
|
||||
, <code>firstName</code>, <code>lastName</code> (for new rows)
|
||||
</>
|
||||
) : null}
|
||||
{mode === "STATUS_UPDATE" ? (
|
||||
<>
|
||||
, <code>status</code> (<code>pending</code>, <code>confirmed</code>, <code>checked_in</code>,{" "}
|
||||
<code>cancelled</code>, <code>rejected</code>)
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
<p style={{ margin: "0 0 8px", color: "var(--muted)" }}>
|
||||
Optional: phone, organization, designation, tags, notes
|
||||
{mode !== "STATUS_UPDATE" ? ". Headers like “First name” / “Last name” are accepted." : "."}
|
||||
</p>
|
||||
<p style={{ margin: 0, color: "var(--muted)", wordBreak: "break-all" }}>
|
||||
Example:{" "}
|
||||
<code style={{ fontSize: 11 }}>
|
||||
{mode === "STATUS_UPDATE"
|
||||
? "email,status"
|
||||
: "email,firstName,lastName,phone,organization,designation,tags,notes"}
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<Label text="Import behaviour" required />
|
||||
<select
|
||||
style={SS}
|
||||
value={mode}
|
||||
onChange={(e) => setMode(e.target.value as EventImportMode)}
|
||||
onFocus={focusIn}
|
||||
onBlur={focusOut}
|
||||
>
|
||||
{MODE_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<HelperText text={modeHelp} />
|
||||
</Field>
|
||||
|
||||
{mode === "SYNC" && (
|
||||
<Field>
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: "8px",
|
||||
cursor: "pointer",
|
||||
fontSize: "var(--text-sm)",
|
||||
fontFamily: "var(--font-noto-sans)",
|
||||
color: "var(--card-foreground)",
|
||||
lineHeight: 1.45,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={confirmSync}
|
||||
onChange={(e) => setConfirmSync(e.target.checked)}
|
||||
style={{ marginTop: 2, accentColor: "var(--primary)" }}
|
||||
/>
|
||||
<span>
|
||||
I understand people on this event whose email is <strong>not</strong> in the CSV will be removed from
|
||||
the event.
|
||||
</span>
|
||||
</label>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{showStatus && (
|
||||
<Field>
|
||||
<Label text="Status" required />
|
||||
<RadioGroup
|
||||
name="import-membership-status"
|
||||
value={membershipStatus}
|
||||
onChange={(v) => setMembershipStatus(v as MembershipStatus)}
|
||||
options={STATUS_OPTIONS}
|
||||
/>
|
||||
<HelperText text="Applied to created and updated event memberships from this import." />
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field>
|
||||
<Label text="CSV file" required />
|
||||
<label
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
padding: "10px 14px",
|
||||
borderRadius: "var(--radius-button)",
|
||||
border: "1px dashed var(--primary)",
|
||||
color: "var(--primary)",
|
||||
cursor: "pointer",
|
||||
fontSize: "var(--text-sm)",
|
||||
fontFamily: "var(--font-noto-sans)",
|
||||
fontWeight: "var(--font-weight-medium)",
|
||||
}}
|
||||
>
|
||||
<Upload size={16} />
|
||||
{fileLabel || "Choose file…"}
|
||||
<input type="file" accept=".csv,text/csv" style={{ display: "none" }} onChange={onFile} />
|
||||
</label>
|
||||
<HelperText text="Matching is by email for this event." />
|
||||
</Field>
|
||||
|
||||
{showTags && (
|
||||
<Field>
|
||||
<Label text="Apply tags to imported rows" />
|
||||
<input
|
||||
style={IS}
|
||||
value={bulkTags}
|
||||
onChange={(e) => setBulkTags(e.target.value)}
|
||||
onFocus={focusIn}
|
||||
onBlur={focusOut}
|
||||
placeholder="e.g. vip, day-1"
|
||||
/>
|
||||
<HelperText text="Comma or semicolon separated. Combined with any tags column in the CSV." />
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{showLists && (
|
||||
<>
|
||||
<Field>
|
||||
<Label text="Participant list" />
|
||||
<RadioGroup
|
||||
name="import-list-action"
|
||||
value={listAction}
|
||||
onChange={(v) => setListAction(v as ListAction)}
|
||||
options={[
|
||||
{ label: "None", value: "none" },
|
||||
{ label: "Create new list", value: "create" },
|
||||
{ label: "Add to existing", value: "add_existing" },
|
||||
]}
|
||||
/>
|
||||
<HelperText text="Applies to participants created or updated by this import (not removals)." />
|
||||
</Field>
|
||||
|
||||
{listAction === "create" && (
|
||||
<>
|
||||
<Field>
|
||||
<Label text="List name" required />
|
||||
<input
|
||||
style={IS}
|
||||
value={listName}
|
||||
onChange={(e) => setListName(e.target.value)}
|
||||
onFocus={focusIn}
|
||||
onBlur={focusOut}
|
||||
placeholder="e.g. January invitees"
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<Label text="Description" />
|
||||
<textarea
|
||||
style={{ ...IS, resize: "vertical", minHeight: "72px" }}
|
||||
value={listDescription}
|
||||
onChange={(e) => setListDescription(e.target.value)}
|
||||
onFocus={focusIn}
|
||||
onBlur={focusOut}
|
||||
placeholder="Optional notes for admins"
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{listAction === "add_existing" && (
|
||||
<Field>
|
||||
<Label text="Existing fixed list" required />
|
||||
<select
|
||||
style={SS}
|
||||
value={existingListId}
|
||||
onChange={(e) => setExistingListId(e.target.value)}
|
||||
onFocus={focusIn}
|
||||
onBlur={focusOut}
|
||||
>
|
||||
<option value="">Select a list…</option>
|
||||
{fixedLists.map((l) => (
|
||||
<option key={l._id} value={l._id}>
|
||||
{l.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{fixedLists.length === 0 && (
|
||||
<HelperText text="No fixed lists found for this organization." />
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PanelShell>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,10 @@
|
|||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { websiteSettingsApi } from "../../../services/api/websiteSettings";
|
||||
import type { WebsiteThemeConfig, WebsiteThemeMode } from "../../../services/api/types";
|
||||
import type {
|
||||
WebsiteThemeConfig,
|
||||
WebsiteThemeMode,
|
||||
WebsiteThemePattern,
|
||||
} from "../../../services/api/types";
|
||||
import {
|
||||
Field,
|
||||
HelperText,
|
||||
|
|
@ -15,44 +19,247 @@ import {
|
|||
const THEME_OPTIONS: { value: WebsiteThemeMode; label: string }[] = [
|
||||
{ value: "simple", label: "Simple" },
|
||||
{ value: "gradient", label: "Gradient" },
|
||||
{ value: "pattern", label: "Pattern" },
|
||||
];
|
||||
|
||||
const SIMPLE_COLORS = [
|
||||
{ value: "blue", label: "Blue (default)" },
|
||||
{ value: "red", label: "Red" },
|
||||
{ value: "green", label: "Green" },
|
||||
{ value: "yellow", label: "Yellow" },
|
||||
{ value: "purple", label: "Purple" },
|
||||
{ value: "gray", label: "Gray" },
|
||||
{ value: "yellow", label: "Yellow" },
|
||||
{ value: "orange", label: "Orange" },
|
||||
] as const;
|
||||
|
||||
type SimpleColor = (typeof SIMPLE_COLORS)[number]["value"];
|
||||
|
||||
const GRADIENT_COLORS = [
|
||||
{ value: "green", label: "Green" },
|
||||
{ value: "warm", label: "Warm" },
|
||||
{ value: "warm2", label: "Warm 2" },
|
||||
] as const;
|
||||
|
||||
const FONT_OPTIONS = ["Raleway", "Montserrat", "Open Sans", "Roboto", "Poppins"] as const;
|
||||
const PATTERN_OPTIONS: { value: WebsiteThemePattern; label: string }[] = [
|
||||
{ value: "dots", label: "Dots" },
|
||||
{ value: "grid", label: "Grid" },
|
||||
{ value: "zigzag", label: "Zigzag" },
|
||||
{ value: "diagonal", label: "Diagonal" },
|
||||
{ value: "hexa", label: "Hexa" },
|
||||
{ value: "plus", label: "Plus" },
|
||||
];
|
||||
|
||||
const FONT_OPTIONS = ["Open Sans", "Montserrat", "Roboto", "Poppins", "Raleway"] as const;
|
||||
|
||||
/** Default palette for simple/pattern color presets (matches public site conferenceTheme). */
|
||||
const PRIMARY_COLOR_DEFAULTS: Record<SimpleColor, string> = {
|
||||
blue: "#112c59",
|
||||
red: "#4c1429",
|
||||
green: "#073428",
|
||||
purple: "#2a235d",
|
||||
gray: "#2a2e33",
|
||||
yellow: "#4f4109",
|
||||
orange: "#683606",
|
||||
};
|
||||
|
||||
const BUTTON_COLOR_DEFAULTS: Record<SimpleColor, string> = {
|
||||
blue: "#50a2ed",
|
||||
red: "#8f3958",
|
||||
green: "#46b295",
|
||||
purple: "#6954c9",
|
||||
gray: "#5f656d",
|
||||
yellow: "#ac9943",
|
||||
orange: "#c7782e",
|
||||
};
|
||||
|
||||
const TEXT_HIGHLIGHT_COLOR_DEFAULTS: Record<SimpleColor, string> = {
|
||||
blue: "#6ba8e3",
|
||||
red: "#e27fa3",
|
||||
green: "#7dc9b2",
|
||||
purple: "#9f94ec",
|
||||
gray: "#c2cad3",
|
||||
yellow: "#d4c169",
|
||||
orange: "#e5b587",
|
||||
};
|
||||
|
||||
function paletteDefaultsForColor(color: string): Pick<
|
||||
WebsiteThemeConfig,
|
||||
"primaryColor" | "buttonColor" | "textHighlightColor"
|
||||
> {
|
||||
const key = (SIMPLE_COLORS.some((c) => c.value === color) ? color : "blue") as SimpleColor;
|
||||
return {
|
||||
primaryColor: PRIMARY_COLOR_DEFAULTS[key],
|
||||
buttonColor: BUTTON_COLOR_DEFAULTS[key],
|
||||
textHighlightColor: TEXT_HIGHLIGHT_COLOR_DEFAULTS[key],
|
||||
};
|
||||
}
|
||||
|
||||
const PALETTE_FIELDS: {
|
||||
key: keyof Pick<
|
||||
WebsiteThemeConfig,
|
||||
| "primaryColor"
|
||||
| "secondaryColor"
|
||||
| "headerColor"
|
||||
| "buttonColor"
|
||||
| "textColor"
|
||||
| "textHighlightColor"
|
||||
>;
|
||||
label: string;
|
||||
help: string;
|
||||
}[] = [
|
||||
{ key: "primaryColor", label: "Primary color", help: "Main background / brand surface." },
|
||||
{ key: "secondaryColor", label: "Secondary color", help: "Secondary surface accent." },
|
||||
{ key: "headerColor", label: "Header color", help: "Top navigation background." },
|
||||
{ key: "buttonColor", label: "Button color", help: "Primary CTA buttons." },
|
||||
{ key: "textColor", label: "Text color", help: "Body / default text." },
|
||||
{ key: "textHighlightColor", label: "Text highlight", help: "Highlighted labels and accents." },
|
||||
];
|
||||
|
||||
const DEFAULT_THEME: WebsiteThemeConfig = {
|
||||
theme: "simple",
|
||||
color: "blue",
|
||||
pattern: "",
|
||||
font: "Open Sans",
|
||||
...paletteDefaultsForColor("blue"),
|
||||
secondaryColor: "",
|
||||
headerColor: "",
|
||||
textColor: "",
|
||||
};
|
||||
|
||||
function trimColor(v?: string | null): string {
|
||||
return v != null ? String(v).trim() : "";
|
||||
}
|
||||
|
||||
/** Keep a valid #rrggbb for `<input type="color">`; empty → fallback swatch. */
|
||||
function colorInputValue(hex: string, fallback = "#112c59"): string {
|
||||
const s = trimColor(hex);
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(s)) return s;
|
||||
if (/^#[0-9a-fA-F]{3}$/.test(s)) {
|
||||
const r = s[1];
|
||||
const g = s[2];
|
||||
const b = s[3];
|
||||
return `#${r}${r}${g}${g}${b}${b}`;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function normalizeTheme(raw?: WebsiteThemeConfig | null): WebsiteThemeConfig {
|
||||
const mode: WebsiteThemeMode = raw?.theme === "gradient" ? "gradient" : "simple";
|
||||
const modeRaw = raw?.theme != null ? String(raw.theme).trim().toLowerCase() : "simple";
|
||||
const mode: WebsiteThemeMode =
|
||||
modeRaw === "gradient" ? "gradient" : modeRaw === "pattern" ? "pattern" : "simple";
|
||||
|
||||
const fonts = new Set<string>(FONT_OPTIONS);
|
||||
const fontRaw = raw?.font != null ? String(raw.font).trim() : "";
|
||||
const font = fontRaw && fonts.has(fontRaw) ? fontRaw : DEFAULT_THEME.font!;
|
||||
|
||||
let color = raw?.color != null ? String(raw.color).trim().toLowerCase() : "";
|
||||
if (mode === "simple") {
|
||||
const ok = new Set(SIMPLE_COLORS.map((c) => c.value));
|
||||
if (!ok.has(color as (typeof SIMPLE_COLORS)[number]["value"])) color = "blue";
|
||||
} else {
|
||||
if (mode === "gradient") {
|
||||
const ok = new Set(GRADIENT_COLORS.map((c) => c.value));
|
||||
if (!ok.has(color as (typeof GRADIENT_COLORS)[number]["value"])) color = "green";
|
||||
} else {
|
||||
const ok = new Set(SIMPLE_COLORS.map((c) => c.value));
|
||||
if (!ok.has(color as (typeof SIMPLE_COLORS)[number]["value"])) color = "blue";
|
||||
}
|
||||
return { theme: mode, color, font };
|
||||
|
||||
const patternRaw = raw?.pattern != null ? String(raw.pattern).trim().toLowerCase() : "";
|
||||
const patternOk = new Set(PATTERN_OPTIONS.map((p) => p.value));
|
||||
const pattern =
|
||||
mode === "pattern"
|
||||
? patternOk.has(patternRaw as WebsiteThemePattern)
|
||||
? patternRaw
|
||||
: "dots"
|
||||
: patternRaw && patternOk.has(patternRaw as WebsiteThemePattern)
|
||||
? patternRaw
|
||||
: "";
|
||||
|
||||
const palette =
|
||||
mode === "simple" || mode === "pattern" ? paletteDefaultsForColor(color) : null;
|
||||
|
||||
const primaryColor = trimColor(raw?.primaryColor) || palette?.primaryColor || "";
|
||||
const buttonColor = trimColor(raw?.buttonColor) || palette?.buttonColor || "";
|
||||
const textHighlightColor =
|
||||
trimColor(raw?.textHighlightColor) || palette?.textHighlightColor || "";
|
||||
|
||||
return {
|
||||
theme: mode,
|
||||
color,
|
||||
pattern,
|
||||
font,
|
||||
primaryColor,
|
||||
secondaryColor: trimColor(raw?.secondaryColor),
|
||||
headerColor: trimColor(raw?.headerColor),
|
||||
buttonColor,
|
||||
textColor: trimColor(raw?.textColor),
|
||||
textHighlightColor,
|
||||
};
|
||||
}
|
||||
|
||||
function PaletteColorField({
|
||||
label,
|
||||
help,
|
||||
value,
|
||||
onChange,
|
||||
swatchFallback = "#112c59",
|
||||
placeholder = "optional",
|
||||
}: {
|
||||
label: string;
|
||||
help: string;
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
swatchFallback?: string;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
return (
|
||||
<Field>
|
||||
<Label text={label} />
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
|
||||
<input
|
||||
type="color"
|
||||
value={colorInputValue(value, swatchFallback)}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={{
|
||||
width: 40,
|
||||
height: 36,
|
||||
padding: 0,
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
cursor: "pointer",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
aria-label={label}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={{ ...SS, flex: 1 }}
|
||||
onFocus={focusIn}
|
||||
onBlur={focusOut}
|
||||
/>
|
||||
{value ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange("")}
|
||||
style={{
|
||||
border: "1px solid var(--border)",
|
||||
background: "var(--card)",
|
||||
borderRadius: "var(--radius-button)",
|
||||
padding: "6px 10px",
|
||||
fontSize: "var(--text-xs)",
|
||||
fontFamily: "var(--font-noto-sans)",
|
||||
color: "var(--muted)",
|
||||
cursor: "pointer",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<HelperText text={help} />
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
export function WebsiteThemeSettingsPanel({
|
||||
|
|
@ -67,9 +274,7 @@ export function WebsiteThemeSettingsPanel({
|
|||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [themeMode, setThemeMode] = useState<WebsiteThemeMode>(DEFAULT_THEME.theme);
|
||||
const [color, setColor] = useState(DEFAULT_THEME.color);
|
||||
const [font, setFont] = useState(DEFAULT_THEME.font || "Open Sans");
|
||||
const [theme, setTheme] = useState<WebsiteThemeConfig>(DEFAULT_THEME);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
|
@ -83,10 +288,7 @@ export function WebsiteThemeSettingsPanel({
|
|||
setError(res.error);
|
||||
return;
|
||||
}
|
||||
const t = normalizeTheme(res.data?.theme);
|
||||
setThemeMode(t.theme);
|
||||
setColor(t.color);
|
||||
setFont(t.font || "Open Sans");
|
||||
setTheme(normalizeTheme(res.data?.theme));
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : "Could not load website settings.");
|
||||
|
|
@ -100,32 +302,62 @@ export function WebsiteThemeSettingsPanel({
|
|||
}, [eventId]);
|
||||
|
||||
const colorOptions = useMemo(
|
||||
() => (themeMode === "gradient" ? GRADIENT_COLORS : SIMPLE_COLORS),
|
||||
[themeMode],
|
||||
() => (theme.theme === "gradient" ? GRADIENT_COLORS : SIMPLE_COLORS),
|
||||
[theme.theme],
|
||||
);
|
||||
|
||||
const onThemeModeChange = (next: WebsiteThemeMode) => {
|
||||
setThemeMode(next);
|
||||
if (next === "gradient") {
|
||||
const ok = new Set(GRADIENT_COLORS.map((c) => c.value));
|
||||
if (!ok.has(color as (typeof GRADIENT_COLORS)[number]["value"])) setColor("green");
|
||||
} else {
|
||||
const ok = new Set(SIMPLE_COLORS.map((c) => c.value));
|
||||
if (!ok.has(color as (typeof SIMPLE_COLORS)[number]["value"])) setColor("blue");
|
||||
}
|
||||
const patchTheme = (partial: Partial<WebsiteThemeConfig>) => {
|
||||
setTheme((prev) => {
|
||||
const nextMode = partial.theme ?? prev.theme;
|
||||
let nextColor = partial.color ?? prev.color;
|
||||
if (partial.theme) {
|
||||
if (partial.theme === "gradient") {
|
||||
const ok = new Set(GRADIENT_COLORS.map((c) => c.value));
|
||||
if (!ok.has(nextColor as (typeof GRADIENT_COLORS)[number]["value"])) nextColor = "green";
|
||||
} else {
|
||||
const ok = new Set(SIMPLE_COLORS.map((c) => c.value));
|
||||
if (!ok.has(nextColor as (typeof SIMPLE_COLORS)[number]["value"])) nextColor = "blue";
|
||||
}
|
||||
}
|
||||
let nextPattern = partial.pattern !== undefined ? partial.pattern : prev.pattern;
|
||||
if (partial.theme === "pattern" && !trimColor(String(nextPattern ?? ""))) {
|
||||
nextPattern = "dots";
|
||||
}
|
||||
|
||||
const colorOrModeChanged =
|
||||
(partial.color !== undefined && partial.color !== prev.color) ||
|
||||
(partial.theme !== undefined && partial.theme !== prev.theme);
|
||||
|
||||
const next: WebsiteThemeConfig = {
|
||||
...prev,
|
||||
...partial,
|
||||
theme: nextMode,
|
||||
color: nextColor,
|
||||
pattern: nextPattern,
|
||||
};
|
||||
|
||||
// When preset color/mode changes, refresh primary/button/highlight to that color's defaults.
|
||||
if (colorOrModeChanged && (nextMode === "simple" || nextMode === "pattern")) {
|
||||
Object.assign(next, paletteDefaultsForColor(nextColor));
|
||||
if (partial.primaryColor !== undefined) next.primaryColor = partial.primaryColor;
|
||||
if (partial.buttonColor !== undefined) next.buttonColor = partial.buttonColor;
|
||||
if (partial.textHighlightColor !== undefined) {
|
||||
next.textHighlightColor = partial.textHighlightColor;
|
||||
}
|
||||
}
|
||||
|
||||
return normalizeTheme(next);
|
||||
});
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const theme = normalizeTheme({ theme: themeMode, color, font });
|
||||
const res = await websiteSettingsApi.update(eventId, { theme });
|
||||
const payload = normalizeTheme(theme);
|
||||
const res = await websiteSettingsApi.update(eventId, { theme: payload });
|
||||
if (res.error) throw new Error(res.error);
|
||||
const saved = normalizeTheme(res.data?.theme ?? theme);
|
||||
setThemeMode(saved.theme);
|
||||
setColor(saved.color);
|
||||
setFont(saved.font || "Open Sans");
|
||||
setTheme(normalizeTheme(res.data?.theme ?? payload));
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
|
|
@ -139,7 +371,7 @@ export function WebsiteThemeSettingsPanel({
|
|||
<PanelShell
|
||||
title="Website settings"
|
||||
breadcrumb="Event Home"
|
||||
description="Choose the look of the public event website (theme, color, and font)."
|
||||
description="Choose the look of the public event website (theme, pattern, palette, and font)."
|
||||
completedCount={1}
|
||||
totalSections={1}
|
||||
showProgress={false}
|
||||
|
|
@ -147,7 +379,7 @@ export function WebsiteThemeSettingsPanel({
|
|||
ctaDisabled={loading || saving}
|
||||
onCtaClick={() => void save()}
|
||||
onClose={onClose}
|
||||
panelWidth="min(480px, 100vw)"
|
||||
panelWidth="min(520px, 100vw)"
|
||||
>
|
||||
<div style={{ padding: "20px" }}>
|
||||
{loading ? (
|
||||
|
|
@ -160,8 +392,8 @@ export function WebsiteThemeSettingsPanel({
|
|||
<Label text="Theme" required />
|
||||
<SelectWrap>
|
||||
<select
|
||||
value={themeMode}
|
||||
onChange={(e) => onThemeModeChange(e.target.value as WebsiteThemeMode)}
|
||||
value={theme.theme}
|
||||
onChange={(e) => patchTheme({ theme: e.target.value as WebsiteThemeMode })}
|
||||
style={SS}
|
||||
onFocus={focusIn}
|
||||
onBlur={focusOut}
|
||||
|
|
@ -173,15 +405,15 @@ export function WebsiteThemeSettingsPanel({
|
|||
))}
|
||||
</select>
|
||||
</SelectWrap>
|
||||
<HelperText text="Simple uses a flat color look. Gradient uses a richer background preset." />
|
||||
<HelperText text="Simple and Pattern use a flat palette. Gradient uses a richer background preset." />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<Label text="Color" required />
|
||||
<SelectWrap>
|
||||
<select
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
value={theme.color}
|
||||
onChange={(e) => patchTheme({ color: e.target.value })}
|
||||
style={SS}
|
||||
onFocus={focusIn}
|
||||
onBlur={focusOut}
|
||||
|
|
@ -195,12 +427,34 @@ export function WebsiteThemeSettingsPanel({
|
|||
</SelectWrap>
|
||||
</Field>
|
||||
|
||||
{theme.theme === "pattern" ? (
|
||||
<Field>
|
||||
<Label text="Pattern" required />
|
||||
<SelectWrap>
|
||||
<select
|
||||
value={theme.pattern || "dots"}
|
||||
onChange={(e) => patchTheme({ pattern: e.target.value as WebsiteThemePattern })}
|
||||
style={SS}
|
||||
onFocus={focusIn}
|
||||
onBlur={focusOut}
|
||||
>
|
||||
{PATTERN_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</SelectWrap>
|
||||
<HelperText text="Background tile used with the Pattern theme." />
|
||||
</Field>
|
||||
) : null}
|
||||
|
||||
<Field>
|
||||
<Label text="Font" required />
|
||||
<SelectWrap>
|
||||
<select
|
||||
value={font}
|
||||
onChange={(e) => setFont(e.target.value)}
|
||||
value={theme.font || "Open Sans"}
|
||||
onChange={(e) => patchTheme({ font: e.target.value })}
|
||||
style={SS}
|
||||
onFocus={focusIn}
|
||||
onBlur={focusOut}
|
||||
|
|
@ -214,8 +468,72 @@ export function WebsiteThemeSettingsPanel({
|
|||
</SelectWrap>
|
||||
</Field>
|
||||
|
||||
<div
|
||||
style={{
|
||||
borderTop: "1px solid var(--border)",
|
||||
paddingTop: "12px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "16px",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<p
|
||||
style={{
|
||||
margin: "0 0 4px",
|
||||
fontSize: "var(--text-sm)",
|
||||
fontWeight: 600,
|
||||
fontFamily: "var(--font-noto-sans)",
|
||||
color: "var(--card-foreground)",
|
||||
}}
|
||||
>
|
||||
Palette overrides
|
||||
</p>
|
||||
<HelperText text="Optional. Leave blank to use the preset palette for the selected color." />
|
||||
</div>
|
||||
|
||||
{PALETTE_FIELDS.map((field) => {
|
||||
const defaults =
|
||||
theme.theme === "simple" || theme.theme === "pattern"
|
||||
? paletteDefaultsForColor(theme.color)
|
||||
: null;
|
||||
const placeholder =
|
||||
field.key === "primaryColor"
|
||||
? defaults?.primaryColor || "#112c59"
|
||||
: field.key === "buttonColor"
|
||||
? defaults?.buttonColor || "#50a2ed"
|
||||
: field.key === "textHighlightColor"
|
||||
? defaults?.textHighlightColor || "#6ba8e3"
|
||||
: "optional";
|
||||
return (
|
||||
<PaletteColorField
|
||||
key={field.key}
|
||||
label={field.label}
|
||||
help={
|
||||
field.key === "primaryColor" ||
|
||||
field.key === "buttonColor" ||
|
||||
field.key === "textHighlightColor"
|
||||
? `${field.help} Default for ${theme.color}: ${placeholder}.`
|
||||
: field.help
|
||||
}
|
||||
value={String(theme[field.key] ?? "")}
|
||||
swatchFallback={placeholder.startsWith("#") ? placeholder : "#112c59"}
|
||||
placeholder={placeholder}
|
||||
onChange={(next) => patchTheme({ [field.key]: next })}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--destructive)", margin: 0 }}>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "var(--text-sm)",
|
||||
fontFamily: "var(--font-noto-sans)",
|
||||
color: "var(--destructive)",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,9 @@ export type BadgeFormState = {
|
|||
layoutElements: BadgeLayoutElement[];
|
||||
customTexts: { id: string; text: string }[];
|
||||
customImages: { id: string; imageUrl: string }[];
|
||||
outputFormat: "pdf" | "png" | "jpeg";
|
||||
outputFormat: "pdf" | "pdf_per_person" | "png" | "jpeg";
|
||||
printDpi: number;
|
||||
colorSpace: "rgb" | "cmyk";
|
||||
badgeTypeLabel: string;
|
||||
};
|
||||
|
||||
|
|
@ -115,7 +117,12 @@ export function formStateFromBadgeBatch(batch: BadgeBatchRecord): BadgeFormState
|
|||
const scope = str(c.participantScope, "all") === "participant_list" ? "participant_list" : "all";
|
||||
const out = str(c.outputFormat, "pdf");
|
||||
const outputFormat: BadgeFormState["outputFormat"] =
|
||||
out === "png" || out === "jpeg" ? out : "pdf";
|
||||
out === "png" || out === "jpeg" || out === "pdf_per_person" ? out : "pdf";
|
||||
const dpiRaw = Number(c.printDpi);
|
||||
const printDpi =
|
||||
Number.isFinite(dpiRaw) ? Math.min(600, Math.max(72, Math.round(dpiRaw))) : 300;
|
||||
const colorSpace: BadgeFormState["colorSpace"] =
|
||||
str(c.colorSpace, "rgb").toLowerCase() === "cmyk" ? "cmyk" : "rgb";
|
||||
|
||||
return {
|
||||
formName: duplicateExportName(batch.name),
|
||||
|
|
@ -137,6 +144,8 @@ export function formStateFromBadgeBatch(batch: BadgeBatchRecord): BadgeFormState
|
|||
customTexts,
|
||||
customImages,
|
||||
outputFormat,
|
||||
printDpi,
|
||||
colorSpace,
|
||||
badgeTypeLabel: str(c.badgeTypeLabel, "Attendee"),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,6 +82,10 @@ export type BadgeLayoutElement = {
|
|||
/** Default true — reduce font size when text overflows */
|
||||
shrinkToFit?: boolean;
|
||||
textAlign?: BadgeTextAlign;
|
||||
/** Optional string before the field value (default empty) */
|
||||
prefix?: string;
|
||||
/** Optional string after the field value (default empty) */
|
||||
postfix?: string;
|
||||
};
|
||||
|
||||
export const DEFAULT_TEXT_LAYOUT = {
|
||||
|
|
@ -213,6 +217,7 @@ export const BADGE_FONT_FAMILIES = [
|
|||
{ id: "Arial", label: "Arial", css: "Arial, sans-serif" },
|
||||
{ id: "Georgia", label: "Georgia", css: "Georgia, serif" },
|
||||
{ id: "Times New Roman", label: "Times New Roman", css: "'Times New Roman', Times, serif" },
|
||||
{ id: "Swiss921 BT", label: "Swiss921 BT", css: "'Swiss921 BT', sans-serif" },
|
||||
] as const;
|
||||
|
||||
export const DEFAULT_TEXT_COLOR = "#0f172a";
|
||||
|
|
@ -389,23 +394,37 @@ export function previewAvatarColor(seed: string): string {
|
|||
return PREVIEW_AVATAR_COLORS[n % PREVIEW_AVATAR_COLORS.length];
|
||||
}
|
||||
|
||||
export function applyTextAffixes(el: Pick<BadgeLayoutElement, "prefix" | "postfix">, text: string): string {
|
||||
const base = String(text ?? "");
|
||||
if (!base) return "";
|
||||
return `${el.prefix ?? ""}${base}${el.postfix ?? ""}`;
|
||||
}
|
||||
|
||||
export function previewTextForElement(
|
||||
el: BadgeLayoutElement,
|
||||
sample: BadgePreviewSample,
|
||||
): string {
|
||||
if (el.kind === "custom_text") return el.text?.trim() || "Your text";
|
||||
switch (el.field) {
|
||||
case "name":
|
||||
return sample.name;
|
||||
case "title":
|
||||
return sample.title;
|
||||
case "org":
|
||||
return sample.org;
|
||||
case "badge_type":
|
||||
return sample.badgeType;
|
||||
default:
|
||||
return "";
|
||||
let base = "";
|
||||
if (el.kind === "custom_text") base = el.text?.trim() || "Your text";
|
||||
else {
|
||||
switch (el.field) {
|
||||
case "name":
|
||||
base = sample.name;
|
||||
break;
|
||||
case "title":
|
||||
base = sample.title;
|
||||
break;
|
||||
case "org":
|
||||
base = sample.org;
|
||||
break;
|
||||
case "badge_type":
|
||||
base = sample.badgeType;
|
||||
break;
|
||||
default:
|
||||
base = "";
|
||||
}
|
||||
}
|
||||
return applyTextAffixes(el, base);
|
||||
}
|
||||
|
||||
export function isTextElement(el: BadgeLayoutElement): boolean {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import type { Participant as ApiParticipant } from "../../../services/api/types"
|
|||
import { portfolioListOrganizationId } from "../../../services/api/scopedOrganization";
|
||||
import { AddParticipantPanel } from "../panels/AddParticipantPanel";
|
||||
import { CreateCustomListPanel } from "../panels/CreateCustomListPanel";
|
||||
import { ImportEventParticipantsPanel, type EventImportResult } from "../panels/ImportEventParticipantsPanel";
|
||||
import { ParticipantQrModal } from "./ParticipantQrModal";
|
||||
import { ParticipantDocumentsPanel } from "../panels/ParticipantDocumentsPanel";
|
||||
|
||||
|
|
@ -326,125 +327,39 @@ function ParticipantRow({ p, onView,onEdit, onDelete, onConfirm, onReject, onQr,
|
|||
</button>
|
||||
</>
|
||||
)}
|
||||
{/* View Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onView}
|
||||
title="View profile"
|
||||
aria-label="View profile"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
padding: "4px 10px",
|
||||
borderRadius: "var(--radius-button)",
|
||||
background: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
color: "var(--card-foreground)",
|
||||
fontSize: "var(--text-xs)",
|
||||
fontFamily: "var(--font-noto-sans)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = "var(--primary)";
|
||||
e.currentTarget.style.color = "var(--primary)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = "var(--border)";
|
||||
e.currentTarget.style.color = "var(--card-foreground)";
|
||||
}}
|
||||
>
|
||||
<Eye size={11} /> View
|
||||
</button>
|
||||
|
||||
{/* QR Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onQr}
|
||||
title="Check-in QR code (EventBuzz Checkins app)"
|
||||
aria-label="Check-in QR code"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
padding: "4px 10px",
|
||||
borderRadius: "var(--radius-button)",
|
||||
background: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
color: "var(--card-foreground)",
|
||||
fontSize: "var(--text-xs)",
|
||||
fontFamily: "var(--font-noto-sans)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = "var(--primary)";
|
||||
e.currentTarget.style.color = "var(--primary)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = "var(--border)";
|
||||
e.currentTarget.style.color = "var(--card-foreground)";
|
||||
}}
|
||||
>
|
||||
<QrCode size={11} /> QR
|
||||
</button>
|
||||
<button
|
||||
onClick={onDocs}
|
||||
title="Participant documents"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
padding: "4px 10px",
|
||||
borderRadius: "var(--radius-button)",
|
||||
background: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
color: "var(--card-foreground)",
|
||||
fontSize: "var(--text-xs)",
|
||||
fontFamily: "var(--font-noto-sans)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<FileText size={11} /> Document
|
||||
</button>
|
||||
<button
|
||||
onClick={onQr}
|
||||
title="Check-in QR code (EventBuzz Checkins app)"
|
||||
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
padding: "4px 10px",
|
||||
borderRadius: "var(--radius-button)",
|
||||
background: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
color: "var(--card-foreground)",
|
||||
fontSize: "var(--text-xs)",
|
||||
fontFamily: "var(--font-noto-sans)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.borderColor = "var(--primary)";
|
||||
(e.currentTarget as HTMLElement).style.color = "var(--primary)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.borderColor = "var(--border)";
|
||||
(e.currentTarget as HTMLElement).style.color = "var(--card-foreground)";
|
||||
}}
|
||||
>
|
||||
<QrCode size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onView}
|
||||
title="View profile"
|
||||
aria-label="View profile"
|
||||
style={iconBtnStyle}
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--card-foreground)"; }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.borderColor = "var(--primary)"; e.currentTarget.style.color = "var(--primary)"; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.borderColor = "var(--border)"; e.currentTarget.style.color = "var(--card-foreground)"; }}
|
||||
>
|
||||
<Eye size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onQr}
|
||||
title="Check-in QR code (EventBuzz Checkins app)"
|
||||
aria-label="Check-in QR code"
|
||||
style={iconBtnStyle}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.borderColor = "var(--primary)"; e.currentTarget.style.color = "var(--primary)"; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.borderColor = "var(--border)"; e.currentTarget.style.color = "var(--card-foreground)"; }}
|
||||
>
|
||||
<QrCode size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDocs}
|
||||
title="Participant documents"
|
||||
aria-label="Participant documents"
|
||||
style={iconBtnStyle}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.borderColor = "var(--primary)"; e.currentTarget.style.color = "var(--primary)"; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.borderColor = "var(--border)"; e.currentTarget.style.color = "var(--card-foreground)"; }}
|
||||
>
|
||||
<FileText size={13} />
|
||||
</button>
|
||||
|
||||
{canEdit && (
|
||||
<button
|
||||
|
|
@ -805,14 +720,8 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
|
|||
const [editId, setEditId] = useState<string | null>(null);
|
||||
const [confirm, setConfirm] = useState<{ msg: string; onConfirm: () => void } | null>(null);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const importInputRef = useRef<HTMLInputElement>(null);
|
||||
const [importResult, setImportResult] = useState<{
|
||||
created: number;
|
||||
skippedDuplicate: number;
|
||||
failed: number;
|
||||
errors: { line: number; message: string }[];
|
||||
} | null>(null);
|
||||
const [showImportPanel, setShowImportPanel] = useState(false);
|
||||
const [importResult, setImportResult] = useState<EventImportResult | null>(null);
|
||||
const [exportError, setExportError] = useState<string | null>(null);
|
||||
|
||||
const fetchList = useCallback(() => {
|
||||
|
|
@ -916,26 +825,6 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
|
|||
else setExportError(null);
|
||||
};
|
||||
|
||||
const onImportFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || !selectedEventId) return;
|
||||
setImporting(true);
|
||||
try {
|
||||
const text = await file.text();
|
||||
const res = await participantsApi.importCsv(selectedEventId, text);
|
||||
if (res.data) {
|
||||
setImportResult(res.data);
|
||||
fetchList();
|
||||
} else {
|
||||
setExportError(res.error || "Import failed");
|
||||
}
|
||||
} catch {
|
||||
setExportError("Could not read file.");
|
||||
}
|
||||
setImporting(false);
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
const filtersActive = statusFilter !== "All" || designationFilter !== "All" || !!activeTag;
|
||||
|
||||
if (!selectedEventId) {
|
||||
|
|
@ -979,7 +868,6 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
|
|||
</div>
|
||||
)}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" }}>
|
||||
<input ref={importInputRef} type="file" accept=".csv,text/csv" style={{ display: "none" }} onChange={onImportFile} />
|
||||
<button
|
||||
type="button"
|
||||
disabled={exporting}
|
||||
|
|
@ -993,13 +881,12 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
|
|||
{isSuperAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={importing}
|
||||
onClick={() => importInputRef.current?.click()}
|
||||
style={{ display: "flex", alignItems: "center", gap: "6px", padding: "7px 14px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--card-foreground)", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", cursor: importing ? "wait" : "pointer" }}
|
||||
onMouseEnter={(e) => { if (!importing) { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.color = "var(--primary)"; } }}
|
||||
onClick={() => setShowImportPanel(true)}
|
||||
style={{ display: "flex", alignItems: "center", gap: "6px", padding: "7px 14px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--card-foreground)", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", cursor: "pointer" }}
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--card-foreground)"; }}
|
||||
>
|
||||
<Upload size={14} /> {importing ? "Importing…" : isMobile ? "Import" : "Import CSV"}
|
||||
<Upload size={14} /> {isMobile ? "Import" : "Import CSV"}
|
||||
</button>
|
||||
)}
|
||||
{isSuperAdmin && (
|
||||
|
|
@ -1272,6 +1159,16 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
|
|||
onSuccess={() => { fetchList(); setShowAddPanel(false); setEditId(null); }}
|
||||
/>
|
||||
)}
|
||||
{showImportPanel && selectedEventId && (
|
||||
<ImportEventParticipantsPanel
|
||||
eventId={selectedEventId}
|
||||
onClose={() => setShowImportPanel(false)}
|
||||
onSuccess={(result) => {
|
||||
setImportResult(result);
|
||||
fetchList();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{confirm && (
|
||||
<ConfirmModal message={confirm.msg} onConfirm={confirm.onConfirm} onCancel={() => setConfirm(null)} />
|
||||
)}
|
||||
|
|
@ -1280,9 +1177,23 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
|
|||
<div style={{ background: "var(--card)", borderRadius: "var(--radius-card)", maxWidth: "440px", width: "100%", padding: "22px", boxShadow: "var(--shadow-xl)", border: "1px solid var(--border)" }} onClick={(e) => e.stopPropagation()}>
|
||||
<p style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-base)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", margin: "0 0 12px" }}>Import finished</p>
|
||||
<ul style={{ margin: "0 0 14px", paddingLeft: "18px", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)", lineHeight: 1.6 }}>
|
||||
<li><strong>{importResult.created}</strong> added</li>
|
||||
<li><strong>{importResult.skippedDuplicate}</strong> skipped (email already registered for this event)</li>
|
||||
{(importResult.created ?? 0) > 0 && <li><strong>{importResult.created}</strong> added</li>}
|
||||
{(importResult.updated ?? 0) > 0 && <li><strong>{importResult.updated}</strong> updated</li>}
|
||||
{(importResult.removed ?? 0) > 0 && <li><strong>{importResult.removed}</strong> removed from event</li>}
|
||||
<li><strong>{importResult.skipped ?? importResult.skippedDuplicate ?? 0}</strong> skipped</li>
|
||||
{importResult.failed > 0 && <li><strong>{importResult.failed}</strong> rows failed validation</li>}
|
||||
{importResult.listId && (
|
||||
<li>
|
||||
List updated
|
||||
{importResult.listLinked != null ? ` (${importResult.listLinked} linked)` : ""}
|
||||
</li>
|
||||
)}
|
||||
{(importResult.created ?? 0) === 0 &&
|
||||
(importResult.updated ?? 0) === 0 &&
|
||||
(importResult.removed ?? 0) === 0 &&
|
||||
importResult.failed === 0 && (
|
||||
<li>No rows changed</li>
|
||||
)}
|
||||
</ul>
|
||||
{importResult.errors.length > 0 && (
|
||||
<div style={{ maxHeight: "160px", overflowY: "auto", marginBottom: "14px", padding: "10px", background: "var(--secondary)", borderRadius: "var(--radius-button)", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)" }}>
|
||||
|
|
@ -1292,9 +1203,6 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
|
|||
{importResult.failed > importResult.errors.length && <div>…and more</div>}
|
||||
</div>
|
||||
)}
|
||||
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: "0 0 14px", fontFamily: "var(--font-noto-sans)" }}>
|
||||
Required columns: <code style={{ fontSize: "11px" }}>email</code>, <code>firstName</code> (or First name), <code>lastName</code> (or Last name). Optional: phone, organization, designation, tags, notes.
|
||||
</p>
|
||||
<button type="button" onClick={() => setImportResult(null)} style={{ padding: "8px 18px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "var(--primary-foreground)", border: "none", cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)" }}>OK</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
type ParticipantListRecord,
|
||||
type FormWithSubmissionCount,
|
||||
type TicketingManualPaymentSettings,
|
||||
type TicketingOnlinePaymentSettings,
|
||||
type TicketManualOrderRow,
|
||||
} from "../../../services/api";
|
||||
import { buildUpiPayUri } from "../../../utils/upiPayUri";
|
||||
|
|
@ -77,6 +78,12 @@ export function EventTicketingPage() {
|
|||
const [ordersLoading, setOrdersLoading] = useState(false);
|
||||
const [enablementPayBusy, setEnablementPayBusy] = useState(false);
|
||||
|
||||
const [onlineSettings, setOnlineSettings] = useState<TicketingOnlinePaymentSettings | null>(null);
|
||||
const [onlineEnabled, setOnlineEnabled] = useState(false);
|
||||
const [onlineGatewayIds, setOnlineGatewayIds] = useState<string[]>([]);
|
||||
const [onlineSaving, setOnlineSaving] = useState(false);
|
||||
const [onlineErr, setOnlineErr] = useState<string | null>(null);
|
||||
|
||||
const canManageBilling = user?.role === "super_admin" || user?.role === "admin";
|
||||
|
||||
const checkoutUrl =
|
||||
|
|
@ -117,13 +124,16 @@ export function EventTicketingPage() {
|
|||
const loadManual = useCallback(async () => {
|
||||
if (!selectedEventId) {
|
||||
setManualSettings(null);
|
||||
setOnlineSettings(null);
|
||||
return;
|
||||
}
|
||||
setManualLoading(true);
|
||||
setManualErr(null);
|
||||
setOnlineErr(null);
|
||||
try {
|
||||
const [m, evRes] = await Promise.all([
|
||||
const [m, online, evRes] = await Promise.all([
|
||||
eventTicketingManualApi.get(selectedEventId),
|
||||
eventTicketingManualApi.getOnline(selectedEventId),
|
||||
eventsApi.get(selectedEventId),
|
||||
]);
|
||||
setEventTitle(evRes.data?.name?.trim() || "");
|
||||
|
|
@ -134,11 +144,15 @@ export function EventTicketingPage() {
|
|||
setManualUpiId(m.upiId || "");
|
||||
setManualPayee(m.payeeName || "");
|
||||
setManualInstructions(m.instructions || "");
|
||||
setOnlineSettings(online);
|
||||
setOnlineEnabled(online.enabled);
|
||||
setOnlineGatewayIds(online.gatewayIds || []);
|
||||
const ord = await ticketManualOrdersApi.list(selectedEventId, "pending_review");
|
||||
setPendingOrders(ord);
|
||||
} catch (e) {
|
||||
setManualErr(e instanceof Error ? e.message : "Failed to load UPI settings");
|
||||
setManualSettings(null);
|
||||
setOnlineSettings(null);
|
||||
} finally {
|
||||
setManualLoading(false);
|
||||
}
|
||||
|
|
@ -209,6 +223,32 @@ export function EventTicketingPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const saveOnlineSettings = async () => {
|
||||
if (!selectedEventId) return;
|
||||
setOnlineSaving(true);
|
||||
setOnlineErr(null);
|
||||
try {
|
||||
const next = await eventTicketingManualApi.patchOnline(selectedEventId, {
|
||||
enabled: onlineEnabled,
|
||||
gatewayIds: onlineEnabled ? onlineGatewayIds : [],
|
||||
});
|
||||
setOnlineSettings(next);
|
||||
setOnlineEnabled(next.enabled);
|
||||
setOnlineGatewayIds(next.gatewayIds || []);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : "Save failed";
|
||||
setOnlineErr(msg);
|
||||
window.alert(msg);
|
||||
} finally {
|
||||
setOnlineSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleOnlineGateway = (id: string, supported: boolean) => {
|
||||
if (!supported) return;
|
||||
setOnlineGatewayIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
||||
};
|
||||
|
||||
const payEnablementFee = async () => {
|
||||
if (!selectedEventId || !canManageBilling) return;
|
||||
setEnablementPayBusy(true);
|
||||
|
|
@ -402,7 +442,8 @@ export function EventTicketingPage() {
|
|||
</h1>
|
||||
<p style={{ fontSize: "var(--text-sm)", color: "var(--muted)", marginTop: "6px", maxWidth: "560px" }}>
|
||||
Sell tickets on the public checkout page. Buyers are added to the participant list you choose. Online card/UPI uses your
|
||||
Razorpay (or platform) gateway when configured; if not, you can enable per-event UPI QR and review payment screenshots
|
||||
Razorpay (and other organisation gateways you enable below) when configured; you can also enable per-event UPI QR and
|
||||
review payment screenshots
|
||||
below.
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -594,6 +635,108 @@ export function EventTicketingPage() {
|
|||
top: 16,
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
style={{
|
||||
fontFamily: "var(--font-inter)",
|
||||
fontSize: "var(--text-lg)",
|
||||
fontWeight: "var(--font-weight-medium)",
|
||||
margin: "0 0 8px",
|
||||
}}
|
||||
>
|
||||
Online payment gateways
|
||||
</h2>
|
||||
<p style={{ fontSize: "var(--text-sm)", color: "var(--muted)", margin: "0 0 12px" }}>
|
||||
Choose organisation payment gateways (from Settings → Payments) that buyers can use when purchasing tickets for this
|
||||
event.
|
||||
</p>
|
||||
{onlineErr ? <p style={{ color: "var(--destructive)", fontSize: "var(--text-sm)" }}>{onlineErr}</p> : null}
|
||||
{!manualLoading && onlineSettings ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "12px", marginBottom: "20px" }}>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: "8px", fontSize: "var(--text-sm)" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={onlineEnabled}
|
||||
onChange={(e) => {
|
||||
const on = e.target.checked;
|
||||
setOnlineEnabled(on);
|
||||
if (on && onlineGatewayIds.length === 0) {
|
||||
const first = (onlineSettings.orgGateways || []).find(
|
||||
(g) => g.isActive && g.ticketCheckoutSupported,
|
||||
);
|
||||
if (first) setOnlineGatewayIds([first.id]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
Enable online checkout for this event
|
||||
</label>
|
||||
|
||||
{onlineEnabled ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
||||
{(onlineSettings.orgGateways || []).length === 0 ? (
|
||||
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: 0 }}>
|
||||
No gateways on this organisation yet. Add Razorpay (or another provider) under{" "}
|
||||
<strong>Settings → Payments</strong>, then return here to enable it for tickets.
|
||||
</p>
|
||||
) : (
|
||||
(onlineSettings.orgGateways || []).map((g) => {
|
||||
const supported = g.ticketCheckoutSupported && g.isActive;
|
||||
return (
|
||||
<label
|
||||
key={g.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: "8px",
|
||||
fontSize: "var(--text-sm)",
|
||||
opacity: supported ? 1 : 0.55,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={onlineGatewayIds.includes(g.id)}
|
||||
disabled={!supported}
|
||||
onChange={() => toggleOnlineGateway(g.id, supported)}
|
||||
style={{ marginTop: 3 }}
|
||||
/>
|
||||
<span>
|
||||
<strong>{g.label || g.provider}</strong>
|
||||
{" · "}
|
||||
{g.provider}
|
||||
{g.isPrimary ? " · primary" : ""}
|
||||
{g.testMode ? " · test" : " · live"}
|
||||
{!g.isActive ? " · inactive" : ""}
|
||||
{!g.ticketCheckoutSupported ? " · not available for tickets yet" : ""}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: 0 }}>
|
||||
Online card/UPI via your connected gateway will not be offered at ticket checkout for this event.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={onlineSaving}
|
||||
onClick={() => void saveOnlineSettings()}
|
||||
style={{
|
||||
alignSelf: "flex-start",
|
||||
padding: "8px 16px",
|
||||
borderRadius: "var(--radius-button)",
|
||||
border: "none",
|
||||
background: "var(--primary)",
|
||||
color: "var(--primary-foreground)",
|
||||
cursor: onlineSaving ? "wait" : "pointer",
|
||||
}}
|
||||
>
|
||||
{onlineSaving ? "Saving…" : "Save online payment settings"}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<h2
|
||||
style={{
|
||||
fontFamily: "var(--font-inter)",
|
||||
|
|
@ -605,10 +748,10 @@ export function EventTicketingPage() {
|
|||
UPI / manual checkout
|
||||
</h2>
|
||||
<p style={{ fontSize: "var(--text-sm)", color: "var(--muted)", margin: "0 0 12px" }}>
|
||||
For events without an online payment gateway. Buyers pay you directly, then upload proof for you to approve.
|
||||
Buyers pay you directly via UPI, then upload proof for you to approve. Can be used alone or alongside online gateways.
|
||||
</p>
|
||||
{manualErr && <p style={{ color: "var(--destructive)", fontSize: "var(--text-sm)" }}>{manualErr}</p>}
|
||||
{manualLoading && <p style={{ color: "var(--muted)" }}>Loading UPI settings…</p>}
|
||||
{manualLoading && <p style={{ color: "var(--muted)" }}>Loading payment settings…</p>}
|
||||
{!manualLoading && manualSettings && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
|
||||
{!manualSettings.enablementPaid ?
|
||||
|
|
@ -787,24 +930,27 @@ export function EventTicketingPage() {
|
|||
Buyers pay ticket price plus tax only on this path. Eventbuzz is covered by the one-time manual UPI enablement fee you
|
||||
pay per event (Billing).
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled={manualSaving}
|
||||
onClick={() => void saveManualSettings()}
|
||||
style={{
|
||||
alignSelf: "flex-start",
|
||||
padding: "8px 16px",
|
||||
borderRadius: "var(--radius-button)",
|
||||
border: "none",
|
||||
background: "var(--primary)",
|
||||
color: "var(--primary-foreground)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{manualSaving ? "Saving…" : "Save UPI settings"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(manualSettings.enablementPaid || manualEnabled || manualSettings.enabled) ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={manualSaving || (!manualSettings.enablementPaid && manualEnabled)}
|
||||
onClick={() => void saveManualSettings()}
|
||||
style={{
|
||||
alignSelf: "flex-start",
|
||||
padding: "8px 16px",
|
||||
borderRadius: "var(--radius-button)",
|
||||
border: "none",
|
||||
background: "var(--primary)",
|
||||
color: "var(--primary-foreground)",
|
||||
cursor: manualSaving ? "wait" : "pointer",
|
||||
}}
|
||||
>
|
||||
{manualSaving ? "Saving…" : manualEnabled ? "Save UPI settings" : "Save (disable UPI checkout)"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,13 +6,34 @@ import {
|
|||
uploadMediaFile,
|
||||
portfolioListOrganizationId,
|
||||
type ConciergeBotRecord,
|
||||
type ConciergeFaqItem,
|
||||
type KnowledgeSourceRecord,
|
||||
type ContextChunkRecord,
|
||||
type Event,
|
||||
} from "../../../services/api";
|
||||
import { API_BASE_URL } from "../../../services/api/client";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
|
||||
/** Public concierge API base (must end with /api). Falls back to local concierge in dev. */
|
||||
const CONCIERGE_API_BASE = (
|
||||
import.meta.env.VITE_CONCIERGE_URL || "http://localhost:3010/api"
|
||||
).replace(/\/$/, "");
|
||||
|
||||
function faqFormatLabel(format?: string): string {
|
||||
if (format === "mcq") return "MCQ";
|
||||
if (format === "mcq_multi") return "Multi-select";
|
||||
if (format === "mcq_short_text") return "MCQ + Short Answer";
|
||||
if (format === "contact_details") return "Contact details";
|
||||
if (format === "media_upload") return "Image / PDF";
|
||||
return "Short Answer";
|
||||
}
|
||||
|
||||
function inferFaqMediaType(file: File, url: string): "image" | "pdf" | "" {
|
||||
const mime = String(file.type || "").toLowerCase();
|
||||
if (mime.includes("pdf") || /\.pdf$/i.test(file.name) || /\.pdf(\?|#|$)/i.test(url)) return "pdf";
|
||||
if (mime.startsWith("image/") || /\.(png|jpe?g|gif|webp|svg)$/i.test(file.name)) return "image";
|
||||
return "";
|
||||
}
|
||||
|
||||
const IS: React.CSSProperties = {
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
|
|
@ -57,10 +78,11 @@ function eventIdOf(bot: ConciergeBotRecord): string {
|
|||
return "";
|
||||
}
|
||||
|
||||
function buildEmbedSnippet(botId: string) {
|
||||
const apiBase = API_BASE_URL;
|
||||
function buildEmbedSnippet(botId: string, accentColor?: string | null) {
|
||||
const apiBase = CONCIERGE_API_BASE;
|
||||
const widgetSrc = apiBase.replace(/\/api\/?$/, "") + "/embed/concierge-widget.js";
|
||||
return `<script>\n window.EB_CONCIERGE = { botId: \"${botId}\", apiBase: \"${apiBase}\" };\n</script>\n<script async src=\"${widgetSrc}\"></script>`;
|
||||
const accent = String(accentColor || "").trim() || "rgb(13, 148, 136)";
|
||||
return `<script>\n window.EB_CONCIERGE = { botId: \"${botId}\", apiBase: \"${apiBase}\", accentColor: \"${accent}\" };\n</script>\n<script async src=\"${widgetSrc}?v=22\"></script>`;
|
||||
}
|
||||
|
||||
export function WhatsAppChatbotsPage() {
|
||||
|
|
@ -68,7 +90,7 @@ export function WhatsAppChatbotsPage() {
|
|||
const [bots, setBots] = useState<ConciergeBotRecord[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [bot, setBot] = useState<ConciergeBotRecord | null>(null);
|
||||
const [tab, setTab] = useState<"settings" | "knowledge" | "preview">("settings");
|
||||
const [tab, setTab] = useState<"settings" | "venue-faq" | "knowledge" | "preview">("settings");
|
||||
const [sources, setSources] = useState<KnowledgeSourceRecord[]>([]);
|
||||
const [chunks, setChunks] = useState<ContextChunkRecord[]>([]);
|
||||
const [previewSourceId, setPreviewSourceId] = useState<string | null>(null);
|
||||
|
|
@ -147,6 +169,21 @@ export function WhatsAppChatbotsPage() {
|
|||
includePlacesRecommendations: bot.knowledge.includePlacesRecommendations,
|
||||
}
|
||||
: undefined,
|
||||
faqs: (bot.faqs || []).map((f) => ({
|
||||
key: f.key,
|
||||
category: f.category,
|
||||
question: f.question,
|
||||
answerFormat: f.answerFormat,
|
||||
options: f.options || [],
|
||||
answer: f.answer || "",
|
||||
answerDetail: f.answerDetail || "",
|
||||
contactEmail: f.contactEmail || "",
|
||||
contactPhone: f.contactPhone || "",
|
||||
mediaUrl: f.mediaUrl || "",
|
||||
mediaName: f.mediaName || "",
|
||||
mediaType: f.mediaType || "",
|
||||
selectedOptions: f.selectedOptions || [],
|
||||
})),
|
||||
});
|
||||
if (res.error) {
|
||||
setMsg(res.error);
|
||||
|
|
@ -157,6 +194,59 @@ export function WhatsAppChatbotsPage() {
|
|||
await loadBots();
|
||||
};
|
||||
|
||||
const patchFaq = (index: number, patch: Partial<ConciergeFaqItem>) => {
|
||||
if (!bot) return;
|
||||
const faqs = [...(bot.faqs || [])];
|
||||
if (!faqs[index]) return;
|
||||
faqs[index] = { ...faqs[index], ...patch };
|
||||
setBot({ ...bot, faqs });
|
||||
};
|
||||
|
||||
const updateFaqAnswer = (index: number, answer: string) => {
|
||||
patchFaq(index, { answer });
|
||||
};
|
||||
|
||||
const updateFaqAnswerDetail = (index: number, answerDetail: string) => {
|
||||
patchFaq(index, { answerDetail });
|
||||
};
|
||||
|
||||
const toggleFaqOption = (index: number, option: string) => {
|
||||
if (!bot) return;
|
||||
const row = bot.faqs?.[index];
|
||||
if (!row) return;
|
||||
const current = Array.isArray(row.selectedOptions) ? row.selectedOptions : [];
|
||||
const next = current.includes(option)
|
||||
? current.filter((o) => o !== option)
|
||||
: [...current, option];
|
||||
patchFaq(index, { selectedOptions: next });
|
||||
};
|
||||
|
||||
const handleFaqMediaUpload = async (index: number, file: File) => {
|
||||
if (!bot) return;
|
||||
const eid = eventIdOf(bot);
|
||||
setMsg(null);
|
||||
try {
|
||||
const up = await uploadMediaFile(file, {
|
||||
eventId: eid || undefined,
|
||||
module: "concierge-faq",
|
||||
visibility: "public",
|
||||
});
|
||||
const url = String(up.url || "").trim();
|
||||
if (!url) {
|
||||
setMsg("Upload failed — no file URL");
|
||||
return;
|
||||
}
|
||||
patchFaq(index, {
|
||||
mediaUrl: url,
|
||||
mediaName: file.name,
|
||||
mediaType: inferFaqMediaType(file, url),
|
||||
});
|
||||
setMsg("Floor plan / map uploaded — save Bot FAQ to persist");
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : "Upload failed");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newEventId || !newPhoneId) {
|
||||
setMsg("Select an event and enter a WhatsApp phone number ID.");
|
||||
|
|
@ -337,15 +427,20 @@ export function WhatsAppChatbotsPage() {
|
|||
)}
|
||||
{bot && (
|
||||
<>
|
||||
<div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
|
||||
{(["settings", "knowledge", "preview"] as const).map((t) => (
|
||||
<div style={{ display: "flex", gap: 8, marginBottom: 16, flexWrap: "wrap" }}>
|
||||
{([
|
||||
["settings", "Settings"],
|
||||
["venue-faq", "Bot FAQ"],
|
||||
["knowledge", "Knowledge"],
|
||||
["preview", "Preview"],
|
||||
] as const).map(([t, label]) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
style={tab === t ? btnPrimary : btnSecondary}
|
||||
onClick={() => setTab(t)}
|
||||
>
|
||||
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -493,12 +588,16 @@ export function WhatsAppChatbotsPage() {
|
|||
<textarea
|
||||
readOnly
|
||||
style={{ ...IS, minHeight: 110, fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace" }}
|
||||
value={buildEmbedSnippet(bot._id)}
|
||||
value={buildEmbedSnippet(bot._id, bot.accentColor)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
style={{ ...btnSecondary, marginTop: 8 }}
|
||||
onClick={() => navigator.clipboard.writeText(buildEmbedSnippet(bot._id)).then(() => setMsg("Embed code copied"))}
|
||||
onClick={() =>
|
||||
navigator.clipboard
|
||||
.writeText(buildEmbedSnippet(bot._id, bot.accentColor))
|
||||
.then(() => setMsg("Embed code copied"))
|
||||
}
|
||||
>
|
||||
Copy embed code
|
||||
</button>
|
||||
|
|
@ -506,6 +605,246 @@ export function WhatsAppChatbotsPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{tab === "venue-faq" && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<p style={{ margin: 0, fontSize: 13, color: "var(--muted-foreground)" }}>
|
||||
Configure answers for fixed bot FAQ prompts. Questions are not editable. Empty answers fall back to event data or the help desk in the concierge.
|
||||
</p>
|
||||
{(bot.faqs || []).length === 0 && (
|
||||
<p style={{ margin: 0, fontSize: 12, color: "var(--muted-foreground)" }}>
|
||||
No questions yet — open this tab after loading the bot (templates seed automatically).
|
||||
</p>
|
||||
)}
|
||||
{(bot.faqs || []).map((f: ConciergeFaqItem, index: number) => {
|
||||
const format = f.answerFormat || "short_text";
|
||||
const options = Array.isArray(f.options) ? f.options : [];
|
||||
const selected = Array.isArray(f.selectedOptions) ? f.selectedOptions : [];
|
||||
const showMcq = format === "mcq" || format === "mcq_short_text";
|
||||
const showMulti = format === "mcq_multi";
|
||||
const showText =
|
||||
format === "short_text" || format === "mcq_short_text";
|
||||
const showContact = format === "contact_details";
|
||||
const showMedia = format === "media_upload";
|
||||
return (
|
||||
<div
|
||||
key={f.key || `${f.category}-${index}`}
|
||||
style={{ border: "1px solid var(--border)", borderRadius: 8, padding: 12 }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
gap: 8,
|
||||
alignItems: "flex-start",
|
||||
marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
<p style={{ margin: 0, fontSize: 13, fontWeight: 600, lineHeight: 1.4 }}>
|
||||
{f.question}
|
||||
</p>
|
||||
<span
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
fontSize: 11,
|
||||
color: "var(--muted-foreground)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 4,
|
||||
padding: "2px 6px",
|
||||
}}
|
||||
>
|
||||
{faqFormatLabel(format)}
|
||||
</span>
|
||||
</div>
|
||||
{showContact && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<p style={{ margin: 0, fontSize: 12, color: "var(--muted-foreground)" }}>
|
||||
These details are shared with visitors who ask for human / emergency help.
|
||||
</p>
|
||||
<label style={{ display: "block", fontSize: 12 }}>
|
||||
Email
|
||||
<input
|
||||
style={{ ...IS, marginTop: 4 }}
|
||||
type="email"
|
||||
placeholder="organizer@example.com"
|
||||
value={f.contactEmail || ""}
|
||||
onChange={(e) => patchFaq(index, { contactEmail: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: "block", fontSize: 12 }}>
|
||||
Phone
|
||||
<input
|
||||
style={{ ...IS, marginTop: 4 }}
|
||||
type="tel"
|
||||
placeholder="+91 …"
|
||||
value={f.contactPhone || ""}
|
||||
onChange={(e) => patchFaq(index, { contactPhone: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: "block", fontSize: 12 }}>
|
||||
Extra note (optional)
|
||||
<textarea
|
||||
style={{ ...IS, minHeight: 48, marginTop: 4 }}
|
||||
placeholder="e.g. On-site help desk near registration"
|
||||
value={f.answerDetail || ""}
|
||||
onChange={(e) => updateFaqAnswerDetail(index, e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{showMedia && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<p style={{ margin: 0, fontSize: 12, color: "var(--muted-foreground)" }}>
|
||||
Upload a floor plan image or PDF. Visitors will see a preview and download option.
|
||||
</p>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*,.pdf,application/pdf"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) void handleFaqMediaUpload(index, file);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
{f.mediaUrl ? (
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
padding: 10,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12 }}>
|
||||
<strong>{f.mediaName || "Uploaded file"}</strong>
|
||||
{f.mediaType ? ` · ${f.mediaType}` : ""}
|
||||
</div>
|
||||
{f.mediaType === "image" ? (
|
||||
<img
|
||||
src={f.mediaUrl}
|
||||
alt={f.mediaName || "Floor plan"}
|
||||
style={{
|
||||
maxWidth: "100%",
|
||||
maxHeight: 180,
|
||||
objectFit: "contain",
|
||||
borderRadius: 6,
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<a href={f.mediaUrl} target="_blank" rel="noreferrer" style={{ fontSize: 13 }}>
|
||||
Open / preview file
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
style={{ ...btnSecondary, alignSelf: "flex-start" }}
|
||||
onClick={() =>
|
||||
patchFaq(index, { mediaUrl: "", mediaName: "", mediaType: "" })
|
||||
}
|
||||
>
|
||||
Remove file
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{showMulti && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<p style={{ margin: 0, fontSize: 12, color: "var(--muted-foreground)" }}>
|
||||
Select all that apply.
|
||||
</p>
|
||||
{options.map((opt) => (
|
||||
<label
|
||||
key={opt}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
fontSize: 13,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(opt)}
|
||||
onChange={() => toggleFaqOption(index, opt)}
|
||||
/>
|
||||
{opt}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{showMcq && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{options.map((opt) => (
|
||||
<label
|
||||
key={opt}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
fontSize: 13,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={`faq-mcq-${f.key || index}`}
|
||||
checked={(f.answer || "") === opt}
|
||||
onChange={() => updateFaqAnswer(index, opt)}
|
||||
/>
|
||||
{opt}
|
||||
</label>
|
||||
))}
|
||||
{(f.answer || "") && !options.includes(f.answer || "") && (
|
||||
<button
|
||||
type="button"
|
||||
style={{ ...btnSecondary, alignSelf: "flex-start", marginTop: 4 }}
|
||||
onClick={() => updateFaqAnswer(index, "")}
|
||||
>
|
||||
Clear selection
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{showText && (
|
||||
<label style={{ display: "block", fontSize: 12, marginTop: showMcq ? 10 : 0 }}>
|
||||
{format === "mcq_short_text"
|
||||
? "Desk location / details (optional)"
|
||||
: "Answer"}
|
||||
<textarea
|
||||
style={{ ...IS, minHeight: 64, marginTop: 4 }}
|
||||
placeholder={
|
||||
format === "mcq_short_text"
|
||||
? "e.g. Registration desk, Hall A entrance"
|
||||
: format === "short_text" && f.key === "wifi"
|
||||
? "Network name and password"
|
||||
: "Organizer answer (leave blank to use fallback)"
|
||||
}
|
||||
value={
|
||||
format === "short_text"
|
||||
? f.answer || ""
|
||||
: f.answerDetail || ""
|
||||
}
|
||||
onChange={(e) =>
|
||||
format === "short_text"
|
||||
? updateFaqAnswer(index, e.target.value)
|
||||
: updateFaqAnswerDetail(index, e.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<button type="button" style={btnPrimary} onClick={saveSettings}>
|
||||
Save Bot FAQ
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "knowledge" && (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, padding: 12, border: "1px solid var(--border)", borderRadius: 8 }}>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
/** Per-session role for a key person on a session (default Speaker). */
|
||||
export const DEFAULT_KEY_PERSON_ROLE = "Speaker";
|
||||
|
||||
export const KEY_PERSON_ROLES = [
|
||||
"Speaker",
|
||||
"Keynote Speaker",
|
||||
"Moderator",
|
||||
"Panelist",
|
||||
"Host",
|
||||
"Master of Ceremonies",
|
||||
"Workshop Leader",
|
||||
"Facilitator",
|
||||
"Professor",
|
||||
"Trainer",
|
||||
"Instructor",
|
||||
"Session Chair",
|
||||
"Track Chair",
|
||||
"Faculty",
|
||||
"Course Director",
|
||||
"Q&A Facilitator",
|
||||
"Audience Host",
|
||||
"Presenter",
|
||||
"Co-Speaker",
|
||||
"Guest Speaker",
|
||||
"Fireside Guest",
|
||||
"Interviewee",
|
||||
"Discussant",
|
||||
"Respondent",
|
||||
"Mentor",
|
||||
"Coach",
|
||||
"Judge",
|
||||
"Evaluator",
|
||||
"Table Host",
|
||||
"Roundtable Leader",
|
||||
"Lightning Speaker",
|
||||
"Pitcher",
|
||||
"Rapporteur",
|
||||
"Session Scribe",
|
||||
"Virtual/Hybrid Host",
|
||||
] as const;
|
||||
|
||||
export type KeyPersonRole = (typeof KEY_PERSON_ROLES)[number];
|
||||
|
||||
export function normalizeKeyPersonRole(role?: string | null): KeyPersonRole {
|
||||
const value = String(role || "").trim();
|
||||
return (KEY_PERSON_ROLES as readonly string[]).includes(value)
|
||||
? (value as KeyPersonRole)
|
||||
: DEFAULT_KEY_PERSON_ROLE;
|
||||
}
|
||||
|
|
@ -15,6 +15,10 @@ const SAMPLE_VARS: Record<string, string> = {
|
|||
"contact.email": "jane@example.com",
|
||||
"contact.organization": "Acme Inc.",
|
||||
"contact.phone": "+91 98765 43210",
|
||||
"contact.qrUrl":
|
||||
"https://api.qrserver.com/v1/create-qr-code/?size=220x220&ecc=M&margin=8&data=eventbuzz%3Aparticipant%3A507f1f77bcf86cd799439011",
|
||||
"contact.qrImage":
|
||||
'<img src="https://api.qrserver.com/v1/create-qr-code/?size=220x220&ecc=M&margin=8&data=eventbuzz%3Aparticipant%3A507f1f77bcf86cd799439011" alt="Check-in QR" width="200" height="200" style="display:block;margin:12px auto;border:0;" />',
|
||||
"event.name": "Annual Summit 2026",
|
||||
"event.startDate": "15 Mar 2026, 9:00 am",
|
||||
"event.endDate": "17 Mar 2026, 6:00 pm",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export interface FormEventHeaderDisplay {
|
||||
showLogo?: boolean;
|
||||
showBanner?: boolean;
|
||||
showTitle?: boolean;
|
||||
showDateRange?: boolean;
|
||||
showEventType?: boolean;
|
||||
|
|
@ -14,6 +15,7 @@ export function resolveFormEventHeaderDisplay(
|
|||
): ResolvedFormEventHeaderDisplay {
|
||||
return {
|
||||
showLogo: display?.showLogo !== false,
|
||||
showBanner: display?.showBanner === true,
|
||||
showTitle: display?.showTitle !== false,
|
||||
showDateRange: display?.showDateRange !== false,
|
||||
showEventType: display?.showEventType !== false,
|
||||
|
|
@ -25,7 +27,8 @@ export function resolveFormEventHeaderDisplay(
|
|||
export type EventHeaderToggleKey = keyof ResolvedFormEventHeaderDisplay;
|
||||
|
||||
export const EVENT_HEADER_TOGGLE_LABELS: Record<EventHeaderToggleKey, string> = {
|
||||
showLogo: "Event logo / banner",
|
||||
showLogo: "Event logo",
|
||||
showBanner: "Event banner",
|
||||
showTitle: "Event title",
|
||||
showDateRange: "Date range",
|
||||
showEventType: "Event type",
|
||||
|
|
@ -37,6 +40,7 @@ export function eventHeaderFromApi(raw?: FormEventHeaderDisplay | null): Record<
|
|||
const resolved = resolveFormEventHeaderDisplay(raw);
|
||||
return {
|
||||
showLogo: resolved.showLogo ? "Yes" : "No",
|
||||
showBanner: resolved.showBanner ? "Yes" : "No",
|
||||
showTitle: resolved.showTitle ? "Yes" : "No",
|
||||
showDateRange: resolved.showDateRange ? "Yes" : "No",
|
||||
showEventType: resolved.showEventType ? "Yes" : "No",
|
||||
|
|
@ -50,6 +54,7 @@ export function eventHeaderToApi(
|
|||
): FormEventHeaderDisplay {
|
||||
return {
|
||||
showLogo: toggles.showLogo === "Yes",
|
||||
showBanner: toggles.showBanner === "Yes",
|
||||
showTitle: toggles.showTitle === "Yes",
|
||||
showDateRange: toggles.showDateRange === "Yes",
|
||||
showEventType: toggles.showEventType === "Yes",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,19 @@ export function sessionToPayload(s: ApiSession): SessionPayload {
|
|||
else if (typeof trackRaw === "string" && trackRaw.trim()) track = trackRaw;
|
||||
|
||||
const speakers = (s.speakers || []).map((x) => (typeof x === "string" ? x : x._id));
|
||||
const keyPersons =
|
||||
Array.isArray(s.keyPersons) && s.keyPersons.length
|
||||
? s.keyPersons
|
||||
.map((kp) => {
|
||||
const speakerId =
|
||||
typeof kp.speaker === "object" && kp.speaker && "_id" in kp.speaker
|
||||
? String(kp.speaker._id)
|
||||
: String(kp.speaker || "");
|
||||
if (!speakerId) return null;
|
||||
return { speaker: speakerId, role: String(kp.role || "Speaker").trim() || "Speaker" };
|
||||
})
|
||||
.filter((x): x is { speaker: string; role: string } => Boolean(x))
|
||||
: speakers.map((speaker) => ({ speaker, role: "Speaker" }));
|
||||
|
||||
return {
|
||||
title: s.title,
|
||||
|
|
@ -31,6 +44,7 @@ export function sessionToPayload(s: ApiSession): SessionPayload {
|
|||
location: s.location,
|
||||
track,
|
||||
speakers,
|
||||
keyPersons,
|
||||
moderator: s.moderator ?? null,
|
||||
capacity: s.capacity,
|
||||
status: s.status,
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 8.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
|
|
@ -0,0 +1,188 @@
|
|||
.form-ticket-step-label {
|
||||
margin: 0 0 12px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.form-ticket-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-ticket-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.form-ticket-card--active {
|
||||
border-color: #0f172a;
|
||||
box-shadow: 0 0 0 1px #0f172a;
|
||||
}
|
||||
|
||||
.form-ticket-card-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.form-ticket-card-price {
|
||||
font-size: 0.875rem;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.form-ticket-card-desc {
|
||||
font-size: 0.8125rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.form-ticket-channels {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.form-ticket-channel {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
cursor: pointer;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.form-ticket-channel--active {
|
||||
border-color: #0f172a;
|
||||
background: #0f172a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.form-ticket-manual {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.form-ticket-qr {
|
||||
display: block;
|
||||
max-width: 180px;
|
||||
margin: 0 auto 10px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.form-ticket-qr-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 0 0 12px;
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.form-ticket-upi-id {
|
||||
margin: 0 0 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.form-ticket-total {
|
||||
margin: 0 0 12px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.form-ticket-tax {
|
||||
color: #64748b;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.form-ticket-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.form-panel {
|
||||
padding: 24px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
margin-bottom: 6px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
font-size: 0.9rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
color: #0f172a;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-submit {
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: #0f172a;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-submit--secondary {
|
||||
background: #fff;
|
||||
color: #0f172a;
|
||||
border: 1px solid #cbd5e1;
|
||||
}
|
||||
|
||||
.form-submit:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
margin: 0;
|
||||
color: #dc2626;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.form-desc {
|
||||
margin: 0 0 8px;
|
||||
color: #64748b;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.form-field {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
|
@ -0,0 +1,417 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import QRCode from 'qrcode';
|
||||
import { loadRazorpayScript } from '../../services/api/subscription';
|
||||
import {
|
||||
publicEventTicketsApi,
|
||||
type PublicTicketCatalogItem,
|
||||
type TicketQuote,
|
||||
} from '../../services/api/publicEventTickets';
|
||||
import type { TicketAttendeePayload } from '../../utils/registrationTicketAttendee';
|
||||
import { formatTicketPrice, INDIAN_STATES } from '../../utils/registrationTicketAttendee';
|
||||
import { buildUpiPayUri } from '../../utils/upiPayUri';
|
||||
import './RegistrationTicketStep.css';
|
||||
|
||||
const PAYMENT_CANCELLED_MESSAGE = 'Payment cancelled';
|
||||
|
||||
type ManualUpi = NonNullable<
|
||||
Awaited<ReturnType<typeof publicEventTicketsApi.checkoutOptions>>['manualUpi']
|
||||
>;
|
||||
|
||||
type Props = {
|
||||
eventId: string;
|
||||
formId: string;
|
||||
eventName?: string;
|
||||
tickets: PublicTicketCatalogItem[];
|
||||
attendee: TicketAttendeePayload;
|
||||
onBack: () => void;
|
||||
onPaid: () => void;
|
||||
onPendingReview: (message: string) => void;
|
||||
};
|
||||
|
||||
export function RegistrationTicketStep({
|
||||
eventId,
|
||||
formId,
|
||||
eventName,
|
||||
tickets,
|
||||
attendee,
|
||||
onBack,
|
||||
onPaid,
|
||||
onPendingReview,
|
||||
}: Props) {
|
||||
const [ticketId, setTicketId] = useState(tickets[0]?._id || '');
|
||||
const [billingState, setBillingState] = useState('');
|
||||
const [quote, setQuote] = useState<TicketQuote | null>(null);
|
||||
const [quoteError, setQuoteError] = useState<string | null>(null);
|
||||
const [payError, setPayError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [checkoutChannel, setCheckoutChannel] = useState<'online' | 'manual'>('online');
|
||||
const [onlineAvailable, setOnlineAvailable] = useState(false);
|
||||
const [onlineGateways, setOnlineGateways] = useState<
|
||||
NonNullable<Awaited<ReturnType<typeof publicEventTicketsApi.checkoutOptions>>['onlineGateways']>
|
||||
>([]);
|
||||
const [selectedOnlineGatewayId, setSelectedOnlineGatewayId] = useState('');
|
||||
const [manualUpi, setManualUpi] = useState<ManualUpi | null>(null);
|
||||
const [screenshotDataUrl, setScreenshotDataUrl] = useState('');
|
||||
const [buyerNote, setBuyerNote] = useState('');
|
||||
const [dynamicQrDataUrl, setDynamicQrDataUrl] = useState<string | null>(null);
|
||||
|
||||
const selected = useMemo(() => tickets.find((t) => t._id === ticketId) || null, [tickets, ticketId]);
|
||||
const payMode = useMemo(() => {
|
||||
if (onlineAvailable && manualUpi) return 'both';
|
||||
if (onlineAvailable) return 'online';
|
||||
if (manualUpi) return 'manual';
|
||||
return 'none';
|
||||
}, [onlineAvailable, manualUpi]);
|
||||
|
||||
const isManualCheckout = payMode === 'manual' || (payMode === 'both' && checkoutChannel === 'manual');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void publicEventTicketsApi
|
||||
.checkoutOptions(eventId)
|
||||
.then((opts) => {
|
||||
if (cancelled) return;
|
||||
setOnlineAvailable(!!opts.onlineAvailable);
|
||||
setManualUpi(opts.manualUpi || null);
|
||||
const gateways = opts.onlineGateways || [];
|
||||
setOnlineGateways(gateways);
|
||||
setSelectedOnlineGatewayId(gateways[0]?.id || '');
|
||||
if (!opts.onlineAvailable && opts.manualUpi) setCheckoutChannel('manual');
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setOnlineAvailable(false);
|
||||
setManualUpi(null);
|
||||
setOnlineGateways([]);
|
||||
setSelectedOnlineGatewayId('');
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [eventId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ticketId || !selected) {
|
||||
setQuote(null);
|
||||
return;
|
||||
}
|
||||
if (selected.requiresBillingState && !billingState) {
|
||||
setQuote(null);
|
||||
setQuoteError(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setQuoteError(null);
|
||||
const run = async () => {
|
||||
try {
|
||||
const q = isManualCheckout
|
||||
? await publicEventTicketsApi.manualQuote(eventId, {
|
||||
ticketId,
|
||||
quantity: 1,
|
||||
billingStateCode: billingState || undefined,
|
||||
})
|
||||
: await publicEventTicketsApi.quote(eventId, {
|
||||
ticketId,
|
||||
quantity: 1,
|
||||
billingStateCode: billingState || undefined,
|
||||
});
|
||||
if (!cancelled) setQuote(q);
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setQuote(null);
|
||||
setQuoteError(e instanceof Error ? e.message : 'Could not load price');
|
||||
}
|
||||
}
|
||||
};
|
||||
void run();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [eventId, ticketId, billingState, selected, isManualCheckout]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isManualCheckout || !manualUpi || manualUpi.manualQrMode !== 'dynamic') {
|
||||
setDynamicQrDataUrl(null);
|
||||
return;
|
||||
}
|
||||
const pa = manualUpi.upiId?.trim();
|
||||
const evName = (manualUpi.eventName || eventName || '').trim();
|
||||
const quoteTotal = quote?.totalMinor;
|
||||
if (!pa || !selected?.name || !evName || quoteTotal == null) {
|
||||
setDynamicQrDataUrl(null);
|
||||
return;
|
||||
}
|
||||
const pn = `${selected.name} · ${evName}`.slice(0, 99);
|
||||
const am = (quoteTotal / 100).toFixed(2);
|
||||
const uri = buildUpiPayUri({ pa, pn, am });
|
||||
if (!uri) {
|
||||
setDynamicQrDataUrl(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void QRCode.toDataURL(uri, { margin: 1, width: 280, errorCorrectionLevel: 'M' }).then((url) => {
|
||||
if (!cancelled) setDynamicQrDataUrl(url);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isManualCheckout, manualUpi, selected?.name, eventName, quote?.totalMinor]);
|
||||
|
||||
const onScreenshot = useCallback((file: File | null) => {
|
||||
if (!file) {
|
||||
setScreenshotDataUrl('');
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => setScreenshotDataUrl(typeof reader.result === 'string' ? reader.result : '');
|
||||
reader.readAsDataURL(file);
|
||||
}, []);
|
||||
|
||||
const pay = async () => {
|
||||
setPayError(null);
|
||||
if (!selected || !quote) {
|
||||
setPayError('Select a ticket and wait for the price.');
|
||||
return;
|
||||
}
|
||||
if (selected.requiresBillingState && !billingState) {
|
||||
setPayError('Select your billing state.');
|
||||
return;
|
||||
}
|
||||
if (!attendee.email || !attendee.firstName || !attendee.lastName) {
|
||||
setPayError('Registration details are incomplete. Go back and fill required fields.');
|
||||
return;
|
||||
}
|
||||
if (payMode === 'none') {
|
||||
setPayError('Payment is not available for this event yet.');
|
||||
return;
|
||||
}
|
||||
if (isManualCheckout && !screenshotDataUrl) {
|
||||
setPayError('Upload a payment screenshot to continue.');
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
const attendees = [attendee];
|
||||
const pe = attendee.email;
|
||||
if (isManualCheckout) {
|
||||
const res = await publicEventTicketsApi.manualSubmit(eventId, {
|
||||
ticketId,
|
||||
quantity: 1,
|
||||
billingStateCode: billingState || undefined,
|
||||
purchaserEmail: pe,
|
||||
sourceFormId: formId,
|
||||
expectedTotalMinor: quote.totalMinor,
|
||||
screenshotDataUrl,
|
||||
buyerNote: buyerNote.trim() || undefined,
|
||||
attendees,
|
||||
});
|
||||
onPendingReview(res.message || 'Submitted for review.');
|
||||
return;
|
||||
}
|
||||
|
||||
const order = await publicEventTicketsApi.checkout(eventId, {
|
||||
ticketId,
|
||||
quantity: 1,
|
||||
billingStateCode: billingState || undefined,
|
||||
purchaserEmail: pe,
|
||||
sourceFormId: formId,
|
||||
expectedTotalMinor: quote.totalMinor,
|
||||
idempotencyKey: `reg-${formId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
orgGatewayId: selectedOnlineGatewayId || onlineGateways[0]?.id || undefined,
|
||||
attendees,
|
||||
});
|
||||
|
||||
await loadRazorpayScript();
|
||||
type RazorpayResponse = { razorpay_order_id: string; razorpay_payment_id: string; razorpay_signature: string };
|
||||
type RazorpayConstructor = new (opts: {
|
||||
key: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
order_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
handler: (response: RazorpayResponse) => void;
|
||||
modal?: { ondismiss?: () => void };
|
||||
theme?: { color?: string };
|
||||
prefill?: { email?: string; name?: string };
|
||||
}) => { open: () => void; on: (ev: string, fn: (r: { error: { description?: string } }) => void) => void };
|
||||
|
||||
const Rzp = (window as unknown as { Razorpay: RazorpayConstructor }).Razorpay;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let invoked = false;
|
||||
const rzp = new Rzp({
|
||||
key: order.keyId,
|
||||
amount: order.amount,
|
||||
currency: order.currency,
|
||||
order_id: order.orderId,
|
||||
name: eventName || 'Event',
|
||||
description: selected.name,
|
||||
handler: (response: RazorpayResponse) => {
|
||||
invoked = true;
|
||||
void publicEventTicketsApi
|
||||
.verifyPayment(eventId, {
|
||||
orderId: response.razorpay_order_id,
|
||||
paymentId: response.razorpay_payment_id,
|
||||
signature: response.razorpay_signature,
|
||||
})
|
||||
.then(() => resolve())
|
||||
.catch((e) => reject(e instanceof Error ? e : new Error('Verification failed')));
|
||||
},
|
||||
modal: {
|
||||
ondismiss: () => {
|
||||
if (!invoked) reject(new Error(PAYMENT_CANCELLED_MESSAGE));
|
||||
},
|
||||
},
|
||||
theme: { color: '#0f172a' },
|
||||
prefill: {
|
||||
email: pe,
|
||||
name: `${attendee.firstName} ${attendee.lastName}`.trim(),
|
||||
},
|
||||
});
|
||||
rzp.on('payment.failed', (response: { error: { description?: string } }) => {
|
||||
reject(new Error(response.error?.description || 'Payment failed'));
|
||||
});
|
||||
rzp.open();
|
||||
});
|
||||
onPaid();
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'Checkout failed';
|
||||
if (msg !== PAYMENT_CANCELLED_MESSAGE) setPayError(msg);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!tickets.length) {
|
||||
return (
|
||||
<div className="form-panel">
|
||||
<p className="form-error">No tickets are available for this form.</p>
|
||||
<button type="button" className="form-submit" onClick={onBack}>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="form-panel form-ticket-step">
|
||||
<p className="form-ticket-step-label">Step 2 of 2 — Choose your ticket</p>
|
||||
<div className="form-ticket-list">
|
||||
{tickets.map((t) => {
|
||||
const active = t._id === ticketId;
|
||||
return (
|
||||
<button
|
||||
key={t._id}
|
||||
type="button"
|
||||
className={`form-ticket-card${active ? ' form-ticket-card--active' : ''}`}
|
||||
onClick={() => setTicketId(t._id)}
|
||||
>
|
||||
<span className="form-ticket-card-name">{t.name}</span>
|
||||
<span className="form-ticket-card-price">
|
||||
{formatTicketPrice(t.basePriceMinor, t.currency || 'INR')}
|
||||
</span>
|
||||
{t.description?.trim() ? (
|
||||
<span className="form-ticket-card-desc">{t.description.trim()}</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{selected?.requiresBillingState ? (
|
||||
<label className="form-field">
|
||||
<span className="form-label">Billing state</span>
|
||||
<select
|
||||
className="form-input"
|
||||
value={billingState}
|
||||
onChange={(e) => setBillingState(e.target.value)}
|
||||
>
|
||||
<option value="">Select state…</option>
|
||||
{INDIAN_STATES.map((s) => (
|
||||
<option key={s.code} value={s.code}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
{payMode === 'both' ? (
|
||||
<div className="form-ticket-channels">
|
||||
<button
|
||||
type="button"
|
||||
className={`form-ticket-channel${checkoutChannel === 'online' ? ' form-ticket-channel--active' : ''}`}
|
||||
onClick={() => setCheckoutChannel('online')}
|
||||
>
|
||||
Card / UPI (Razorpay)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`form-ticket-channel${checkoutChannel === 'manual' ? ' form-ticket-channel--active' : ''}`}
|
||||
onClick={() => setCheckoutChannel('manual')}
|
||||
>
|
||||
Manual UPI
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isManualCheckout && manualUpi ? (
|
||||
<div className="form-ticket-manual">
|
||||
{manualUpi.upiId ? <p className="form-ticket-upi-id">UPI ID: {manualUpi.upiId}</p> : null}
|
||||
{manualUpi.instructions ? <p className="form-desc">{manualUpi.instructions}</p> : null}
|
||||
<div className="form-ticket-qr-wrap">
|
||||
{manualUpi.manualQrMode === 'dynamic' ? (
|
||||
dynamicQrDataUrl ? (
|
||||
<img src={dynamicQrDataUrl} alt="UPI QR" className="form-ticket-qr" />
|
||||
) : (
|
||||
<p className="form-desc">Select a ticket to generate the UPI QR with the exact amount.</p>
|
||||
)
|
||||
) : manualUpi.upiQrImageUrl ? (
|
||||
<img src={manualUpi.upiQrImageUrl} alt="UPI QR" className="form-ticket-qr" />
|
||||
) : null}
|
||||
</div>
|
||||
<label className="form-field">
|
||||
<span className="form-label">Payment screenshot</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="form-input"
|
||||
onChange={(e) => onScreenshot(e.target.files?.[0] || null)}
|
||||
/>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
<span className="form-label">Note (optional)</span>
|
||||
<input
|
||||
className="form-input"
|
||||
value={buyerNote}
|
||||
onChange={(e) => setBuyerNote(e.target.value)}
|
||||
placeholder="Transaction reference"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{quoteError ? <p className="form-error">{quoteError}</p> : null}
|
||||
{quote ? (
|
||||
<p className="form-ticket-total">
|
||||
Total: <strong>{formatTicketPrice(quote.totalMinor, quote.currency)}</strong>
|
||||
{quote.taxMinor > 0 ? (
|
||||
<span className="form-ticket-tax"> (incl. tax {formatTicketPrice(quote.taxMinor, quote.currency)})</span>
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
{payError ? <p className="form-error">{payError}</p> : null}
|
||||
|
||||
<div className="form-ticket-actions">
|
||||
<button type="button" className="form-submit form-submit--secondary" onClick={onBack} disabled={busy}>
|
||||
Back
|
||||
</button>
|
||||
<button type="button" className="form-submit" onClick={() => void pay()} disabled={busy || !quote || payMode === 'none'}>
|
||||
{busy ? 'Processing…' : isManualCheckout ? 'Submit payment proof' : 'Pay & register'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { api } from './client';
|
||||
import { api, fetchWithAuth } from './client';
|
||||
|
||||
export type BadgeBatchStatus = 'pending' | 'processing' | 'ready' | 'failed';
|
||||
|
||||
|
|
@ -35,6 +35,8 @@ export interface BadgeLayoutElementPayload {
|
|||
fullWidthCenter?: boolean;
|
||||
shrinkToFit?: boolean;
|
||||
textAlign?: 'left' | 'center' | 'right';
|
||||
prefix?: string;
|
||||
postfix?: string;
|
||||
}
|
||||
|
||||
export interface BadgeCustomTextPayload {
|
||||
|
|
@ -74,7 +76,11 @@ export interface CreateBadgeBatchPayload {
|
|||
layout?: BadgeLayoutElementPayload[];
|
||||
customTexts?: BadgeCustomTextPayload[];
|
||||
customImages?: BadgeCustomImagePayload[];
|
||||
outputFormat: 'pdf' | 'png' | 'jpeg';
|
||||
outputFormat: 'pdf' | 'pdf_per_person' | 'png' | 'jpeg';
|
||||
/** Rasterization DPI for PNG/JPEG and raster assets (72–600, default 300). */
|
||||
printDpi?: number;
|
||||
/** PDF color space: rgb (screen) or cmyk (native DeviceCMYK text/fills). */
|
||||
colorSpace?: 'rgb' | 'cmyk';
|
||||
badgeTypeLabel?: string;
|
||||
}
|
||||
|
||||
|
|
@ -101,4 +107,51 @@ export const badgeBatchesApi = {
|
|||
create(payload: CreateBadgeBatchPayload) {
|
||||
return api.post<BadgeBatchRecord>('/badge-batches', payload);
|
||||
},
|
||||
|
||||
remove(id: string, organization?: string) {
|
||||
const sp = new URLSearchParams();
|
||||
if (organization) sp.set('organization', organization);
|
||||
const q = sp.toString();
|
||||
return api.delete<{ deleted: boolean }>(`/badge-batches/${id}${q ? `?${q}` : ''}`);
|
||||
},
|
||||
|
||||
/** True when worker saved zip under BADGE_BATCH_LOCAL_DIR (zipS3Key prefix local:). */
|
||||
isLocalExport(batch: Pick<BadgeBatchRecord, 'zipS3Key' | 'zipUrl'> | null | undefined) {
|
||||
if (batch?.zipS3Key?.startsWith('local:')) return true;
|
||||
return Boolean(batch?.zipUrl && /\/api\/badge-batches\/[^/]+\/download/.test(batch.zipUrl));
|
||||
},
|
||||
|
||||
async downloadZip(batch: BadgeBatchRecord, organization?: string) {
|
||||
if (!this.isLocalExport(batch)) {
|
||||
if (!batch.zipUrl) throw new Error('No download URL');
|
||||
window.open(batch.zipUrl, '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
}
|
||||
const sp = new URLSearchParams();
|
||||
if (organization) sp.set('organization', organization);
|
||||
const q = sp.toString();
|
||||
const res = await fetchWithAuth(`/badge-batches/${batch._id}/download${q ? `?${q}` : ''}`);
|
||||
if (!res.ok) {
|
||||
let message = res.statusText || 'Download failed';
|
||||
try {
|
||||
const body = (await res.json()) as { error?: { message?: string } };
|
||||
if (body?.error?.message) message = body.error.message;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const cd = res.headers.get('Content-Disposition') || '';
|
||||
const match = /filename="?([^"]+)"?/i.exec(cd);
|
||||
const filename = match?.[1] || `badge-batch-${batch._id}.zip`;
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = objectUrl;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -21,17 +21,65 @@ export interface ConciergeBotKnowledge {
|
|||
lastPlacesSnapshotAt?: string;
|
||||
}
|
||||
|
||||
export type ConciergeFaqCategory =
|
||||
| 'venue_map'
|
||||
| 'venue_wayfinding'
|
||||
| 'venue_amenity'
|
||||
| 'venue_transport'
|
||||
| 'venue_parking'
|
||||
| 'venue_parking_transport'
|
||||
| 'venue_food'
|
||||
| 'venue_accessibility'
|
||||
| 'wifi'
|
||||
| 'app_account'
|
||||
| 'checkin_venue'
|
||||
| 'emergency_contact'
|
||||
| 'ticket_checkin'
|
||||
| 'session_recordings'
|
||||
| 'registration_update'
|
||||
| 'checkin_timing'
|
||||
| 'dress_code'
|
||||
| 'refund_policy';
|
||||
|
||||
export type ConciergeFaqAnswerFormat =
|
||||
| 'mcq'
|
||||
| 'short_text'
|
||||
| 'mcq_short_text'
|
||||
| 'mcq_multi'
|
||||
| 'contact_details'
|
||||
| 'media_upload';
|
||||
|
||||
export interface ConciergeFaqItem {
|
||||
key?: string;
|
||||
category: ConciergeFaqCategory;
|
||||
question: string;
|
||||
answerFormat?: ConciergeFaqAnswerFormat;
|
||||
options?: string[];
|
||||
answer?: string;
|
||||
/** Extra free text for short_text / mcq_short_text (e.g. desk location). */
|
||||
answerDetail?: string;
|
||||
contactEmail?: string;
|
||||
contactPhone?: string;
|
||||
mediaUrl?: string;
|
||||
mediaName?: string;
|
||||
mediaType?: string;
|
||||
selectedOptions?: string[];
|
||||
}
|
||||
|
||||
export interface ConciergeBotRecord {
|
||||
_id: string;
|
||||
organization: string;
|
||||
event: string | { _id: string; name?: string; startDate?: string; endDate?: string };
|
||||
name?: string;
|
||||
wabaPhoneNumberId: string;
|
||||
/** Website widget accent (hex or rgb). */
|
||||
accentColor?: string;
|
||||
enabled: boolean;
|
||||
status: ConciergeBotStatus;
|
||||
greeting?: ConciergeBotGreeting;
|
||||
registration?: ConciergeBotRegistration;
|
||||
knowledge?: ConciergeBotKnowledge;
|
||||
faqs?: ConciergeFaqItem[];
|
||||
lastKnowledgeClearedAt?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
|
|
@ -110,9 +158,11 @@ export const conciergeBotsApi = {
|
|||
payload: Partial<{
|
||||
name: string;
|
||||
wabaPhoneNumberId: string;
|
||||
accentColor: string;
|
||||
greeting: ConciergeBotGreeting;
|
||||
registration: ConciergeBotRegistration;
|
||||
knowledge: ConciergeBotKnowledge;
|
||||
faqs: ConciergeFaqItem[];
|
||||
enabled: boolean;
|
||||
}>,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,24 @@ export type TicketingManualPaymentSettings = {
|
|||
enablementPaidAt: string | null;
|
||||
};
|
||||
|
||||
export type TicketingOnlineGatewayOption = {
|
||||
id: string;
|
||||
provider: string;
|
||||
label: string;
|
||||
isActive: boolean;
|
||||
isPrimary: boolean;
|
||||
testMode: boolean;
|
||||
ticketCheckoutSupported: boolean;
|
||||
};
|
||||
|
||||
export type TicketingOnlinePaymentSettings = {
|
||||
configured: boolean;
|
||||
enabled: boolean;
|
||||
gatewayIds: string[];
|
||||
orgGateways: TicketingOnlineGatewayOption[];
|
||||
activeForCheckout: TicketingOnlineGatewayOption[];
|
||||
};
|
||||
|
||||
export type TicketManualOrderRow = {
|
||||
_id: string;
|
||||
status: string;
|
||||
|
|
@ -58,6 +76,26 @@ export const eventTicketingManualApi = {
|
|||
return r.data.ticketingManualPayment;
|
||||
},
|
||||
|
||||
async getOnline(eventId: string): Promise<TicketingOnlinePaymentSettings> {
|
||||
const r = await api.get<{ ticketingOnlinePayment: TicketingOnlinePaymentSettings }>(
|
||||
`/events/${eventId}/ticketing-online-payment`,
|
||||
);
|
||||
if (r.error || !r.data?.ticketingOnlinePayment) throw new Error(r.error ?? 'Failed to load online payment settings');
|
||||
return r.data.ticketingOnlinePayment;
|
||||
},
|
||||
|
||||
async patchOnline(
|
||||
eventId: string,
|
||||
payload: { enabled: boolean; gatewayIds: string[] },
|
||||
): Promise<TicketingOnlinePaymentSettings> {
|
||||
const r = await api.patch<{ ticketingOnlinePayment: TicketingOnlinePaymentSettings }>(
|
||||
`/events/${eventId}/ticketing-online-payment`,
|
||||
payload,
|
||||
);
|
||||
if (r.error || !r.data?.ticketingOnlinePayment) throw new Error(r.error ?? 'Failed to save');
|
||||
return r.data.ticketingOnlinePayment;
|
||||
},
|
||||
|
||||
async checkoutEnablementOrder(eventId: string): Promise<{
|
||||
keyId: string;
|
||||
orderId: string;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
import { api } from './client';
|
||||
|
||||
export type ExhibitorStatus = 'draft' | 'published' | 'hidden';
|
||||
|
||||
export type ExhibitorSocialLinks = {
|
||||
linkedin?: string;
|
||||
twitter?: string;
|
||||
instagram?: string;
|
||||
website?: string;
|
||||
};
|
||||
|
||||
export type ApiExhibitor = {
|
||||
_id: string;
|
||||
event: string;
|
||||
name: string;
|
||||
status: ExhibitorStatus;
|
||||
logo?: string;
|
||||
shortDescription?: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
boothNumber?: string;
|
||||
hall?: string;
|
||||
website?: string;
|
||||
isFeatured?: boolean;
|
||||
contactName?: string;
|
||||
contactEmail?: string;
|
||||
contactPhone?: string;
|
||||
socialLinks?: ExhibitorSocialLinks;
|
||||
products?: string[];
|
||||
internalNotes?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type ExhibitorPayload = {
|
||||
name: string;
|
||||
status?: ExhibitorStatus;
|
||||
logo?: string;
|
||||
shortDescription?: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
boothNumber?: string;
|
||||
hall?: string;
|
||||
website?: string;
|
||||
isFeatured?: boolean;
|
||||
contactName?: string;
|
||||
contactEmail?: string;
|
||||
contactPhone?: string;
|
||||
socialLinks?: ExhibitorSocialLinks;
|
||||
products?: string[];
|
||||
internalNotes?: string;
|
||||
};
|
||||
|
||||
export const exhibitorsApi = {
|
||||
list(eventId: string, params?: { q?: string }) {
|
||||
const sp = new URLSearchParams();
|
||||
if (params?.q) sp.set('q', params.q);
|
||||
const q = sp.toString();
|
||||
return api.get<{ exhibitors: ApiExhibitor[]; total: number }>(
|
||||
`/events/${eventId}/exhibitors${q ? `?${q}` : ''}`,
|
||||
);
|
||||
},
|
||||
|
||||
get(id: string) {
|
||||
return api.get<ApiExhibitor>(`/exhibitors/${id}`);
|
||||
},
|
||||
|
||||
create(eventId: string, payload: ExhibitorPayload) {
|
||||
return api.post<ApiExhibitor>(`/events/${eventId}/exhibitors`, payload);
|
||||
},
|
||||
|
||||
update(id: string, payload: Partial<ExhibitorPayload> & { name?: string }) {
|
||||
return api.put<ApiExhibitor>(`/exhibitors/${id}`, payload);
|
||||
},
|
||||
|
||||
remove(id: string) {
|
||||
return api.delete<null>(`/exhibitors/${id}`);
|
||||
},
|
||||
};
|
||||
|
|
@ -39,15 +39,31 @@ export {
|
|||
eventTicketingManualApi,
|
||||
ticketManualOrdersApi,
|
||||
type TicketingManualPaymentSettings,
|
||||
type TicketingOnlinePaymentSettings,
|
||||
type TicketingOnlineGatewayOption,
|
||||
type TicketManualOrderRow,
|
||||
type PatchTicketingManualPayload,
|
||||
} from './eventTicketingManual';
|
||||
export { speakersApi } from './speakers';
|
||||
export {
|
||||
exhibitorsApi,
|
||||
type ApiExhibitor,
|
||||
type ExhibitorPayload,
|
||||
type ExhibitorStatus,
|
||||
type ExhibitorSocialLinks,
|
||||
} from './exhibitors';
|
||||
export { formsApi } from './forms';
|
||||
export { rolesApi } from './roles';
|
||||
export { usersApi } from './users';
|
||||
export { tracksApi, type ApiTrack, type TrackPayload } from './tracks';
|
||||
export { sessionsApi, type ApiSession, type SessionPayload, type SessionTopic } from './sessions';
|
||||
export {
|
||||
sessionsApi,
|
||||
type ApiSession,
|
||||
type ApiSessionKeyPerson,
|
||||
type SessionPayload,
|
||||
type SessionKeyPersonPayload,
|
||||
type SessionTopic,
|
||||
} from './sessions';
|
||||
export { eventStaffApi, type EventStaffMember, type AgendaTrack } from './eventStaff';
|
||||
export { quizPollsApi, type QuizPoll, type QuizPollPayload, type QuizPollQuestion } from './quizPolls';
|
||||
export {
|
||||
|
|
@ -73,6 +89,8 @@ export { whatsappTemplatesApi } from './whatsappTemplates';
|
|||
export {
|
||||
conciergeBotsApi,
|
||||
type ConciergeBotRecord,
|
||||
type ConciergeFaqCategory,
|
||||
type ConciergeFaqItem,
|
||||
type KnowledgeSourceRecord,
|
||||
type ContextChunkRecord,
|
||||
} from './conciergeBots';
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ export const organizationApi = {
|
|||
hasPassword: boolean;
|
||||
fromName: string;
|
||||
fromAddress: string;
|
||||
replyTo: string;
|
||||
}>('/organization/email-settings');
|
||||
},
|
||||
|
||||
|
|
@ -56,6 +57,7 @@ export const organizationApi = {
|
|||
password?: string;
|
||||
fromName?: string;
|
||||
fromAddress?: string;
|
||||
replyTo?: string;
|
||||
}) {
|
||||
return api.put<{
|
||||
enabled: boolean;
|
||||
|
|
@ -67,6 +69,7 @@ export const organizationApi = {
|
|||
hasPassword: boolean;
|
||||
fromName: string;
|
||||
fromAddress: string;
|
||||
replyTo: string;
|
||||
}>('/organization/email-settings', payload);
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -185,13 +185,39 @@ export const participantsApi = {
|
|||
return { ok: true as const };
|
||||
},
|
||||
|
||||
importCsv(eventId: string, csv: string) {
|
||||
importCsv(
|
||||
eventId: string,
|
||||
payload: {
|
||||
csv: string;
|
||||
mode?:
|
||||
| 'NEW_INSERT'
|
||||
| 'UPSERT'
|
||||
| 'UPDATE'
|
||||
| 'REPLACE'
|
||||
| 'TAGS_ONLY'
|
||||
| 'STATUS_UPDATE'
|
||||
| 'REMOVE'
|
||||
| 'SYNC';
|
||||
confirmSync?: boolean;
|
||||
membershipStatus?: 'pending' | 'confirmed' | 'rejected';
|
||||
tags?: string[];
|
||||
listAction?: 'none' | 'create' | 'add_existing';
|
||||
listName?: string;
|
||||
listDescription?: string;
|
||||
existingListId?: string;
|
||||
}
|
||||
) {
|
||||
return api.post<{
|
||||
created: number;
|
||||
updated: number;
|
||||
removed: number;
|
||||
skipped: number;
|
||||
skippedDuplicate: number;
|
||||
failed: number;
|
||||
errors: { line: number; message: string }[];
|
||||
}>(`/events/${eventId}/participants/import`, { csv });
|
||||
listId?: string;
|
||||
listLinked?: number;
|
||||
}>(`/events/${eventId}/participants/import`, payload);
|
||||
},
|
||||
|
||||
async downloadExportAll(
|
||||
|
|
|
|||
|
|
@ -62,6 +62,15 @@ export type TicketQuote = {
|
|||
|
||||
export type PublicCheckoutOptions = {
|
||||
onlineAvailable: boolean;
|
||||
onlineGateways?: Array<{
|
||||
id: string;
|
||||
provider: string;
|
||||
label: string;
|
||||
isActive: boolean;
|
||||
isPrimary: boolean;
|
||||
testMode: boolean;
|
||||
ticketCheckoutSupported: boolean;
|
||||
}>;
|
||||
manualUpi: {
|
||||
manualQrMode: 'upload' | 'dynamic';
|
||||
eventName: string;
|
||||
|
|
@ -155,6 +164,7 @@ export const publicEventTicketsApi = {
|
|||
quantity: number;
|
||||
billingStateCode?: string;
|
||||
purchaserEmail: string;
|
||||
sourceFormId?: string;
|
||||
attendees: Array<{
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
|
|
@ -167,6 +177,7 @@ export const publicEventTicketsApi = {
|
|||
}>;
|
||||
expectedTotalMinor: number;
|
||||
idempotencyKey?: string;
|
||||
orgGatewayId?: string;
|
||||
},
|
||||
): Promise<CheckoutOrderResponse> {
|
||||
const res = await fetch(url(`/public/events/${eventId}/tickets/checkout`), {
|
||||
|
|
@ -199,6 +210,7 @@ export const publicEventTicketsApi = {
|
|||
quantity: number;
|
||||
billingStateCode?: string;
|
||||
purchaserEmail: string;
|
||||
sourceFormId?: string;
|
||||
attendees: Array<{
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import { api } from './client';
|
||||
import type { ApiTrack } from './tracks';
|
||||
|
||||
export interface ApiSessionKeyPerson {
|
||||
speaker: string | { _id: string; firstName?: string; lastName?: string; title?: string; email?: string };
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export interface ApiSession {
|
||||
_id: string;
|
||||
event: string;
|
||||
|
|
@ -14,6 +19,8 @@ export interface ApiSession {
|
|||
endTime: string;
|
||||
location?: string;
|
||||
speakers?: (string | { _id: string; firstName?: string; lastName?: string })[];
|
||||
/** Per-session key persons with roles (preferred over flat speakers for edit UI). */
|
||||
keyPersons?: ApiSessionKeyPerson[];
|
||||
moderator?: string | null;
|
||||
capacity?: number;
|
||||
status: string;
|
||||
|
|
@ -32,6 +39,8 @@ export interface ApiSession {
|
|||
enableQnA?: boolean;
|
||||
/** When false, hide Quiz & Polls tab on activity detail (default true for normal events). */
|
||||
enableQnP?: boolean;
|
||||
audienceFeedbackForm?: string | null;
|
||||
audienceRatingsForm?: string | null;
|
||||
/** Nested session topics for hierarchical programmes */
|
||||
topics?: SessionTopic[];
|
||||
}
|
||||
|
|
@ -43,6 +52,11 @@ export interface SessionTopic {
|
|||
role?: string;
|
||||
}
|
||||
|
||||
export type SessionKeyPersonPayload = {
|
||||
speaker: string;
|
||||
role: string;
|
||||
};
|
||||
|
||||
export type SessionPayload = {
|
||||
title: string;
|
||||
description?: string;
|
||||
|
|
@ -52,6 +66,7 @@ export type SessionPayload = {
|
|||
location?: string;
|
||||
track?: string | null;
|
||||
speakers?: string[];
|
||||
keyPersons?: SessionKeyPersonPayload[];
|
||||
moderator?: string | null;
|
||||
capacity?: number;
|
||||
status?: string;
|
||||
|
|
|
|||
|
|
@ -285,13 +285,25 @@ export interface EventMicrosite {
|
|||
eventName?: string | null;
|
||||
}
|
||||
|
||||
/** SPA microsite theme presets (`website.theme` on public microsite payload). */
|
||||
export type WebsiteThemeMode = 'simple' | 'gradient';
|
||||
/** SPA microsite theme presets (`website.theme` on public microsite / defaultsite payload). */
|
||||
export type WebsiteThemeMode = 'simple' | 'gradient' | 'pattern';
|
||||
|
||||
/** SVG tile pattern names when theme === 'pattern'. */
|
||||
export type WebsiteThemePattern = 'dots' | 'grid' | 'zigzag' | 'diagonal' | 'hexa' | 'plus';
|
||||
|
||||
export interface WebsiteThemeConfig {
|
||||
theme: WebsiteThemeMode;
|
||||
color: string;
|
||||
/** Required for theme === 'pattern' */
|
||||
pattern?: WebsiteThemePattern | string | null;
|
||||
font?: string | null;
|
||||
/** Optional palette overrides → --theme-* CSS vars on the public site */
|
||||
primaryColor?: string | null;
|
||||
secondaryColor?: string | null;
|
||||
headerColor?: string | null;
|
||||
buttonColor?: string | null;
|
||||
textColor?: string | null;
|
||||
textHighlightColor?: string | null;
|
||||
}
|
||||
|
||||
export interface EventWebsiteSettings {
|
||||
|
|
@ -314,7 +326,7 @@ export interface EventWebsiteSettings {
|
|||
seoDescription?: string;
|
||||
/** Visual editor markup; canonical with publish (also mirrored legacy on Home EventPage). */
|
||||
micrositeHtml?: string;
|
||||
/** Luma-style theme: simple/gradient + color + font */
|
||||
/** Luma-style theme: simple/gradient/pattern + color + font + optional palette overrides */
|
||||
theme?: WebsiteThemeConfig | null;
|
||||
}
|
||||
|
||||
|
|
@ -364,8 +376,9 @@ export interface Event {
|
|||
organization?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
/** Public code for https://eventbuzz.live/code/{joinCode} (event hub); assigned on save. */
|
||||
/** Public code for https://join.eventbuzz.live/code/{joinCode} (event hub); assigned on save. */
|
||||
joinCode?: string;
|
||||
|
||||
website?: EventWebsiteSettings;
|
||||
settings?: Record<string, unknown>;
|
||||
metadata?: { totalRevenue?: number; rating?: number; ratingCount?: number };
|
||||
|
|
@ -578,6 +591,7 @@ export interface FormSettings {
|
|||
allowAnonymous?: boolean;
|
||||
paymentEnabled?: boolean;
|
||||
paymentAmount?: string | number;
|
||||
ticketIds?: string[];
|
||||
sendConfirmationEmail?: boolean;
|
||||
/** Emailer DesignTemplate id used for registration confirmation. */
|
||||
confirmationEmailTemplateId?: string | null;
|
||||
|
|
@ -595,6 +609,7 @@ export interface FormSettings {
|
|||
duplicatePhoneMessage?: string;
|
||||
eventHeader?: {
|
||||
showLogo?: boolean;
|
||||
showBanner?: boolean;
|
||||
showTitle?: boolean;
|
||||
showDateRange?: boolean;
|
||||
showEventType?: boolean;
|
||||
|
|
@ -627,6 +642,7 @@ export interface FormWithSubmissionCount extends Form {
|
|||
|
||||
/** Public event context on GET /api/public/forms/:id (from linked event, safe fields only). */
|
||||
export interface PublicFormEventSummary {
|
||||
_id?: string;
|
||||
name: string;
|
||||
subtitle?: string;
|
||||
eventType?: string;
|
||||
|
|
@ -639,6 +655,17 @@ export interface PublicFormEventSummary {
|
|||
}
|
||||
|
||||
/** Safe shape from GET /api/public/forms/:id (no auth). */
|
||||
export interface PublicFormTicketOption {
|
||||
_id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
keyPoints?: string[];
|
||||
basePriceMinor: number;
|
||||
currency: string;
|
||||
maxPerOrder?: number | null;
|
||||
requiresBillingState?: boolean;
|
||||
}
|
||||
|
||||
export interface PublicFormDefinition {
|
||||
_id: string;
|
||||
name: string;
|
||||
|
|
@ -646,11 +673,14 @@ export interface PublicFormDefinition {
|
|||
type: string;
|
||||
event?: PublicFormEventSummary | null;
|
||||
fields?: FormField[];
|
||||
tickets?: PublicFormTicketOption[];
|
||||
settings?: {
|
||||
thankYouMessage?: string;
|
||||
showThankYou?: boolean;
|
||||
collectEmail?: boolean;
|
||||
allowAnonymous?: boolean;
|
||||
paymentEnabled?: boolean;
|
||||
ticketIds?: string[];
|
||||
formClosedMessage?: string;
|
||||
submitErrorMessage?: string;
|
||||
captchaFailedMessage?: string;
|
||||
|
|
@ -781,9 +811,29 @@ export interface CreateContentCollectionPayload {
|
|||
/** PUT /content-collections/:id — at least one field (Joi .min(1)). */
|
||||
export type UpdateContentCollectionPayload = Partial<CreateContentCollectionPayload>;
|
||||
|
||||
export type AttendanceAudienceMode = 'all' | 'statuses' | 'tags' | 'participant_lists';
|
||||
|
||||
export type AttendanceMembershipStatus =
|
||||
| 'pending'
|
||||
| 'confirmed'
|
||||
| 'checked_in'
|
||||
| 'cancelled'
|
||||
| 'rejected';
|
||||
|
||||
export interface AttendanceAudienceSettings {
|
||||
mode?: AttendanceAudienceMode;
|
||||
/** Used when mode === 'statuses' */
|
||||
statuses?: AttendanceMembershipStatus[];
|
||||
/** Used when mode === 'tags' */
|
||||
tags?: string[];
|
||||
/** Used when mode === 'participant_lists' */
|
||||
participantListIds?: string[];
|
||||
}
|
||||
|
||||
export interface AttendanceSettings {
|
||||
allowDuplicateCheckIn?: boolean;
|
||||
publicLinkEnabled?: boolean;
|
||||
audience?: AttendanceAudienceSettings;
|
||||
}
|
||||
|
||||
export interface AttendanceRecord {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
@font-face {
|
||||
font-family: "Swiss921 BT";
|
||||
src: url("/fonts/Swiss921BT.ttf") format("truetype");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
|
@ -85,10 +85,12 @@
|
|||
--radius-card: 8px;
|
||||
/* The border radius for buttons and form controls. */
|
||||
--radius-button: 4px;
|
||||
/* Brand orange for Lucide / UI icons (wordmark buzz color). */
|
||||
--icon: #EF8D1D;
|
||||
/* The background color for sidebars, navigation menus, and other persistent elements. */
|
||||
--sidebar: rgba(255, 255, 255, 1.00);
|
||||
/* The default color for text or elements that appear on top of sidebars, navigation menus, and other persistent elements. */
|
||||
--sidebar-foreground: rgba(15, 48, 114, 1.00);
|
||||
--sidebar-foreground: #484B57;
|
||||
/* The primary color used for buttons, links, and other interactive elements in sidebars and navigation menus. */
|
||||
/* WCAG AA: #0B4DD1 on white sidebar = 7.00:1 ✓ */
|
||||
--sidebar-primary: rgba(11, 77, 209, 1.00);
|
||||
|
|
@ -97,7 +99,7 @@
|
|||
/* The accent color used for highlights, links, and other interactive elements in sidebars and navigation menus. */
|
||||
--sidebar-accent: rgba(229, 236, 251, 1.00);
|
||||
/* The default color for text or elements that appear on top of accent colored elements in sidebars and navigation menus. */
|
||||
--sidebar-accent-foreground: rgba(13, 62, 161, 1.00);
|
||||
--sidebar-accent-foreground: #484B57;
|
||||
/* The default border color for elements in sidebars and navigation menus. */
|
||||
--sidebar-border: rgba(216, 222, 228, 1.00);
|
||||
/* The default color for focus rings, outlines, and other focus indicators in sidebars and navigation menus. */
|
||||
|
|
@ -140,6 +142,8 @@
|
|||
--success-fg: rgba(74, 222, 128, 1.00);
|
||||
--success-fg-label: rgba(134, 239, 172, 1.00);
|
||||
--elevation-sm: 0px 4px 12px 0px rgba(0, 0, 0, 0.25);
|
||||
/* Same brand orange in dark mode for icon consistency. */
|
||||
--icon: #EF8D1D;
|
||||
--sidebar: rgba(30, 41, 59, 1.00);
|
||||
--sidebar-foreground: rgba(248, 250, 252, 1.00);
|
||||
/* WCAG AA: #60A5FA on dark sidebar-accent (#334155) = 4.05:1 ✓ (was #3B82F6 = 2.81:1 ❌) */
|
||||
|
|
@ -190,6 +194,7 @@
|
|||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-icon: var(--icon);
|
||||
--shadow-sm: var(--elevation-sm);
|
||||
--shadow-md: 0px 6px 16px 0px rgba(33, 38, 44, 0.20);
|
||||
--shadow-lg: 0px 8px 24px 0px rgba(33, 38, 44, 0.24);
|
||||
|
|
@ -286,4 +291,32 @@
|
|||
|
||||
html {
|
||||
font-size: var(--font-size);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lucide React icons use currentColor — force brand orange app-wide.
|
||||
* Overrides per-icon muted/primary inline colors so icons stay consistent.
|
||||
*/
|
||||
svg.lucide {
|
||||
color: var(--icon) !important;
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon-in-box: when a container's only child is a Lucide icon, match its
|
||||
* border to the icon color (no-op when the element has no border).
|
||||
* Hover fills the box orange with a white icon.
|
||||
*/
|
||||
:is(button, a, span, div, label):has(> svg.lucide:only-child) {
|
||||
border-color: var(--icon) !important;
|
||||
transition: background-color 0.12s, border-color 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
:is(button, a):has(> svg.lucide:only-child):not(:disabled):hover {
|
||||
background-color: var(--icon) !important;
|
||||
border-color: var(--icon) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
:is(button, a):has(> svg.lucide:only-child):not(:disabled):hover > svg.lucide {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
export type TicketAttendeePayload = {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
organization?: string;
|
||||
designation?: string;
|
||||
displayName?: string;
|
||||
formData?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function fieldKey(f: { name?: string; label?: string }): string {
|
||||
return String(f.name || f.label || '').trim();
|
||||
}
|
||||
|
||||
function mappedValue(
|
||||
fields: Array<{ name?: string; label?: string; mapsTo?: string }>,
|
||||
values: Record<string, unknown>,
|
||||
mapsTo: string,
|
||||
): string {
|
||||
const f = fields.find((x) => x.mapsTo === mapsTo);
|
||||
if (!f) return '';
|
||||
const raw = values[fieldKey(f)];
|
||||
return raw != null ? String(raw).trim() : '';
|
||||
}
|
||||
|
||||
/** Build ticket checkout attendee + formData from registration form answers. */
|
||||
export function buildTicketAttendeeFromForm(
|
||||
fields: Array<{ name?: string; label?: string; mapsTo?: string }>,
|
||||
values: Record<string, unknown>,
|
||||
submitData: Record<string, unknown>,
|
||||
): TicketAttendeePayload {
|
||||
return {
|
||||
firstName: mappedValue(fields, values, 'firstName') || mappedValue(fields, submitData, 'firstName'),
|
||||
lastName: mappedValue(fields, values, 'lastName') || mappedValue(fields, submitData, 'lastName'),
|
||||
email: (
|
||||
mappedValue(fields, values, 'email') || mappedValue(fields, submitData, 'email')
|
||||
).toLowerCase(),
|
||||
phone: mappedValue(fields, values, 'phone') || mappedValue(fields, submitData, 'phone') || undefined,
|
||||
organization:
|
||||
mappedValue(fields, values, 'organization')
|
||||
|| mappedValue(fields, submitData, 'organization')
|
||||
|| undefined,
|
||||
designation:
|
||||
mappedValue(fields, values, 'designation')
|
||||
|| mappedValue(fields, submitData, 'designation')
|
||||
|| undefined,
|
||||
displayName:
|
||||
mappedValue(fields, values, 'displayName')
|
||||
|| mappedValue(fields, submitData, 'displayName')
|
||||
|| undefined,
|
||||
formData: submitData,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatTicketPrice(minor: number, currency = 'INR'): string {
|
||||
const major = (Number(minor) || 0) / 100;
|
||||
try {
|
||||
return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(major);
|
||||
} catch {
|
||||
return `${currency} ${major.toFixed(2)}`;
|
||||
}
|
||||
}
|
||||
|
||||
export const INDIAN_STATES = [
|
||||
{ code: 'AN', name: 'Andaman and Nicobar Islands' },
|
||||
{ code: 'AP', name: 'Andhra Pradesh' },
|
||||
{ code: 'AR', name: 'Arunachal Pradesh' },
|
||||
{ code: 'AS', name: 'Assam' },
|
||||
{ code: 'BR', name: 'Bihar' },
|
||||
{ code: 'CH', name: 'Chandigarh' },
|
||||
{ code: 'CT', name: 'Chhattisgarh' },
|
||||
{ code: 'DN', name: 'Dadra and Nagar Haveli and Daman and Diu' },
|
||||
{ code: 'DL', name: 'Delhi' },
|
||||
{ code: 'GA', name: 'Goa' },
|
||||
{ code: 'GJ', name: 'Gujarat' },
|
||||
{ code: 'HR', name: 'Haryana' },
|
||||
{ code: 'HP', name: 'Himachal Pradesh' },
|
||||
{ code: 'JK', name: 'Jammu and Kashmir' },
|
||||
{ code: 'JH', name: 'Jharkhand' },
|
||||
{ code: 'KA', name: 'Karnataka' },
|
||||
{ code: 'KL', name: 'Kerala' },
|
||||
{ code: 'LA', name: 'Ladakh' },
|
||||
{ code: 'LD', name: 'Lakshadweep' },
|
||||
{ code: 'MP', name: 'Madhya Pradesh' },
|
||||
{ code: 'MH', name: 'Maharashtra' },
|
||||
{ code: 'MN', name: 'Manipur' },
|
||||
{ code: 'ML', name: 'Meghalaya' },
|
||||
{ code: 'MZ', name: 'Mizoram' },
|
||||
{ code: 'NL', name: 'Nagaland' },
|
||||
{ code: 'OR', name: 'Odisha' },
|
||||
{ code: 'PY', name: 'Puducherry' },
|
||||
{ code: 'PB', name: 'Punjab' },
|
||||
{ code: 'RJ', name: 'Rajasthan' },
|
||||
{ code: 'SK', name: 'Sikkim' },
|
||||
{ code: 'TN', name: 'Tamil Nadu' },
|
||||
{ code: 'TG', name: 'Telangana' },
|
||||
{ code: 'TR', name: 'Tripura' },
|
||||
{ code: 'UP', name: 'Uttar Pradesh' },
|
||||
{ code: 'UT', name: 'Uttarakhand' },
|
||||
{ code: 'WB', name: 'West Bengal' },
|
||||
];
|
||||
|
|
@ -137,8 +137,11 @@ export function EventTicketCheckoutView() {
|
|||
const [payBusy, setPayBusy] = useState(false);
|
||||
const [done, setDone] = useState(false);
|
||||
const [payMode, setPayMode] = useState<"loading" | "online" | "manual" | "both" | "none">("loading");
|
||||
/** When payMode is "both", whether the buyer uses Razorpay or manual UPI for this order. */
|
||||
const [checkoutChannel, setCheckoutChannel] = useState<"online" | "manual">("online");
|
||||
const [onlineGateways, setOnlineGateways] = useState<
|
||||
NonNullable<Awaited<ReturnType<typeof publicEventTicketsApi.checkoutOptions>>["onlineGateways"]>
|
||||
>([]);
|
||||
const [selectedOnlineGatewayId, setSelectedOnlineGatewayId] = useState<string>("");
|
||||
const [manualUpi, setManualUpi] = useState<PublicCheckoutOptions["manualUpi"]>(null);
|
||||
|
||||
const isManualCheckout = useMemo(() => {
|
||||
|
|
@ -174,6 +177,9 @@ export function EventTicketCheckoutView() {
|
|||
if (cancelled) return;
|
||||
setSummary(s);
|
||||
setTickets(tix);
|
||||
const gateways = opts.onlineGateways || [];
|
||||
setOnlineGateways(gateways);
|
||||
setSelectedOnlineGatewayId(gateways[0]?.id || "");
|
||||
if (opts.onlineAvailable && opts.manualUpi) {
|
||||
setPayMode("both");
|
||||
setCheckoutChannel("online");
|
||||
|
|
@ -366,6 +372,7 @@ export function EventTicketCheckoutView() {
|
|||
purchaserEmail: pe,
|
||||
expectedTotalMinor: quoteTotal,
|
||||
idempotencyKey,
|
||||
orgGatewayId: selectedOnlineGatewayId || onlineGateways[0]?.id || undefined,
|
||||
attendees: attendees.map((a) => ({
|
||||
firstName: a.firstName.trim(),
|
||||
lastName: a.lastName.trim(),
|
||||
|
|
@ -700,8 +707,27 @@ export function EventTicketCheckoutView() {
|
|||
</div>
|
||||
</>
|
||||
: <p style={{ fontSize: "0.9375rem", color: T.body, margin: 0, lineHeight: 1.55, fontWeight: 500 }}>
|
||||
You will pay through the organiser's connected Razorpay account (not a platform wallet).
|
||||
You will pay through the organiser's connected payment gateway.
|
||||
</p>}
|
||||
{!isManualCheckout && onlineGateways.length > 1 ? (
|
||||
<div style={{ marginTop: 16, display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<span style={{ fontSize: "0.7rem", fontWeight: 600, letterSpacing: "0.22em", textTransform: "uppercase", color: T.accent }}>
|
||||
Gateway
|
||||
</span>
|
||||
{onlineGateways.map((g) => (
|
||||
<label key={g.id} style={{ display: "flex", alignItems: "center", gap: 8, fontSize: "0.9rem", color: T.ink }}>
|
||||
<input
|
||||
type="radio"
|
||||
name="eb-online-gateway"
|
||||
checked={selectedOnlineGatewayId === g.id}
|
||||
onChange={() => setSelectedOnlineGatewayId(g.id)}
|
||||
/>
|
||||
{g.label || g.provider}
|
||||
{g.testMode ? " (test)" : ""}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import { assignUniqueFieldNames, fieldDataKey, fieldDisplayLabel, countryStateKe
|
|||
import { SelectWithOtherInput } from "../app/components/forms/SelectWithOtherInput";
|
||||
import { ensureRegistrationCoreFields } from "../app/utils/registrationFormFields";
|
||||
import { COUNTRY_NAMES, DEFAULT_COUNTRY, statesForCountry } from "../app/data/countryStates";
|
||||
import { RegistrationTicketStep } from "../components/forms/RegistrationTicketStep";
|
||||
import { buildTicketAttendeeFromForm } from "../utils/registrationTicketAttendee";
|
||||
|
||||
const EVENT_TYPE_LABEL: Record<string, string> = {
|
||||
conference: "Conference",
|
||||
|
|
@ -58,6 +60,7 @@ function PublicFormEventHeader({
|
|||
}) {
|
||||
const d = {
|
||||
showLogo: display?.showLogo !== false,
|
||||
showBanner: display?.showBanner === true,
|
||||
showTitle: display?.showTitle !== false,
|
||||
showDateRange: display?.showDateRange !== false,
|
||||
showEventType: display?.showEventType !== false,
|
||||
|
|
@ -69,7 +72,7 @@ function PublicFormEventHeader({
|
|||
const kind = formatLocationKind(event.locationType);
|
||||
const showLoc = d.showVenue && shouldShowLocation(event);
|
||||
const embedded = variant === "embedded";
|
||||
const showBanner = d.showLogo && !!event.imageUrl && !event.logoUrl;
|
||||
const showBanner = d.showBanner && !!event.imageUrl;
|
||||
const showLogo = d.showLogo && !!event.logoUrl;
|
||||
const showTitle = d.showTitle && !!event.name;
|
||||
const showSubtitle = d.showTitle && !!event.subtitle?.trim();
|
||||
|
|
@ -132,33 +135,44 @@ function PublicFormEventHeader({
|
|||
marginBottom: embedded ? 18 : 28,
|
||||
paddingBottom: embedded ? 0 : 22,
|
||||
borderBottom: embedded ? "none" : "1px solid var(--border, #e2e8f0)",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{showLogo ? (
|
||||
<img
|
||||
src={event.logoUrl}
|
||||
alt={event.name}
|
||||
style={{
|
||||
display: "block",
|
||||
maxHeight: 96,
|
||||
maxWidth: "100%",
|
||||
width: "auto",
|
||||
height: "auto",
|
||||
objectFit: "contain",
|
||||
margin: "0 auto 16px",
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{showBanner ? (
|
||||
<div style={{ marginBottom: 16, borderRadius: 10, overflow: "hidden", maxHeight: 160 }}>
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
marginBottom: 16,
|
||||
borderRadius: 10,
|
||||
overflow: "hidden",
|
||||
maxHeight: 180,
|
||||
lineHeight: 0,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={event.imageUrl}
|
||||
alt=""
|
||||
style={{ width: "100%", height: "100%", maxHeight: 160, objectFit: "cover", display: "block" }}
|
||||
style={{ width: "100%", height: "auto", maxHeight: 180, objectFit: "cover", display: "block" }}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div style={{ display: "flex", alignItems: "flex-start", gap: 16, flexWrap: "wrap" }}>
|
||||
{showLogo ? (
|
||||
<img
|
||||
src={event.logoUrl}
|
||||
alt={event.name}
|
||||
style={{
|
||||
maxHeight: 64,
|
||||
maxWidth: 220,
|
||||
width: "auto",
|
||||
height: "auto",
|
||||
objectFit: "contain",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div style={{ minWidth: 0, flex: "1 1 200px" }}>
|
||||
{showTitle || showSubtitle || metaItems.length > 0 ? (
|
||||
<div>
|
||||
{showTitle ? (
|
||||
<h1
|
||||
style={{
|
||||
|
|
@ -191,6 +205,7 @@ function PublicFormEventHeader({
|
|||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "6px 10px",
|
||||
fontSize: "0.8125rem",
|
||||
color: "var(--muted, #64748b)",
|
||||
|
|
@ -210,7 +225,7 @@ function PublicFormEventHeader({
|
|||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -271,6 +286,11 @@ export function PublicFormView() {
|
|||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [done, setDone] = useState(false);
|
||||
const [doneMessage, setDoneMessage] = useState<string | null>(null);
|
||||
const [step, setStep] = useState<1 | 2>(1);
|
||||
|
||||
const ticketsEnabled = !!(form?.settings?.paymentEnabled && (form.tickets?.length || 0) > 0);
|
||||
const eventId = form?.event?._id ? String(form.event._id) : "";
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!formId) return;
|
||||
|
|
@ -350,6 +370,16 @@ export function PublicFormView() {
|
|||
if (field.type === "section") continue;
|
||||
Object.assign(data, fieldValueToData(field, values));
|
||||
}
|
||||
|
||||
if (ticketsEnabled) {
|
||||
if (!eventId) {
|
||||
setSubmitError("This form is missing event context required for ticket payment.");
|
||||
return;
|
||||
}
|
||||
setStep(2);
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await formsApi.submit(formId, { data });
|
||||
|
|
@ -403,7 +433,10 @@ export function PublicFormView() {
|
|||
|
||||
if (done) {
|
||||
const showThanks = form.settings?.showThankYou !== false;
|
||||
const msg = form.settings?.thankYouMessage?.trim() || "Thank you for your response.";
|
||||
const msg =
|
||||
doneMessage
|
||||
|| form.settings?.thankYouMessage?.trim()
|
||||
|| "Thank you for your response.";
|
||||
const ev = form.event;
|
||||
return (
|
||||
<div
|
||||
|
|
@ -515,6 +548,35 @@ export function PublicFormView() {
|
|||
<div style={{ marginBottom: 24 }} />
|
||||
)}
|
||||
|
||||
{step === 2 && ticketsEnabled && eventId ? (
|
||||
<RegistrationTicketStep
|
||||
eventId={eventId}
|
||||
formId={formId}
|
||||
eventName={ev?.name}
|
||||
tickets={form.tickets || []}
|
||||
attendee={buildTicketAttendeeFromForm(
|
||||
form.fields || [],
|
||||
values,
|
||||
(() => {
|
||||
const data: Record<string, unknown> = {};
|
||||
for (const field of form.fields || []) {
|
||||
if (field.type === "section") continue;
|
||||
Object.assign(data, fieldValueToData(field, values));
|
||||
}
|
||||
return data;
|
||||
})(),
|
||||
)}
|
||||
onBack={() => setStep(1)}
|
||||
onPaid={() => {
|
||||
setDoneMessage(null);
|
||||
setDone(true);
|
||||
}}
|
||||
onPendingReview={(message) => {
|
||||
setDoneMessage(message);
|
||||
setDone(true);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={(e) => void onSubmit(e)}
|
||||
style={{
|
||||
|
|
@ -528,6 +590,11 @@ export function PublicFormView() {
|
|||
boxShadow: "0 1px 3px rgba(0,0,0,0.06)",
|
||||
}}
|
||||
>
|
||||
{ticketsEnabled ? (
|
||||
<p style={{ margin: 0, fontSize: "0.8125rem", fontWeight: 600, color: "#64748b", textTransform: "uppercase", letterSpacing: "0.04em" }}>
|
||||
Step 1 of 2 — Your details
|
||||
</p>
|
||||
) : null}
|
||||
{(form.fields || []).map((field, index) => {
|
||||
if (field.type === "section") {
|
||||
const title = (field.label || "").trim();
|
||||
|
|
@ -903,9 +970,10 @@ export function PublicFormView() {
|
|||
opacity: submitting ? 0.75 : 1,
|
||||
}}
|
||||
>
|
||||
{submitting ? "Submitting…" : "Submit"}
|
||||
{submitting ? "Submitting…" : ticketsEnabled ? "Continue" : "Submit"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
/** App-specific `VITE_*` variables (merges with Vite’s ImportMetaEnv) */
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL?: string;
|
||||
/** Concierge public API base for website embed (ends with /api). */
|
||||
readonly VITE_CONCIERGE_URL?: string;
|
||||
readonly VITE_GOOGLE_CLIENT_ID?: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue