This commit is contained in:
Ravi Gulhane 2026-07-08 13:48:45 +05:30
parent 980e0d9156
commit 17b3592df4
32 changed files with 2672 additions and 187 deletions

File diff suppressed because one or more lines are too long

4
dist/index.html vendored
View File

@ -12,8 +12,8 @@
rel="stylesheet" rel="stylesheet"
/> />
<script src="https://accounts.google.com/gsi/client" async defer></script> <script src="https://accounts.google.com/gsi/client" async defer></script>
<script type="module" crossorigin src="/assets/index-DBI6VQnt.js"></script> <script type="module" crossorigin src="/assets/index-D03jbJi-.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B2aqbk3U.css"> <link rel="stylesheet" crossorigin href="/assets/index-YPkmj8ow.css">
</head> </head>
<body> <body>

View File

@ -9,6 +9,7 @@ import { ProtectedLayout } from "./views/ProtectedLayout";
import { PlatformAdminLayout } from "./views/PlatformAdminLayout"; import { PlatformAdminLayout } from "./views/PlatformAdminLayout";
import { SuperAdminClientsPage } from "./views/SuperAdminClientsPage"; import { SuperAdminClientsPage } from "./views/SuperAdminClientsPage";
import { SuperAdminPlansPage } from "./views/SuperAdminPlansPage"; import { SuperAdminPlansPage } from "./views/SuperAdminPlansPage";
import { SuperAdminMicrositeV2TemplatesPage } from "./views/SuperAdminMicrositeV2TemplatesPage";
import { EventTicketCheckoutView } from "../views/EventTicketCheckoutView"; import { EventTicketCheckoutView } from "../views/EventTicketCheckoutView";
import { PublicFormView } from "../views/PublicFormView"; import { PublicFormView } from "../views/PublicFormView";
import { PublicAttendanceCheckInView } from "../views/PublicAttendanceCheckInView"; import { PublicAttendanceCheckInView } from "../views/PublicAttendanceCheckInView";
@ -33,6 +34,7 @@ export default function App() {
<Route index element={<Navigate to="clients" replace />} /> <Route index element={<Navigate to="clients" replace />} />
<Route path="clients" element={<SuperAdminClientsPage />} /> <Route path="clients" element={<SuperAdminClientsPage />} />
<Route path="plans" element={<SuperAdminPlansPage />} /> <Route path="plans" element={<SuperAdminPlansPage />} />
<Route path="microsite-templates" element={<SuperAdminMicrositeV2TemplatesPage />} />
</Route> </Route>
<Route path=":navId" element={<ProtectedLayout.AppShellWithNav />} /> <Route path=":navId" element={<ProtectedLayout.AppShellWithNav />} />
</Route> </Route>

View File

@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useState } from "react"; import React, { useCallback, useEffect, useState } from "react";
import { BarChart2, CalendarDays, FileText, Plus, ShieldCheck, Tag, Ticket, Trophy } from "lucide-react"; import { BarChart2, CalendarDays, Check, FileText, Globe, Link2, Plus, ShieldCheck, Tag, Ticket, Trophy } from "lucide-react";
import { useBreakpoint } from "../../hooks/useBreakpoint"; import { useBreakpoint } from "../../hooks/useBreakpoint";
import { useAppNav } from "../../contexts/AppNavContext"; import { useAppNav } from "../../contexts/AppNavContext";
import { eventsApi } from "../../../services/api/events"; import { eventsApi } from "../../../services/api/events";
@ -8,6 +8,7 @@ import { quizPollsApi } from "../../../services/api/quizPolls";
import { badgeBatchesApi } from "../../../services/api/badgeBatches"; import { badgeBatchesApi } from "../../../services/api/badgeBatches";
import { eventTicketsApi } from "../../../services/api/eventTickets"; import { eventTicketsApi } from "../../../services/api/eventTickets";
import { countConfiguredRewardRules, RewardRulesSettingsPanel } from "../rewards/rewardRulesSettings"; import { countConfiguredRewardRules, RewardRulesSettingsPanel } from "../rewards/rewardRulesSettings";
import { ManageStaticMicrositePanel } from "../microsite-v2/ManageStaticMicrositePanel";
import { CreateFormPanel } from "../panels/CreateFormPanel"; import { CreateFormPanel } from "../panels/CreateFormPanel";
import { FormSubmissionsPanel } from "../panels/FormSubmissionsPanel"; import { FormSubmissionsPanel } from "../panels/FormSubmissionsPanel";
import { CreateBadgePanel } from "../panels/CreateBadgePanel"; import { CreateBadgePanel } from "../panels/CreateBadgePanel";
@ -16,20 +17,23 @@ import { AddQuizPollModal, buildQuizPollSettingsPayload } from "../quiz/AddQuizP
import type { QuizPollModalForm } from "../quiz/AddQuizPollModal"; import type { QuizPollModalForm } from "../quiz/AddQuizPollModal";
import type { Question } from "../quiz/AddQuestionModal"; import type { Question } from "../quiz/AddQuestionModal";
import { modalQuestionToApi } from "../quiz/quizQuestionMappers"; import { modalQuestionToApi } from "../quiz/quizQuestionMappers";
import { sessionsApi, tracksApi, formsApi } from "../../../services/api"; import { sessionsApi, tracksApi, formsApi, staticMicrositeApi, staticMicrositePublicUrl } from "../../../services/api";
import type { FormWithSubmissionCount } from "../../../services/api/types"; import type { FormWithSubmissionCount } from "../../../services/api/types";
import { publicFormPageUrl } from "../../utils/publicFormUrl";
function ModuleCard({ function ModuleCard({
icon, icon,
title, title,
description, description,
meta, meta,
headerAction,
actions, actions,
}: { }: {
icon: React.ReactNode; icon: React.ReactNode;
title: string; title: string;
description?: string; description?: string;
meta?: string; meta?: string;
headerAction?: React.ReactNode;
actions: React.ReactNode; actions: React.ReactNode;
}) { }) {
const bp = useBreakpoint(); const bp = useBreakpoint();
@ -65,18 +69,23 @@ function ModuleCard({
{icon} {icon}
</div> </div>
<div style={{ minWidth: 0, flex: 1 }}> <div style={{ minWidth: 0, flex: 1 }}>
<p <div style={{ display: "flex", alignItems: "flex-start", gap: "6px" }}>
style={{ <p
fontFamily: "var(--font-inter)", style={{
fontSize: "var(--text-base)", fontFamily: "var(--font-inter)",
fontWeight: "var(--font-weight-medium)", fontSize: "var(--text-base)",
color: "var(--foreground)", fontWeight: "var(--font-weight-medium)",
margin: 0, color: "var(--foreground)",
lineHeight: 1.3, margin: 0,
}} lineHeight: 1.3,
> flex: 1,
{title} minWidth: 0,
</p> }}
>
{title}
</p>
{headerAction}
</div>
{description ? ( {description ? (
<p <p
style={{ style={{
@ -148,6 +157,22 @@ function formMetaLine(form: FormWithSubmissionCount): string {
return `${formDisplayType(form.type)} · ${count.toLocaleString()} response${count === 1 ? "" : "s"}`; return `${formDisplayType(form.type)} · ${count.toLocaleString()} response${count === 1 ? "" : "s"}`;
} }
function formLinkCopyButtonStyle(copied: boolean): React.CSSProperties {
return {
width: "26px",
height: "26px",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "var(--radius-button)",
border: "1px solid var(--border)",
background: copied ? "#dcfce7" : "var(--card)",
color: copied ? "#15803d" : "var(--muted)",
cursor: "pointer",
flexShrink: 0,
};
}
export function EventHomeModules() { export function EventHomeModules() {
const { selectedEventId, setActiveNav, setPermissionsOpenAddOnce, setOpenAgendaSessionPanelOnce } = useAppNav(); const { selectedEventId, setActiveNav, setPermissionsOpenAddOnce, setOpenAgendaSessionPanelOnce } = useAppNav();
const bp = useBreakpoint(); const bp = useBreakpoint();
@ -162,6 +187,7 @@ export function EventHomeModules() {
const [formEditTarget, setFormEditTarget] = useState<FormWithSubmissionCount | null>(null); const [formEditTarget, setFormEditTarget] = useState<FormWithSubmissionCount | null>(null);
const [formResponsesTarget, setFormResponsesTarget] = useState<FormWithSubmissionCount | null>(null); const [formResponsesTarget, setFormResponsesTarget] = useState<FormWithSubmissionCount | null>(null);
const [newFormMode, setNewFormMode] = useState<"registration" | "feedback" | "other" | null>(null); const [newFormMode, setNewFormMode] = useState<"registration" | "feedback" | "other" | null>(null);
const [copiedFormLinkId, setCopiedFormLinkId] = useState<string | null>(null);
const [pollQuizCounts, setPollQuizCounts] = useState({ polls: 0, quizzes: 0 }); const [pollQuizCounts, setPollQuizCounts] = useState({ polls: 0, quizzes: 0 });
const [pollQuizLoading, setPollQuizLoading] = useState(false); const [pollQuizLoading, setPollQuizLoading] = useState(false);
const [showAddPoll, setShowAddPoll] = useState(false); const [showAddPoll, setShowAddPoll] = useState(false);
@ -179,6 +205,9 @@ export function EventHomeModules() {
const [rewardRulesCount, setRewardRulesCount] = useState(0); const [rewardRulesCount, setRewardRulesCount] = useState(0);
const [rewardLoading, setRewardLoading] = useState(false); const [rewardLoading, setRewardLoading] = useState(false);
const [rewardRulesOpen, setRewardRulesOpen] = useState(false); const [rewardRulesOpen, setRewardRulesOpen] = useState(false);
const [staticMicrositeOpen, setStaticMicrositeOpen] = useState(false);
const [staticMicrositeMeta, setStaticMicrositeMeta] = useState<string | null>(null);
const [staticMicrositeLoading, setStaticMicrositeLoading] = useState(false);
const [agendaCounts, setAgendaCounts] = useState({ tracks: 0, sessions: 0 }); const [agendaCounts, setAgendaCounts] = useState({ tracks: 0, sessions: 0 });
const [agendaLoading, setAgendaLoading] = useState(false); const [agendaLoading, setAgendaLoading] = useState(false);
@ -333,6 +362,32 @@ export function EventHomeModules() {
void loadRewardRulesMeta(); void loadRewardRulesMeta();
}, [loadRewardRulesMeta]); }, [loadRewardRulesMeta]);
const loadStaticMicrositeMeta = useCallback(async () => {
if (!selectedEventId) {
setStaticMicrositeMeta(null);
setStaticMicrositeLoading(false);
return;
}
setStaticMicrositeLoading(true);
try {
const res = await staticMicrositeApi.getForEvent(selectedEventId);
if (res.data?.subdomain) {
const status = res.data.status === "published" ? "Published" : "Draft";
setStaticMicrositeMeta(`${staticMicrositePublicUrl(res.data.subdomain)} · ${status}`);
} else {
setStaticMicrositeMeta("Not configured");
}
} catch {
setStaticMicrositeMeta("Not configured");
} finally {
setStaticMicrositeLoading(false);
}
}, [selectedEventId]);
useEffect(() => {
void loadStaticMicrositeMeta();
}, [loadStaticMicrositeMeta]);
const loadAgendaMeta = useCallback(async () => { const loadAgendaMeta = useCallback(async () => {
if (!selectedEventId) { if (!selectedEventId) {
setAgendaCounts({ tracks: 0, sessions: 0 }); setAgendaCounts({ tracks: 0, sessions: 0 });
@ -386,6 +441,19 @@ export function EventHomeModules() {
void loadEventForms(); void loadEventForms();
}; };
const copyFormPublicLink = useCallback(
(form: FormWithSubmissionCount) => {
const url = publicFormPageUrl(form, selectedEventId);
void navigator.clipboard.writeText(url).then(() => {
setCopiedFormLinkId(form._id);
window.setTimeout(() => {
setCopiedFormLinkId((prev) => (prev === form._id ? null : prev));
}, 4000);
});
},
[selectedEventId],
);
const actionsDisabled = !selectedEventId; const actionsDisabled = !selectedEventId;
const pollQuizMeta = !selectedEventId const pollQuizMeta = !selectedEventId
? "Select an event to create polls and quizzes" ? "Select an event to create polls and quizzes"
@ -414,6 +482,11 @@ export function EventHomeModules() {
: rewardRulesCount === 0 : rewardRulesCount === 0
? "No rules configured" ? "No rules configured"
: `${rewardRulesCount} rule${rewardRulesCount === 1 ? "" : "s"} configured`; : `${rewardRulesCount} rule${rewardRulesCount === 1 ? "" : "s"} configured`;
const micrositeMeta = !selectedEventId
? "Select an event to manage microsite"
: staticMicrositeLoading
? "Loading…"
: staticMicrositeMeta || "Not configured";
const agendaMeta = !selectedEventId const agendaMeta = !selectedEventId
? "Select an event to manage the agenda" ? "Select an event to manage the agenda"
: agendaLoading : agendaLoading
@ -499,12 +572,39 @@ export function EventHomeModules() {
</> </>
} }
/> />
{eventForms.map((form) => ( {eventForms.map((form) => {
const linkCopied = copiedFormLinkId === form._id;
return (
<ModuleCard <ModuleCard
key={form._id} key={form._id}
icon={<FileText size={18} />} icon={<FileText size={18} />}
title={form.name?.trim() || "Untitled form"} title={form.name?.trim() || "Untitled form"}
meta={formMetaLine(form)} meta={formMetaLine(form)}
headerAction={
<button
type="button"
title={linkCopied ? "Copied to clipboard" : "Copy public form link"}
disabled={actionsDisabled}
style={{
...formLinkCopyButtonStyle(linkCopied),
opacity: actionsDisabled ? 0.55 : 1,
cursor: actionsDisabled ? "not-allowed" : "pointer",
}}
onClick={() => copyFormPublicLink(form)}
onMouseEnter={(e) => {
if (actionsDisabled || linkCopied) return;
(e.currentTarget as HTMLElement).style.borderColor = "var(--primary)";
(e.currentTarget as HTMLElement).style.color = "var(--primary)";
}}
onMouseLeave={(e) => {
if (linkCopied) return;
(e.currentTarget as HTMLElement).style.borderColor = "var(--border)";
(e.currentTarget as HTMLElement).style.color = "var(--muted)";
}}
>
{linkCopied ? <Check size={12} strokeWidth={3} /> : <Link2 size={12} />}
</button>
}
actions={ actions={
<> <>
<button <button
@ -544,7 +644,8 @@ export function EventHomeModules() {
</> </>
} }
/> />
))} );
})}
<ModuleCard <ModuleCard
icon={<ShieldCheck size={18} />} icon={<ShieldCheck size={18} />}
title="Users & Permissions" title="Users & Permissions"
@ -696,6 +797,25 @@ export function EventHomeModules() {
</button> </button>
} }
/> />
<ModuleCard
icon={<Globe size={18} />}
title="Microsite"
meta={micrositeMeta}
actions={
<button
type="button"
disabled={actionsDisabled}
style={{
...moduleButtonStyle(true),
opacity: actionsDisabled ? 0.55 : 1,
cursor: actionsDisabled ? "not-allowed" : "pointer",
}}
onClick={() => setStaticMicrositeOpen(true)}
>
Manage
</button>
}
/>
<ModuleCard <ModuleCard
icon={<Trophy size={18} />} icon={<Trophy size={18} />}
title="Reward Points Config" title="Reward Points Config"
@ -860,6 +980,14 @@ export function EventHomeModules() {
onSaved={() => void loadRewardRulesMeta()} onSaved={() => void loadRewardRulesMeta()}
/> />
) : null} ) : null}
{staticMicrositeOpen && selectedEventId ? (
<ManageStaticMicrositePanel
eventId={selectedEventId}
onClose={() => setStaticMicrositeOpen(false)}
onSaved={() => void loadStaticMicrositeMeta()}
/>
) : null}
</> </>
); );
} }

View File

@ -0,0 +1,48 @@
import React from 'react';
import { HelperText } from '../panels/PanelShell';
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '8px 10px',
borderRadius: 'var(--radius-button)',
border: '1px solid var(--border)',
background: 'var(--input-background)',
color: 'var(--foreground)',
fontSize: 'var(--text-sm)',
boxSizing: 'border-box',
};
export function DropdownOtherConfigEditor({
allowOther,
otherLabel,
onChange,
}: {
allowOther?: boolean;
otherLabel?: string;
onChange: (patch: { allowOther?: boolean; otherLabel?: string }) => void;
}) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginTop: '4px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: 'var(--text-xs)', cursor: 'pointer' }}>
<input
type="checkbox"
checked={!!allowOther}
onChange={(e) => onChange({ allowOther: e.target.checked, otherLabel: otherLabel || 'Other' })}
/>
Include &quot;Other&quot; option
</label>
{allowOther ? (
<div>
<span style={{ fontSize: 'var(--text-xs)', fontWeight: 600, color: 'var(--muted)' }}>Other option label</span>
<input
style={{ ...inputStyle, marginTop: '4px' }}
value={otherLabel ?? 'Other'}
placeholder="Other"
onChange={(e) => onChange({ allowOther: true, otherLabel: e.target.value })}
/>
<HelperText text="When selected, respondents can type a custom value; that text is saved instead of &quot;Other&quot;." />
</div>
) : null}
</div>
);
}

View File

@ -0,0 +1,105 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
SELECT_OTHER_VALUE,
normalizeSelectOptions,
otherTextFromValue,
selectControlValue,
type SelectOptionItem,
} from './selectWithOtherUtils';
const defaultInputStyle: React.CSSProperties = {
width: '100%',
padding: '8px 10px',
borderRadius: 'var(--radius-button)',
border: '1px solid var(--border)',
background: 'var(--input-background)',
color: 'var(--foreground)',
fontSize: 'var(--text-sm)',
boxSizing: 'border-box',
};
export function SelectWithOtherInput({
value,
onChange,
options,
allowOther,
otherLabel = 'Other',
otherValue = SELECT_OTHER_VALUE,
otherInputPlaceholder = 'Please specify',
inputStyle = defaultInputStyle,
required,
emptyLabel = 'Select…',
}: {
value: unknown;
onChange: (v: string) => void;
options: SelectOptionItem[];
allowOther?: boolean;
otherLabel?: string;
otherValue?: string;
otherInputPlaceholder?: string;
inputStyle?: React.CSSProperties;
required?: boolean;
emptyLabel?: string;
}) {
const normalized = useMemo(() => normalizeSelectOptions(options), [options]);
const optionValues = useMemo(() => normalized.map((o) => o.value), [normalized]);
const stored = String(value ?? '');
const [selectValue, setSelectValue] = useState(() =>
selectControlValue(stored, optionValues, allowOther, otherValue),
);
const [otherText, setOtherText] = useState(() => otherTextFromValue(stored, optionValues, allowOther));
useEffect(() => {
setSelectValue(selectControlValue(stored, optionValues, allowOther, otherValue));
setOtherText(otherTextFromValue(stored, optionValues, allowOther));
}, [stored, allowOther, otherValue, optionValues]);
const handleSelectChange = (next: string) => {
setSelectValue(next);
if (next === otherValue) {
onChange(otherText.trim());
} else {
setOtherText('');
onChange(next);
}
};
const handleOtherTextChange = (text: string) => {
setOtherText(text);
if (selectValue === otherValue) {
onChange(text);
}
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<select
value={selectValue}
onChange={(e) => handleSelectChange(e.target.value)}
required={!!required && selectValue !== otherValue}
style={{ ...inputStyle, cursor: 'pointer' }}
>
<option value="">{emptyLabel}</option>
{normalized.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
{allowOther ? (
<option value={otherValue}>{otherLabel || 'Other'}</option>
) : null}
</select>
{allowOther && selectValue === otherValue ? (
<input
type="text"
value={otherText}
onChange={(e) => handleOtherTextChange(e.target.value)}
placeholder={otherInputPlaceholder}
required={!!required}
style={inputStyle}
/>
) : null}
</div>
);
}

View File

@ -0,0 +1,39 @@
/** Internal select value when respondent picks the configurable Other option. Never stored in submissions. */
export const SELECT_OTHER_VALUE = '__other__';
export type SelectOptionItem = string | { value: string; label: string };
export function normalizeSelectOptions(options: SelectOptionItem[] | undefined): { value: string; label: string }[] {
return (options ?? []).map((o) => (typeof o === 'string' ? { value: o, label: o } : o));
}
export function isCustomSelectValue(
value: unknown,
optionValues: string[],
allowOther?: boolean,
): boolean {
if (!allowOther) return false;
const stored = String(value ?? '').trim();
if (!stored) return false;
return !optionValues.includes(stored);
}
export function selectControlValue(
value: unknown,
optionValues: string[],
allowOther?: boolean,
otherValue = SELECT_OTHER_VALUE,
): string {
const stored = String(value ?? '');
if (isCustomSelectValue(stored, optionValues, allowOther)) return otherValue;
return stored;
}
export function otherTextFromValue(
value: unknown,
optionValues: string[],
allowOther?: boolean,
): string {
if (!isCustomSelectValue(value, optionValues, allowOther)) return '';
return String(value ?? '');
}

View File

@ -0,0 +1,372 @@
import React, { useCallback, useEffect, useState } from 'react';
import { ExternalLink, Eye, Rocket, Save } from 'lucide-react';
import {
PanelShell,
AccordionSection,
useAccordion,
Label,
Field,
HelperText,
} from '../panels/PanelShell';
import {
staticMicrositeApi,
staticMicrositePublicUrl,
type EventStaticMicrosite,
type MicrositeV2Template,
type StaticMicrositeConfig,
} from '../../../services/api';
import { TemplatePicker } from './TemplatePicker';
import { MicrositeConfigForm } from './MicrositeConfigForm';
import { mergeConfig } from './configUtils';
export function ManageStaticMicrositePanel({
eventId,
onClose,
onSaved,
}: {
eventId: string;
onClose: () => void;
onSaved?: () => void;
}) {
const acc = useAccordion(3);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [publishing, setPublishing] = useState(false);
const [error, setError] = useState('');
const [subdomain, setSubdomain] = useState('');
const [subdomainCheck, setSubdomainCheck] = useState<{ available?: boolean; message?: string } | null>(null);
const [checkingSubdomain, setCheckingSubdomain] = useState(false);
const [templates, setTemplates] = useState<MicrositeV2Template[]>([]);
const [selectedTemplate, setSelectedTemplate] = useState<MicrositeV2Template | null>(null);
const [schema, setSchema] = useState<MicrositeV2Template['schema'] | null>(null);
const [config, setConfig] = useState<StaticMicrositeConfig>({ sections: {} });
const [existing, setExisting] = useState<EventStaticMicrosite | null>(null);
const [publishedUrl, setPublishedUrl] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
const [tplRes, siteRes] = await Promise.all([
staticMicrositeApi.listTemplates(),
staticMicrositeApi.getForEvent(eventId),
]);
if (tplRes.error) throw new Error(tplRes.error);
setTemplates(tplRes.data || []);
if (!siteRes.error && siteRes.data) {
const site = siteRes.data;
setExisting(site);
setSubdomain(site.subdomain);
setPublishedUrl(site.publishedHtmlUrl || (site.status === 'published' ? staticMicrositePublicUrl(site.subdomain) : null));
const tpl =
typeof site.template === 'object'
? site.template
: tplRes.data?.find((t) => t._id === site.template) || null;
if (tpl) {
setSelectedTemplate(tpl);
const detail = await staticMicrositeApi.getTemplate(tpl._id);
const sch = detail.data?.schema;
if (sch) {
setSchema(sch);
setConfig(mergeConfig(sch, site.config));
}
}
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load');
} finally {
setLoading(false);
}
}, [eventId]);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
if (!subdomain || subdomain.length < 3) {
setSubdomainCheck(null);
return;
}
const t = window.setTimeout(() => {
setCheckingSubdomain(true);
void staticMicrositeApi.checkSubdomain(eventId, subdomain).then((res) => {
setSubdomainCheck(res.data || null);
setCheckingSubdomain(false);
});
}, 400);
return () => window.clearTimeout(t);
}, [subdomain, eventId]);
const selectTemplate = async (t: MicrositeV2Template) => {
setSelectedTemplate(t);
const res = await staticMicrositeApi.getTemplate(t._id);
const sch = res.data?.schema;
if (sch) {
setSchema(sch);
setConfig(mergeConfig(sch, config));
}
};
const canSave =
subdomain.length >= 3 &&
selectedTemplate &&
(subdomainCheck?.available !== false);
const handleSave = async () => {
if (!selectedTemplate || !canSave) return;
setSaving(true);
setError('');
try {
const res = await staticMicrositeApi.upsertForEvent(eventId, {
subdomain: subdomain.toLowerCase().replace(/[^a-z0-9-]/g, ''),
templateId: selectedTemplate._id,
config,
});
if (res.error) throw new Error(res.error);
setExisting(res.data || null);
onSaved?.();
} catch (e) {
setError(e instanceof Error ? e.message : 'Save failed');
} finally {
setSaving(false);
}
};
const handlePublish = async () => {
setPublishing(true);
setError('');
try {
if (canSave) await handleSave();
const res = await staticMicrositeApi.publish(eventId);
if (res.error) throw new Error(res.error);
setPublishedUrl(res.data?.publicUrl || res.data?.publishedHtmlUrl || null);
onSaved?.();
} catch (e) {
setError(e instanceof Error ? e.message : 'Publish failed');
} finally {
setPublishing(false);
}
};
const handlePreview = async () => {
try {
if (canSave && !existing) await handleSave();
const html = await staticMicrositeApi.previewEventHtml(eventId);
const blob = new Blob([html], { type: 'text/html' });
const url = URL.createObjectURL(blob);
window.open(url, '_blank', 'noopener');
window.setTimeout(() => URL.revokeObjectURL(url), 60000);
} catch (e) {
setError(e instanceof Error ? e.message : 'Preview failed');
}
};
const completedSections =
(subdomain.length >= 3 ? 1 : 0) + (selectedTemplate ? 1 : 0) + (schema ? 1 : 0);
return (
<PanelShell
title="Microsite"
description="Choose a subdomain, pick a template, and configure your event microsite."
onClose={onClose}
panelWidth="min(640px, 100vw)"
completedCount={completedSections}
totalSections={3}
ctaLabel={publishing ? 'Publishing…' : 'Publish'}
ctaDisabled={!canSave || publishing || saving}
onCtaClick={() => void handlePublish()}
>
{loading ? (
<p style={{ color: 'var(--muted)', fontSize: 'var(--text-sm)' }}>Loading…</p>
) : (
<>
{error ? (
<p style={{ color: 'var(--destructive)', fontSize: 'var(--text-sm)', marginBottom: '12px' }}>{error}</p>
) : null}
{publishedUrl ? (
<div
style={{
marginBottom: '16px',
padding: '12px',
borderRadius: 'var(--radius-button)',
background: 'color-mix(in srgb, var(--primary) 10%, transparent)',
fontSize: 'var(--text-sm)',
}}
>
<strong>Live site:</strong>{' '}
<a href={publishedUrl} target="_blank" rel="noopener noreferrer">
{staticMicrositePublicUrl(subdomain)}
<ExternalLink size={12} style={{ marginLeft: '4px', verticalAlign: 'middle' }} />
</a>
</div>
) : null}
<AccordionSection
n={1}
title="Subdomain"
subtitle="Choose your eventbuzz.live subdomain"
open={acc.open === 1}
completed={acc.done.has(1)}
onToggle={() => acc.toggle(1)}
onSave={() => {
if (subdomain.length >= 3 && subdomainCheck?.available !== false) acc.advance(1);
}}
nextLabel="Next"
nextDisabled={subdomain.length < 3 || subdomainCheck?.available === false}
>
<Label text="Subdomain" />
<Field>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<input
type="text"
value={subdomain}
onChange={(e) =>
setSubdomain(
e.target.value
.toLowerCase()
.replace(/[^a-z0-9-]/g, ''),
)
}
placeholder="mysummit"
style={{
flex: 1,
padding: '8px 10px',
borderRadius: 'var(--radius-button)',
border: '1px solid var(--border)',
background: 'var(--input-background)',
fontSize: 'var(--text-sm)',
}}
/>
<span style={{ fontSize: 'var(--text-sm)', color: 'var(--muted)', whiteSpace: 'nowrap' }}>
.eventbuzz.live
</span>
</div>
</Field>
<HelperText
text={
checkingSubdomain
? 'Checking availability…'
: subdomainCheck?.available === false
? subdomainCheck.message || 'Unavailable'
: subdomainCheck?.available
? 'Subdomain is available'
: 'Letters, numbers, and hyphens only (min 3 characters)'
}
/>
</AccordionSection>
<AccordionSection
n={2}
title="Template"
subtitle="Pick a microsite design"
open={acc.open === 2}
completed={acc.done.has(2)}
onToggle={() => acc.toggle(2)}
onSave={() => {
if (selectedTemplate) acc.advance(2);
}}
nextLabel="Next"
nextDisabled={!selectedTemplate}
>
<TemplatePicker
templates={templates}
selectedId={selectedTemplate?._id}
onSelect={(t) => void selectTemplate(t)}
/>
</AccordionSection>
{schema ? (
<AccordionSection
n={3}
title="Configure"
subtitle="Customize sections and content"
open={acc.open === 3}
completed={acc.done.has(3)}
onToggle={() => acc.toggle(3)}
onSave={() => void handleSave()}
nextLabel={saving ? 'Saving…' : 'Save'}
nextDisabled={!canSave || saving}
>
<MicrositeConfigForm
schema={schema}
config={config}
onChange={setConfig}
eventId={eventId}
/>
</AccordionSection>
) : null}
<div style={{ display: 'flex', gap: '8px', marginTop: '20px', flexWrap: 'wrap' }}>
<button
type="button"
disabled={!canSave || saving}
onClick={() => void handleSave()}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
padding: '8px 14px',
borderRadius: 'var(--radius-button)',
border: '1px solid var(--border)',
background: 'var(--card)',
cursor: saving ? 'wait' : 'pointer',
fontSize: 'var(--text-sm)',
}}
>
<Save size={14} />
{saving ? 'Saving…' : 'Save'}
</button>
<button
type="button"
disabled={!selectedTemplate}
onClick={() => void handlePreview()}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
padding: '8px 14px',
borderRadius: 'var(--radius-button)',
border: '1px solid var(--border)',
background: 'var(--card)',
cursor: 'pointer',
fontSize: 'var(--text-sm)',
}}
>
<Eye size={14} />
Preview
</button>
<button
type="button"
disabled={!canSave || publishing}
onClick={() => void handlePublish()}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
padding: '8px 14px',
borderRadius: 'var(--radius-button)',
border: 'none',
background: 'var(--primary)',
color: 'var(--primary-foreground)',
cursor: publishing ? 'wait' : 'pointer',
fontSize: 'var(--text-sm)',
}}
>
<Rocket size={14} />
{publishing ? 'Publishing…' : 'Publish'}
</button>
</div>
</>
)}
</PanelShell>
);
}

View File

@ -0,0 +1,351 @@
import React, { useRef } from 'react';
import { Plus, Trash2 } from 'lucide-react';
import { uploadMediaFile } from '../../../services/api/media';
import type { MicrositeV2FieldSchema, MicrositeV2SectionSchema, StaticMicrositeConfig } from '../../../services/api/types';
import { Field, Label, HelperText } from '../panels/PanelShell';
import { getNestedValue, setNestedValue } from './configUtils';
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '8px 10px',
borderRadius: 'var(--radius-button)',
border: '1px solid var(--border)',
background: 'var(--input-background)',
color: 'var(--foreground)',
fontSize: 'var(--text-sm)',
boxSizing: 'border-box',
};
function FieldInput({
field,
value,
onChange,
eventId,
}: {
field: MicrositeV2FieldSchema;
value: unknown;
onChange: (v: unknown) => void;
eventId: string;
}) {
const fileRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = React.useState(false);
const handleImage = async (file: File) => {
setUploading(true);
try {
const res = await uploadMediaFile(file, { eventId, module: 'microsite-v2' });
onChange(res.url);
} catch (e) {
alert(e instanceof Error ? e.message : 'Upload failed');
} finally {
setUploading(false);
}
};
switch (field.type) {
case 'boolean':
return (
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: 'var(--text-sm)' }}>
<input type="checkbox" checked={Boolean(value)} onChange={(e) => onChange(e.target.checked)} />
{field.label || field.key}
</label>
);
case 'textarea':
case 'richtext':
return (
<textarea
rows={field.type === 'richtext' ? 6 : 3}
value={String(value ?? '')}
onChange={(e) => onChange(e.target.value)}
style={{ ...inputStyle, resize: 'vertical' }}
/>
);
case 'color':
return (
<input
type="color"
value={String(value || '#5c6ac4')}
onChange={(e) => onChange(e.target.value)}
style={{ width: '48px', height: '36px', padding: 0, border: 'none', background: 'transparent' }}
/>
);
case 'select':
return (
<select
value={String(value ?? '')}
onChange={(e) => onChange(e.target.value)}
style={inputStyle}
>
{(field.options || []).map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
);
case 'image':
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{value ? (
<img
src={String(value)}
alt=""
style={{ maxWidth: '100%', maxHeight: '120px', borderRadius: 'var(--radius-button)' }}
/>
) : null}
<input ref={fileRef} type="file" accept="image/*" hidden onChange={(e) => {
const f = e.target.files?.[0];
if (f) void handleImage(f);
e.target.value = '';
}} />
<button
type="button"
disabled={uploading}
onClick={() => fileRef.current?.click()}
style={{
...inputStyle,
cursor: uploading ? 'wait' : 'pointer',
width: 'auto',
alignSelf: 'flex-start',
}}
>
{uploading ? 'Uploading…' : value ? 'Change image' : 'Upload image'}
</button>
</div>
);
case 'array':
return (
<ArrayFieldEditor
field={field}
value={Array.isArray(value) ? value : []}
onChange={onChange}
eventId={eventId}
/>
);
default:
return (
<input
type={field.type === 'url' ? 'url' : 'text'}
value={String(value ?? '')}
onChange={(e) => onChange(e.target.value)}
style={inputStyle}
/>
);
}
}
function ArrayFieldEditor({
field,
value,
onChange,
eventId,
}: {
field: MicrositeV2FieldSchema;
value: Record<string, unknown>[];
onChange: (v: Record<string, unknown>[]) => void;
eventId: string;
}) {
const itemFields = field.itemFields || [];
const addRow = () => {
const row: Record<string, unknown> = {};
for (const f of itemFields) {
if (f.key) row[f.key] = f.default ?? '';
}
onChange([...value, row]);
};
const updateRow = (idx: number, key: string, v: unknown) => {
const next = value.map((row, i) => (i === idx ? { ...row, [key]: v } : row));
onChange(next);
};
const removeRow = (idx: number) => {
onChange(value.filter((_, i) => i !== idx));
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{value.map((row, idx) => (
<div
key={idx}
style={{
border: '1px solid var(--border)',
borderRadius: 'var(--radius-button)',
padding: '12px',
display: 'flex',
flexDirection: 'column',
gap: '8px',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--muted)' }}>Item {idx + 1}</span>
<button type="button" onClick={() => removeRow(idx)} style={{ border: 'none', background: 'none', cursor: 'pointer', color: 'var(--destructive)' }}>
<Trash2 size={14} />
</button>
</div>
{itemFields.map((f) => (
<div key={f.key}>
<Label text={f.label || f.key} />
<FieldInput
field={f}
value={row[f.key]}
onChange={(v) => updateRow(idx, f.key, v)}
eventId={eventId}
/>
</div>
))}
</div>
))}
<button
type="button"
onClick={addRow}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
padding: '8px 12px',
borderRadius: 'var(--radius-button)',
border: '1px dashed var(--border)',
background: 'transparent',
cursor: 'pointer',
fontSize: 'var(--text-sm)',
color: 'var(--primary)',
alignSelf: 'flex-start',
}}
>
<Plus size={14} />
Add {field.label || 'item'}
</button>
</div>
);
}
function SectionEditor({
section,
sectionData,
onChange,
eventId,
}: {
section: MicrositeV2SectionSchema;
sectionData: Record<string, unknown>;
onChange: (data: Record<string, unknown>) => void;
eventId: string;
}) {
const show = sectionData.show !== false;
const setField = (key: string, val: unknown) => {
if (key.includes('.')) {
onChange(setNestedValue({ ...sectionData }, key, val));
} else {
onChange({ ...sectionData, [key]: val });
}
};
const getField = (key: string) => {
if (key.includes('.')) return getNestedValue(sectionData, key);
return sectionData[key];
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: 'var(--text-sm)', fontWeight: 600 }}>
<input
type="checkbox"
checked={show}
onChange={(e) => onChange({ ...sectionData, show: e.target.checked })}
/>
Show {section.label} section
</label>
{show
? section.fields.map((field) => (
<div key={field.key}>
<Label text={field.label || field.key} />
<FieldInput
field={field}
value={getField(field.key)}
onChange={(v) => setField(field.key, v)}
eventId={eventId}
/>
</div>
))
: null}
</div>
);
}
export function MicrositeConfigForm({
schema,
config,
onChange,
eventId,
}: {
schema: {
meta?: Record<string, unknown>;
theme?: { fields?: MicrositeV2FieldSchema[] };
sections: MicrositeV2SectionSchema[];
};
config: StaticMicrositeConfig;
onChange: (c: StaticMicrositeConfig) => void;
eventId: string;
}) {
const theme = config.theme || {};
const sections = config.sections || {};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
{schema.meta && Object.keys(schema.meta).length > 0 ? (
<div>
<Label text="Page title" />
<input
type="text"
value={String(config.meta?.title ?? schema.meta?.title ?? '')}
onChange={(e) => onChange({ ...config, meta: { ...config.meta, title: e.target.value } })}
style={inputStyle}
/>
<HelperText text="Browser tab title for the microsite." />
</div>
) : null}
{(schema.theme?.fields || []).length > 0 ? (
<div>
<p style={{ fontSize: 'var(--text-sm)', fontWeight: 600, margin: '0 0 8px' }}>Theme</p>
{schema.theme!.fields!.map((field) => (
<div key={field.key} style={{ marginBottom: '12px' }}>
<Label text={field.label || field.key} />
<FieldInput
field={field}
value={(theme as Record<string, unknown>)[field.key]}
onChange={(v) => onChange({ ...config, theme: { ...theme, [field.key]: v } })}
eventId={eventId}
/>
</div>
))}
</div>
) : null}
{schema.sections.map((section) => (
<div
key={section.id}
style={{
borderTop: '1px solid var(--border)',
paddingTop: '16px',
}}
>
<p style={{ fontSize: 'var(--text-sm)', fontWeight: 600, margin: '0 0 12px' }}>{section.label}</p>
<SectionEditor
section={section}
sectionData={(sections[section.id] as Record<string, unknown>) || { show: true }}
onChange={(data) =>
onChange({
...config,
sections: { ...sections, [section.id]: data },
})
}
eventId={eventId}
/>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,92 @@
import React from 'react';
import type { MicrositeV2Template } from '../../../services/api/types';
const cardStyle = (selected: boolean): React.CSSProperties => ({
border: selected ? '2px solid var(--primary)' : '1px solid var(--border)',
borderRadius: 'var(--radius-card)',
padding: '12px',
cursor: 'pointer',
background: selected ? 'color-mix(in srgb, var(--primary) 8%, var(--card))' : 'var(--card)',
display: 'flex',
flexDirection: 'column',
gap: '8px',
minWidth: 0,
});
export function TemplatePicker({
templates,
selectedId,
onSelect,
loading,
}: {
templates: MicrositeV2Template[];
selectedId?: string;
onSelect: (t: MicrositeV2Template) => void;
loading?: boolean;
}) {
if (loading) {
return <p style={{ fontSize: 'var(--text-sm)', color: 'var(--muted)' }}>Loading templates…</p>;
}
if (templates.length === 0) {
return <p style={{ fontSize: 'var(--text-sm)', color: 'var(--muted)' }}>No templates available.</p>;
}
return (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
gap: '12px',
}}
>
{templates.map((t) => {
const selected = t._id === selectedId;
return (
<button
key={t._id}
type="button"
onClick={() => onSelect(t)}
style={{ ...cardStyle(selected), textAlign: 'left' }}
>
<div
style={{
aspectRatio: '16/10',
borderRadius: 'var(--radius-button)',
background: 'var(--muted)',
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{t.thumbnailUrl ? (
<img
src={t.thumbnailUrl}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : (
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--card)' }}>No preview</span>
)}
</div>
<span
style={{
fontSize: 'var(--text-sm)',
fontWeight: 600,
color: 'var(--foreground)',
fontFamily: 'var(--font-inter)',
}}
>
{t.name}
</span>
{t.description ? (
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--muted)', lineHeight: 1.3 }}>
{t.description}
</span>
) : null}
</button>
);
})}
</div>
);
}

View File

@ -0,0 +1,175 @@
import React, { useState } from 'react';
import { Save } from 'lucide-react';
import { PanelShell, Label, Field, HelperText } from '../panels/PanelShell';
import { staticMicrositeApi, type MicrositeV2Template } from '../../../services/api';
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '8px 10px',
borderRadius: 'var(--radius-button)',
border: '1px solid var(--border)',
background: 'var(--input-background)',
color: 'var(--foreground)',
fontSize: 'var(--text-sm)',
boxSizing: 'border-box',
};
export function UploadMicrositeV2TemplatePanel({
template,
onClose,
onSaved,
}: {
template: MicrositeV2Template | null;
onClose: () => void;
onSaved: () => void;
}) {
const isEdit = Boolean(template);
const [slug, setSlug] = useState(template?.slug || '');
const [name, setName] = useState(template?.name || '');
const [description, setDescription] = useState(template?.description || '');
const [templateHtml, setTemplateHtml] = useState<File | null>(null);
const [templateJson, setTemplateJson] = useState<File | null>(null);
const [thumbnail, setThumbnail] = useState<File | null>(null);
const [assetsZip, setAssetsZip] = useState<File | null>(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const handleSave = async () => {
setSaving(true);
setError('');
try {
let templateId = template?._id;
if (!isEdit) {
if (!slug.trim() || !name.trim()) {
throw new Error('Slug and name are required');
}
const createRes = await staticMicrositeApi.createTemplate({
slug: slug.trim(),
name: name.trim(),
description: description.trim() || undefined,
});
if (createRes.error || !createRes.data) throw new Error(createRes.error || 'Create failed');
templateId = createRes.data._id;
} else if (name !== template?.name || description !== template?.description) {
const updateRes = await staticMicrositeApi.updateTemplate(template!._id, {
name: name.trim(),
description: description.trim(),
});
if (updateRes.error) throw new Error(updateRes.error);
}
if (!templateId) throw new Error('Missing template id');
const hasFiles = templateHtml || templateJson || thumbnail || assetsZip;
if (!isEdit && (!templateHtml || !templateJson)) {
throw new Error('template.html and template.json are required for new templates');
}
if (hasFiles) {
const uploadRes = await staticMicrositeApi.uploadTemplateFiles(templateId, {
templateHtml: templateHtml || undefined,
templateJson: templateJson || undefined,
thumbnail: thumbnail || undefined,
assetsZip: assetsZip || undefined,
});
if (uploadRes.error) throw new Error(uploadRes.error);
}
onSaved();
} catch (e) {
setError(e instanceof Error ? e.message : 'Save failed');
} finally {
setSaving(false);
}
};
return (
<PanelShell
title={isEdit ? 'Edit template' : 'New microsite template'}
description="Upload template.html, template.json, thumbnail, and optional assets zip."
onClose={onClose}
panelWidth="min(520px, 100vw)"
ctaLabel={saving ? 'Saving…' : 'Save'}
ctaDisabled={saving}
onCtaClick={() => void handleSave()}
>
{error ? <p style={{ color: 'var(--destructive)', fontSize: 'var(--text-sm)', marginBottom: '12px' }}>{error}</p> : null}
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<div>
<Label text="Slug" />
<Field>
<input
type="text"
value={slug}
disabled={isEdit}
onChange={(e) => setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
placeholder="event-up"
style={inputStyle}
/>
</Field>
<HelperText text="Unique id used in S3 paths. Cannot change after create." />
</div>
<div>
<Label text="Name" />
<Field>
<input type="text" value={name} onChange={(e) => setName(e.target.value)} style={inputStyle} />
</Field>
</div>
<div>
<Label text="Description" />
<Field>
<textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={2} style={{ ...inputStyle, resize: 'vertical' }} />
</Field>
</div>
<div>
<Label text={`template.html ${isEdit ? '(optional)' : '(required)'}`} />
<input type="file" accept=".html,text/html" onChange={(e) => setTemplateHtml(e.target.files?.[0] || null)} />
</div>
<div>
<Label text={`template.json ${isEdit ? '(optional)' : '(required)'}`} />
<input type="file" accept=".json,application/json" onChange={(e) => setTemplateJson(e.target.files?.[0] || null)} />
</div>
<div>
<Label text="Thumbnail (optional)" />
<input type="file" accept="image/*" onChange={(e) => setThumbnail(e.target.files?.[0] || null)} />
</div>
<div>
<Label text="Assets zip (optional)" />
<input type="file" accept=".zip,application/zip" onChange={(e) => setAssetsZip(e.target.files?.[0] || null)} />
<HelperText text="Zip contents are extracted to templates/{slug}/assets/" />
</div>
<button
type="button"
disabled={saving}
onClick={() => void handleSave()}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
padding: '8px 14px',
borderRadius: 'var(--radius-button)',
border: 'none',
background: 'var(--primary)',
color: 'var(--primary-foreground)',
cursor: saving ? 'wait' : 'pointer',
fontSize: 'var(--text-sm)',
alignSelf: 'flex-start',
}}
>
<Save size={14} />
{saving ? 'Saving…' : 'Save template'}
</button>
</div>
</PanelShell>
);
}

View File

@ -0,0 +1,91 @@
import type { MicrositeV2FieldSchema, StaticMicrositeConfig } from '../../../services/api/types';
export function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
const parts = path.split('.');
let cur: unknown = obj;
for (const p of parts) {
if (cur == null || typeof cur !== 'object') return undefined;
cur = (cur as Record<string, unknown>)[p];
}
return cur;
}
export function setNestedValue(obj: Record<string, unknown>, path: string, value: unknown): Record<string, unknown> {
const parts = path.split('.');
const out = { ...obj };
let cur: Record<string, unknown> = out;
for (let i = 0; i < parts.length - 1; i++) {
const p = parts[i];
const next = cur[p];
cur[p] = next && typeof next === 'object' && !Array.isArray(next) ? { ...(next as Record<string, unknown>) } : {};
cur = cur[p] as Record<string, unknown>;
}
cur[parts[parts.length - 1]] = value;
return out;
}
export function defaultForField(field: MicrositeV2FieldSchema): unknown {
if (field.default !== undefined) return field.default;
switch (field.type) {
case 'boolean':
return false;
case 'array':
return [];
case 'color':
return '#5c6ac4';
default:
return '';
}
}
export function buildInitialConfigFromSchema(schema: {
meta?: Record<string, unknown>;
theme?: { fields?: MicrositeV2FieldSchema[] };
sections: { id: string; show?: boolean; fields: MicrositeV2FieldSchema[] }[];
}): StaticMicrositeConfig {
const config: StaticMicrositeConfig = {
meta: { ...(schema.meta || {}) },
theme: {},
sections: {},
};
for (const field of schema.theme?.fields || []) {
if (field.key) {
config.theme = config.theme || {};
(config.theme as Record<string, unknown>)[field.key] = defaultForField(field);
}
}
for (const section of schema.sections) {
const sectionData: Record<string, unknown> = { show: section.show !== false };
for (const field of section.fields || []) {
if (field.type === 'array') {
sectionData[field.key] = defaultForField(field);
} else if (field.key?.includes('.')) {
const base = setNestedValue({}, field.key, defaultForField(field));
Object.assign(sectionData, base);
} else if (field.key) {
sectionData[field.key] = defaultForField(field);
}
}
config.sections![section.id] = sectionData;
}
return config;
}
export function mergeConfig(
schema: Parameters<typeof buildInitialConfigFromSchema>[0],
existing?: StaticMicrositeConfig | null,
): StaticMicrositeConfig {
const base = buildInitialConfigFromSchema(schema);
if (!existing) return base;
return {
meta: { ...base.meta, ...existing.meta },
theme: { ...base.theme, ...existing.theme },
sections: {
...base.sections,
...(existing.sections || {}),
},
};
}

View File

@ -8,6 +8,8 @@ import { PanelShell, AccordionSection, useAccordion, Label, Field, Grid, RadioGr
import { useAuth } from "../../contexts/AuthContext"; import { useAuth } from "../../contexts/AuthContext";
import { eventsApi, formsApi, participantListsApi, portfolioListOrganizationId } from "../../../services/api"; import { eventsApi, formsApi, participantListsApi, portfolioListOrganizationId } from "../../../services/api";
import type { Form, FormField, ParticipantListRecord } from "../../../services/api"; import type { Form, FormField, ParticipantListRecord } from "../../../services/api";
import { assignUniqueFieldNames } from "../../utils/formFieldNames";
import { DropdownOtherConfigEditor } from "../forms/DropdownOtherConfigEditor";
type FormMode = "feedback" | "registration" | "other"; type FormMode = "feedback" | "registration" | "other";
@ -17,14 +19,122 @@ function eventIdFromFormRef(ev: Form["event"] | undefined): string {
return String((ev as { _id?: string })._id ?? ""); return String((ev as { _id?: string })._id ?? "");
} }
const FEEDBACK_Q_TYPES = ["Star Rating", "NPS (0–10)", "Multiple Choice", "Radio", "Short Text", "Long Text", "Checkboxes", "Yes / No"]; const FEEDBACK_Q_TYPES = ["Star Rating", "NPS (0–10)", "Multiple Choice", "Radio", "Short Text", "Long Text", "Checkboxes", "Yes / No", "Section"];
const REG_FIELD_TYPES = ["Text", "Email", "Phone", "Dropdown", "Radio", "Checkboxes", "Yes / No", "Date", "File Upload"]; const REG_FIELD_TYPES = ["Text", "Email", "Phone", "Dropdown", "Radio", "Checkboxes", "Yes / No", "Date", "Time", "File Upload", "Country State", "Section"];
/** Other forms: union of feedback + registration field labels (order preserved, no duplicates). */ /** Other forms: union of feedback + registration field labels (order preserved, no duplicates). */
const OTHER_Q_TYPES = [...new Set([...FEEDBACK_Q_TYPES, ...REG_FIELD_TYPES])]; const OTHER_Q_TYPES = [...new Set([...FEEDBACK_Q_TYPES, ...REG_FIELD_TYPES])];
const SECTION_FIELD_TYPE = "Section";
function isSectionFieldType(type: string): boolean {
return type === SECTION_FIELD_TYPE;
}
function patchForFieldTypeChange(next: string, q: Question): Partial<Question> {
const nextPatch: Partial<Question> = { type: next };
if (isSectionFieldType(next)) {
nextPatch.required = "No";
nextPatch.options = [];
nextPatch.mapsTo = "";
nextPatch.customMapsKey = "";
return nextPatch;
}
if (OPTION_FIELD_TYPES.has(next)) {
nextPatch.options = q.options?.length ? q.options : defaultChoiceOptions();
} else {
nextPatch.options = [];
}
if (next !== "Dropdown") {
nextPatch.allowOther = false;
nextPatch.otherLabel = undefined;
}
return nextPatch;
}
/** Types that need a configurable list of choices (dropdown / MC / radio / multi-checkbox). */ /** Types that need a configurable list of choices (dropdown / MC / radio / multi-checkbox). */
const OPTION_FIELD_TYPES = new Set(["Multiple Choice", "Dropdown", "Radio", "Checkboxes"]); const OPTION_FIELD_TYPES = new Set(["Multiple Choice", "Dropdown", "Radio", "Checkboxes"]);
const FORM_Q_DRAG = "application/x-eventbuzz-form-q";
function reorderList<T>(items: T[], from: number, to: number): T[] {
if (from === to || from < 0 || to < 0 || from >= items.length || to >= items.length) return items;
const next = [...items];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
return next;
}
function DragGrip({ index, disabled }: { index: number; disabled?: boolean }) {
return (
<div
draggable={!disabled}
onDragStart={(e) => {
if (disabled) return;
e.stopPropagation();
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData(FORM_Q_DRAG, String(index));
e.dataTransfer.setData("text/plain", String(index));
}}
style={{
color: "var(--muted)",
paddingTop: "6px",
flexShrink: 0,
cursor: disabled ? "default" : "grab",
opacity: disabled ? 0.35 : 1,
touchAction: "none",
}}
title={disabled ? undefined : "Drag to reorder"}
aria-label={disabled ? undefined : "Drag to reorder"}
>
<GripVertical size={14} />
</div>
);
}
function DraggableRowShell({
index,
onReorder,
disabled,
children,
style,
}: {
index: number;
onReorder: (from: number, to: number) => void;
disabled?: boolean;
children: React.ReactNode;
style?: React.CSSProperties;
}) {
const [dragOver, setDragOver] = useState(false);
return (
<div
onDragOver={(e) => {
if (disabled) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
if (disabled) return;
e.preventDefault();
setDragOver(false);
const raw = e.dataTransfer.getData(FORM_Q_DRAG) || e.dataTransfer.getData("text/plain");
const from = parseInt(raw, 10);
if (Number.isNaN(from) || from === index) return;
onReorder(from, index);
}}
style={{
...style,
outline: dragOver && !disabled ? "2px solid var(--primary)" : undefined,
outlineOffset: dragOver && !disabled ? 2 : undefined,
}}
>
{children}
</div>
);
}
const REG_TYPE_TO_API: Record<string, FormField["type"]> = { const REG_TYPE_TO_API: Record<string, FormField["type"]> = {
Text: "text", Text: "text",
Email: "email", Email: "email",
@ -33,8 +143,10 @@ const REG_TYPE_TO_API: Record<string, FormField["type"]> = {
Radio: "radio", Radio: "radio",
Checkboxes: "checkboxes", Checkboxes: "checkboxes",
"Yes / No": "checkbox", "Yes / No": "checkbox",
Date: "text", Date: "date",
Time: "time",
"File Upload": "file", "File Upload": "file",
"Country State": "country_state",
}; };
const REG_EXTRA_MAP_OPTIONS: { value: string; label: string }[] = [ const REG_EXTRA_MAP_OPTIONS: { value: string; label: string }[] = [
@ -50,13 +162,20 @@ const REG_EXTRA_MAP_OPTIONS: { value: string; label: string }[] = [
interface Question { interface Question {
id: string; id: string;
label: string; label: string;
/** Unique submission key (derived from label; not shown to respondents). */
name?: string;
type: string; type: string;
required: "Yes" | "No"; required: "Yes" | "No";
/** Choice labels for Multiple Choice, Dropdown, Radio, Checkboxes. */ /** Choice labels for Multiple Choice, Dropdown, Radio, Checkboxes. */
options?: string[]; options?: string[];
/** Dropdown only: include an Other option with a custom label. */
allowOther?: boolean;
otherLabel?: string;
mapsTo?: string; mapsTo?: string;
customMapsKey?: string; customMapsKey?: string;
fixed?: boolean; fixed?: boolean;
/** Optional note shown below the field on the public form. */
remarks?: string;
} }
function normalizedOptions(q: Question): string[] { function normalizedOptions(q: Question): string[] {
@ -72,6 +191,23 @@ function defaultChoiceOptions(): string[] {
return ["Option 1", "Option 2"]; return ["Option 1", "Option 2"];
} }
function FieldRemarksEditor({ value, onChange }: { value?: string; onChange: (v: string) => void }) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
<span style={{ fontSize: "var(--text-xs)", fontWeight: 600, fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>Remarks</span>
<input
style={IS}
placeholder="Optional note shown below this field (small text)"
value={value ?? ""}
onChange={(e) => onChange(e.target.value)}
onFocus={focusIn}
onBlur={focusOut}
/>
<HelperText text="Respondents see this as small helper text under the field." />
</div>
);
}
function FieldOptionsEditor({ options, onChange }: { options: string[]; onChange: (opts: string[]) => void }) { function FieldOptionsEditor({ options, onChange }: { options: string[]; onChange: (opts: string[]) => void }) {
const rows = options.length ? options : ["", ""]; const rows = options.length ? options : ["", ""];
const setAt = (i: number, v: string) => { const setAt = (i: number, v: string) => {
@ -113,33 +249,40 @@ function initialRegQuestions(): Question[] {
]; ];
} }
function QuestionRow({ q, types, onPatch, onRemove, idx, canRemove }: { function QuestionRow({ q, types, onPatch, onRemove, idx, canRemove, dragIndex, onReorder, onLabelCommit }: {
q: Question; q: Question;
types: string[]; types: string[];
onPatch: (patch: Partial<Question>) => void; onPatch: (patch: Partial<Question>) => void;
onRemove: () => void; onRemove: () => void;
idx: number; idx: number;
canRemove: boolean; canRemove: boolean;
dragIndex: number;
onReorder: (from: number, to: number) => void;
onLabelCommit: () => void;
}) { }) {
const showChoices = OPTION_FIELD_TYPES.has(q.type); const showChoices = OPTION_FIELD_TYPES.has(q.type);
const isSection = isSectionFieldType(q.type);
const rowStyle = {
display: "flex" as const,
gap: "8px",
alignItems: "flex-start" as const,
padding: "10px 12px",
background: isSection ? "var(--accent)" : "var(--secondary)",
borderRadius: "var(--radius-button)",
border: "1px solid var(--border)",
};
return ( return (
<div style={{ display: "flex", gap: "8px", alignItems: "flex-start", padding: "10px 12px", background: "var(--secondary)", borderRadius: "var(--radius-button)", border: "1px solid var(--border)" }}> <DraggableRowShell index={dragIndex} onReorder={onReorder} style={rowStyle}>
<div style={{ color: "var(--muted)", paddingTop: "6px", flexShrink: 0 }}><GripVertical size={14} /></div> <DragGrip index={dragIndex} />
<div style={{ flex: 1, display: "flex", flexDirection: "column", gap: "8px" }}> <div style={{ flex: 1, display: "flex", flexDirection: "column", gap: "8px" }}>
{isSection ? (
<span style={{ fontSize: "10px", fontWeight: 600, fontFamily: "var(--font-noto-sans)", color: "var(--primary)", textTransform: "uppercase" }}>Section divider · not submitted</span>
) : null}
<div style={{ display: "grid", gridTemplateColumns: canRemove ? "1fr auto auto auto" : "1fr auto auto", gap: "8px", alignItems: "center" }}> <div style={{ display: "grid", gridTemplateColumns: canRemove ? "1fr auto auto auto" : "1fr auto auto", gap: "8px", alignItems: "center" }}>
<input style={IS} placeholder={`Question ${idx + 1}`} value={q.label} <input style={IS} placeholder={isSection ? "Section title" : `Question ${idx + 1}`} value={q.label}
onChange={e => onPatch({ label: e.target.value })} onFocus={focusIn} onBlur={focusOut} /> onChange={e => onPatch({ label: e.target.value })} onFocus={focusIn} onBlur={() => { focusOut(); onLabelCommit(); }} />
<div style={{ position: "relative" }}> <div style={{ position: "relative" }}>
<select style={{ ...SS, width: "auto", minWidth: "130px" }} value={q.type} onChange={(e) => { <select style={{ ...SS, width: "auto", minWidth: "130px" }} value={q.type} onChange={(e) => onPatch(patchForFieldTypeChange(e.target.value, q))} onFocus={focusIn} onBlur={focusOut}>
const next = e.target.value;
const nextPatch: Partial<Question> = { type: next };
if (OPTION_FIELD_TYPES.has(next)) {
nextPatch.options = q.options?.length ? q.options : defaultChoiceOptions();
} else {
nextPatch.options = [];
}
onPatch(nextPatch);
}} onFocus={focusIn} onBlur={focusOut}>
{types.map(t => <option key={t}>{t}</option>)} {types.map(t => <option key={t}>{t}</option>)}
</select> </select>
</div> </div>
@ -152,6 +295,14 @@ function QuestionRow({ q, types, onPatch, onRemove, idx, canRemove }: {
{showChoices && ( {showChoices && (
<FieldOptionsEditor options={q.options ?? []} onChange={(opts) => onPatch({ options: opts })} /> <FieldOptionsEditor options={q.options ?? []} onChange={(opts) => onPatch({ options: opts })} />
)} )}
{q.type === "Dropdown" && (
<DropdownOtherConfigEditor
allowOther={q.allowOther}
otherLabel={q.otherLabel}
onChange={(patch) => onPatch(patch)}
/>
)}
{!isSection ? (
<div style={{ display: "flex", alignItems: "center", gap: "16px" }}> <div style={{ display: "flex", alignItems: "center", gap: "16px" }}>
<label style={{ display: "flex", alignItems: "center", gap: "6px", cursor: "pointer", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)" }}> <label style={{ display: "flex", alignItems: "center", gap: "6px", cursor: "pointer", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)" }}>
<input type="checkbox" checked={q.required === "Yes"} onChange={e => onPatch({ required: e.target.checked ? "Yes" : "No" })} <input type="checkbox" checked={q.required === "Yes"} onChange={e => onPatch({ required: e.target.checked ? "Yes" : "No" })}
@ -159,56 +310,70 @@ function QuestionRow({ q, types, onPatch, onRemove, idx, canRemove }: {
Required field Required field
</label> </label>
</div> </div>
) : null}
{!isSection ? (
<FieldRemarksEditor value={q.remarks} onChange={(remarks) => onPatch({ remarks })} />
) : null}
</div> </div>
</div> </DraggableRowShell>
); );
} }
function RegCoreFieldRow({ q, idx, onPatch }: { q: Question; idx: number; onPatch: (patch: Partial<Question>) => void }) { function RegCoreFieldRow({ q, idx, onPatch, onLabelCommit }: { q: Question; idx: number; onPatch: (patch: Partial<Question>) => void; onLabelCommit: () => void }) {
return ( return (
<div style={{ display: "flex", gap: "8px", alignItems: "flex-start", padding: "10px 12px", background: "var(--accent)", borderRadius: "var(--radius-button)", border: "1px solid var(--border)" }}> <div style={{ display: "flex", gap: "8px", alignItems: "flex-start", padding: "10px 12px", background: "var(--accent)", borderRadius: "var(--radius-button)", border: "1px solid var(--border)" }}>
<div style={{ color: "var(--muted)", paddingTop: "6px", flexShrink: 0 }}><GripVertical size={14} /></div> <DragGrip index={idx} disabled />
<div style={{ flex: 1, display: "flex", flexDirection: "column", gap: "8px" }}> <div style={{ flex: 1, display: "flex", flexDirection: "column", gap: "8px" }}>
<div style={{ display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" }}> <div style={{ display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" }}>
<span style={{ fontSize: "10px", fontWeight: 600, fontFamily: "var(--font-noto-sans)", color: "var(--primary)", textTransform: "uppercase" }}>Core · maps to {q.mapsTo}</span> <span style={{ fontSize: "10px", fontWeight: 600, fontFamily: "var(--font-noto-sans)", color: "var(--primary)", textTransform: "uppercase" }}>Core · maps to {q.mapsTo}</span>
</div> </div>
<input style={IS} placeholder={q.mapsTo === "email" ? "Email address" : "Label shown to registrants"} value={q.label} <input style={IS} placeholder={q.mapsTo === "email" ? "Email address" : "Label shown to registrants"} value={q.label}
onChange={e => onPatch({ label: e.target.value })} onFocus={focusIn} onBlur={focusOut} /> onChange={e => onPatch({ label: e.target.value })} onFocus={focusIn} onBlur={() => { focusOut(); onLabelCommit(); }} />
<FieldRemarksEditor value={q.remarks} onChange={(remarks) => onPatch({ remarks })} />
</div> </div>
</div> </div>
); );
} }
function RegExtraFieldRow({ q, types, onPatch, onRemove, idx }: { function RegExtraFieldRow({ q, types, onPatch, onRemove, idx, dragIndex, onReorder, onLabelCommit }: {
q: Question; q: Question;
types: string[]; types: string[];
onPatch: (patch: Partial<Question>) => void; onPatch: (patch: Partial<Question>) => void;
onRemove: () => void; onRemove: () => void;
idx: number; idx: number;
dragIndex: number;
onReorder: (from: number, to: number) => void;
onLabelCommit: () => void;
}) { }) {
const showChoices = OPTION_FIELD_TYPES.has(q.type); const showChoices = OPTION_FIELD_TYPES.has(q.type);
const isSection = isSectionFieldType(q.type);
const rowStyle = {
display: "flex" as const,
flexDirection: "column" as const,
gap: "8px",
padding: "10px 12px",
background: isSection ? "var(--accent)" : "var(--secondary)",
borderRadius: "var(--radius-button)",
border: "1px solid var(--border)",
};
return ( return (
<div style={{ display: "flex", flexDirection: "column", gap: "8px", padding: "10px 12px", background: "var(--secondary)", borderRadius: "var(--radius-button)", border: "1px solid var(--border)" }}> <DraggableRowShell index={dragIndex} onReorder={onReorder} style={rowStyle}>
<div style={{ display: "flex", gap: "8px", alignItems: "flex-start" }}> <div style={{ display: "flex", gap: "8px", alignItems: "flex-start" }}>
<div style={{ color: "var(--muted)", paddingTop: "6px" }}><GripVertical size={14} /></div> <DragGrip index={dragIndex} />
<div style={{ flex: 1, display: "grid", gridTemplateColumns: "1fr 1fr", gap: "8px", alignItems: "start" }}> <div style={{ flex: 1, display: "grid", gridTemplateColumns: "1fr 1fr", gap: "8px", alignItems: "start" }}>
{isSection ? (
<Field span2>
<span style={{ fontSize: "10px", fontWeight: 600, fontFamily: "var(--font-noto-sans)", color: "var(--primary)", textTransform: "uppercase" }}>Section divider · not submitted</span>
</Field>
) : null}
<Field> <Field>
<Label text="Field label" /> <Label text={isSection ? "Section title" : "Field label"} />
<input style={IS} placeholder={`Field ${idx + 1}`} value={q.label} onChange={e => onPatch({ label: e.target.value })} onFocus={focusIn} onBlur={focusOut} /> <input style={IS} placeholder={isSection ? "e.g. Personal details" : `Field ${idx + 1}`} value={q.label} onChange={e => onPatch({ label: e.target.value })} onFocus={focusIn} onBlur={() => { focusOut(); onLabelCommit(); }} />
</Field> </Field>
<Field> <Field>
<Label text="Field type" /> <Label text="Field type" />
<div style={{ position: "relative" }}> <div style={{ position: "relative" }}>
<select style={SS} value={q.type} onChange={(e) => { <select style={SS} value={q.type} onChange={(e) => onPatch(patchForFieldTypeChange(e.target.value, q))} onFocus={focusIn} onBlur={focusOut}>
const next = e.target.value;
const nextPatch: Partial<Question> = { type: next };
if (OPTION_FIELD_TYPES.has(next)) {
nextPatch.options = q.options?.length ? q.options : defaultChoiceOptions();
} else {
nextPatch.options = [];
}
onPatch(nextPatch);
}} onFocus={focusIn} onBlur={focusOut}>
{types.map(t => <option key={t}>{t}</option>)} {types.map(t => <option key={t}>{t}</option>)}
</select> </select>
</div> </div>
@ -218,6 +383,17 @@ function RegExtraFieldRow({ q, types, onPatch, onRemove, idx }: {
<FieldOptionsEditor options={q.options ?? []} onChange={(opts) => onPatch({ options: opts })} /> <FieldOptionsEditor options={q.options ?? []} onChange={(opts) => onPatch({ options: opts })} />
</Field> </Field>
)} )}
{q.type === "Dropdown" && (
<Field span2>
<DropdownOtherConfigEditor
allowOther={q.allowOther}
otherLabel={q.otherLabel}
onChange={(patch) => onPatch(patch)}
/>
</Field>
)}
{!isSection ? (
<>
<Field> <Field>
<Label text="Map to participant" /> <Label text="Map to participant" />
<div style={{ position: "relative" }}> <div style={{ position: "relative" }}>
@ -237,13 +413,20 @@ function RegExtraFieldRow({ q, types, onPatch, onRemove, idx }: {
<HelperText text="Stored under Participant.customFields[key]." /> <HelperText text="Stored under Participant.customFields[key]." />
</Field> </Field>
)} )}
</>
) : null}
{!isSection ? (
<Field span2>
<FieldRemarksEditor value={q.remarks} onChange={(remarks) => onPatch({ remarks })} />
</Field>
) : null}
</div> </div>
<button onClick={onRemove} type="button" title="Remove field" <button onClick={onRemove} type="button" title="Remove field"
style={{ display: "flex", alignItems: "center", justifyContent: "center", width: "28px", height: "28px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--destructive)", cursor: "pointer", flexShrink: 0 }}> style={{ display: "flex", alignItems: "center", justifyContent: "center", width: "28px", height: "28px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--destructive)", cursor: "pointer", flexShrink: 0 }}>
<Trash2 size={13} /> <Trash2 size={13} />
</button> </button>
</div> </div>
</div> </DraggableRowShell>
); );
} }
@ -293,26 +476,40 @@ const initialForm = (qTypes: string[], isFeedback: boolean): FForm => ({
onSubmitListId: "", onSubmitListId: "",
}); });
function typeFromApi(t: string, opts?: string[], qTypes?: string[]): string { function typeFromApi(
t: string,
opts: string[] | undefined,
qTypes: string[],
mode: FormMode,
uiType?: string,
): string {
const savedUi = uiType?.trim();
if (savedUi && qTypes.includes(savedUi)) return savedUi;
if (t === "stars") return "Star Rating"; if (t === "stars") return "Star Rating";
if (t === "select" && opts?.length === 5 && opts.every((v, i) => v === String(i + 1))) return "Star Rating"; if (t === "select" && opts?.length === 5 && opts.every((v, i) => v === String(i + 1))) return "Star Rating";
if (t === "select" && opts?.length === 11) return "NPS (0–10)"; if (t === "select" && opts?.length === 11) return "NPS (0–10)";
if (t === "radio") return "Radio"; if (t === "radio") return "Radio";
if (t === "checkboxes") return "Checkboxes"; if (t === "checkboxes") return "Checkboxes";
if (t === "select") { if (t === "select") {
const mcIdx = qTypes?.indexOf("Multiple Choice") ?? -1; if (mode === "registration") return "Dropdown";
const ddIdx = qTypes?.indexOf("Dropdown") ?? -1; if (mode === "feedback") return "Multiple Choice";
if (ddIdx >= 0 && (mcIdx < 0 || ddIdx < mcIdx)) return "Dropdown"; return qTypes.includes("Dropdown") ? "Dropdown" : "Multiple Choice";
if (mcIdx >= 0) return "Multiple Choice"; }
return "Multiple Choice"; if (t === "checkbox") return qTypes.includes("Yes / No") ? "Yes / No" : "Checkboxes";
if (t === "textarea") return mode === "registration" ? "Text" : "Long Text";
if (t === "text") {
if (mode === "registration" || mode === "other") return qTypes.includes("Text") ? "Text" : "Short Text";
return "Short Text";
} }
if (t === "checkbox") return qTypes?.includes("Yes / No") ? "Yes / No" : "Checkboxes";
if (t === "textarea") return qTypes?.includes("Long Text") ? "Long Text" : "Text";
if (t === "text") return qTypes?.includes("Short Text") ? "Short Text" : "Text";
if (t === "email") return "Email"; if (t === "email") return "Email";
if (t === "phone") return "Phone"; if (t === "phone") return "Phone";
if (t === "date") return "Date";
if (t === "time") return "Time";
if (t === "file") return "File Upload"; if (t === "file") return "File Upload";
return qTypes?.[0] ?? "Short Text"; if (t === "section") return "Section";
if (t === "country_state") return "Country State";
return qTypes[0] ?? "Short Text";
} }
function optionsFromApi(f: FormField): string[] { function optionsFromApi(f: FormField): string[] {
@ -320,33 +517,94 @@ function optionsFromApi(f: FormField): string[] {
return f.options.map((s) => String(s)); return f.options.map((s) => String(s));
} }
function toApiField(q: Question, isFeedbackOrOther: boolean): FormField { function withUiType(field: FormField, uiType: string | undefined, persistUiType: boolean): FormField {
if (!persistUiType || !uiType?.trim()) return field;
return { ...field, uiType: uiType.trim() };
}
function withFieldRemarks(field: FormField, remarks?: string): FormField {
const trimmed = remarks?.trim();
return trimmed ? { ...field, remarks: trimmed } : field;
}
function withSelectOther(field: FormField, q: Question): FormField {
if (q.type !== "Dropdown") return field;
if (!q.allowOther) return field;
const otherLabel = q.otherLabel?.trim() || "Other";
return { ...field, allowOther: true, otherLabel };
}
function toApiField(q: Question, mode: FormMode): FormField {
const label = q.label?.trim() || "Untitled"; const label = q.label?.trim() || "Untitled";
const name = q.name?.trim() || label;
const required = q.required === "Yes"; const required = q.required === "Yes";
const choiceOpts = normalizedOptions(q); const choiceOpts = normalizedOptions(q);
const persistUiType = mode === "other";
if (isSectionFieldType(q.type)) {
return withUiType({ label, type: "section", required: false }, q.type, persistUiType);
}
const withName = (field: FormField) => withFieldRemarks({ ...field, name }, q.remarks);
const isFeedbackOrOther = mode === "feedback" || mode === "other";
if (isFeedbackOrOther) { if (isFeedbackOrOther) {
if (q.type === "Star Rating") return { label, type: "stars", required, options: ["1", "2", "3", "4", "5"] }; if (q.type === "Star Rating") {
if (q.type === "NPS (0–10)") return { label, type: "select", required, options: Array.from({ length: 11 }, (_, i) => String(i)) }; return withName(withUiType({ label, type: "stars", required, options: ["1", "2", "3", "4", "5"] }, q.type, persistUiType));
if (q.type === "Multiple Choice") return { label, type: "select", required, options: choiceOpts }; }
if (q.type === "Radio") return { label, type: "radio", required, options: choiceOpts }; if (q.type === "NPS (0–10)") {
if (q.type === "Checkboxes") return { label, type: "checkboxes", required, options: choiceOpts }; return withName(withUiType(
if (q.type === "Short Text") return { label, type: "text", required }; { label, type: "select", required, options: Array.from({ length: 11 }, (_, i) => String(i)) },
if (q.type === "Long Text") return { label, type: "textarea", required }; q.type,
if (q.type === "Yes / No") return { label, type: "checkbox", required }; persistUiType,
));
}
if (q.type === "Multiple Choice") {
return withName(withUiType({ label, type: "select", required, options: choiceOpts }, q.type, persistUiType));
}
if (q.type === "Dropdown") {
return withName(
withSelectOther(
withUiType({ label, type: "select", required, options: choiceOpts }, q.type, persistUiType),
q,
),
);
}
if (q.type === "Radio") {
return withName(withUiType({ label, type: "radio", required, options: choiceOpts }, q.type, persistUiType));
}
if (q.type === "Checkboxes") {
return withName(withUiType({ label, type: "checkboxes", required, options: choiceOpts }, q.type, persistUiType));
}
if (q.type === "Short Text") {
return withName(withUiType({ label, type: "text", required }, q.type, persistUiType));
}
if (q.type === "Long Text") {
return withName(withUiType({ label, type: "textarea", required }, q.type, persistUiType));
}
if (q.type === "Yes / No") {
return withName(withUiType({ label, type: "checkbox", required }, q.type, persistUiType));
}
if (q.type === "Country State") {
return withName(withUiType({ label, type: "country_state", required }, q.type, persistUiType));
}
const regType = REG_TYPE_TO_API[q.type]; const regType = REG_TYPE_TO_API[q.type];
if (regType) { if (regType) {
if (regType === "select" || regType === "radio" || regType === "checkboxes") { if (regType === "select" || regType === "radio" || regType === "checkboxes") {
return { label, type: regType, required, options: choiceOpts }; const base = withUiType({ label, type: regType, required, options: choiceOpts }, q.type, persistUiType);
return withName(q.type === "Dropdown" ? withSelectOther(base, q) : base);
} }
return { label, type: regType, required }; return withName(withUiType({ label, type: regType, required }, q.type, persistUiType));
} }
return { label, type: "text", required }; return withName(withUiType({ label, type: "text", required }, q.type, persistUiType));
} }
const type = (REG_TYPE_TO_API[q.type] ?? "text") as FormField["type"]; const type = (REG_TYPE_TO_API[q.type] ?? "text") as FormField["type"];
if (type === "select" || type === "radio" || type === "checkboxes") { if (type === "select" || type === "radio" || type === "checkboxes") {
return { label, type, required, options: choiceOpts }; const base = { label, name, type, required, options: choiceOpts };
return withFieldRemarks(q.type === "Dropdown" ? withSelectOther(base, q) : base, q.remarks);
} }
return { label, type, required }; return withFieldRemarks({ label, name, type, required }, q.remarks);
} }
function isCoreRegFieldMapsTo(mapsTo: string | undefined): boolean { function isCoreRegFieldMapsTo(mapsTo: string | undefined): boolean {
@ -354,7 +612,7 @@ function isCoreRegFieldMapsTo(mapsTo: string | undefined): boolean {
} }
function toApiFieldReg(q: Question): FormField { function toApiFieldReg(q: Question): FormField {
const base = toApiField(q, false); const base = toApiField(q, "registration");
const required = q.fixed === true || isCoreRegFieldMapsTo(q.mapsTo) ? true : base.required; const required = q.fixed === true || isCoreRegFieldMapsTo(q.mapsTo) ? true : base.required;
return { return {
...base, ...base,
@ -370,43 +628,52 @@ function requiredFromApiRegField(f: FormField): "Yes" | "No" {
return f.required ? "Yes" : "No"; return f.required ? "Yes" : "No";
} }
function fromApiFields(fields: FormField[] | undefined, qTypes: string[]): Question[] { function fromApiFields(fields: FormField[] | undefined, qTypes: string[], mode: FormMode): Question[] {
if (!fields?.length) return [newQ(qTypes)]; if (!fields?.length) return [newQ(qTypes)];
return fields.map((f, i) => { const questions = fields.map((f, i) => {
const typ = typeFromApi(f.type ?? "text", f.options, qTypes); const typ = typeFromApi(f.type ?? "text", f.options, qTypes, mode, f.uiType);
return { return {
id: Math.random().toString(36).slice(2) + String(i), id: Math.random().toString(36).slice(2) + String(i),
label: f.label ?? "", label: f.label ?? "",
name: f.name ?? f.label ?? "",
type: typ, type: typ,
required: f.required ? "Yes" : "No", required: f.required ? "Yes" : "No",
options: OPTION_FIELD_TYPES.has(typ) ? optionsFromApi(f) : [], options: OPTION_FIELD_TYPES.has(typ) ? optionsFromApi(f) : [],
allowOther: typ === "Dropdown" ? !!f.allowOther : undefined,
otherLabel: typ === "Dropdown" ? (f.otherLabel?.trim() || "Other") : undefined,
mapsTo: f.mapsTo, mapsTo: f.mapsTo,
customMapsKey: f.customMapsKey, customMapsKey: f.customMapsKey,
fixed: f.isFixed, fixed: f.isFixed,
remarks: f.remarks ?? "",
}; };
}); });
return assignUniqueFieldNames(questions);
} }
function fromApiFieldSingle(f: FormField, i: number, qTypes: string[]): Question { function fromApiFieldSingle(f: FormField, i: number, qTypes: string[], mode: FormMode): Question {
const typ = typeFromApi(f.type ?? "text", f.options, qTypes); const typ = typeFromApi(f.type ?? "text", f.options, qTypes, mode, f.uiType);
return { return {
id: `imp-${i}-${Math.random().toString(36).slice(2)}`, id: `imp-${i}-${Math.random().toString(36).slice(2)}`,
label: f.label ?? "", label: f.label ?? "",
name: f.name ?? f.label ?? "",
type: typ, type: typ,
required: requiredFromApiRegField(f), required: requiredFromApiRegField(f),
options: OPTION_FIELD_TYPES.has(typ) ? optionsFromApi(f) : [], options: OPTION_FIELD_TYPES.has(typ) ? optionsFromApi(f) : [],
allowOther: typ === "Dropdown" ? !!f.allowOther : undefined,
otherLabel: typ === "Dropdown" ? (f.otherLabel?.trim() || "Other") : undefined,
mapsTo: f.mapsTo, mapsTo: f.mapsTo,
customMapsKey: f.customMapsKey, customMapsKey: f.customMapsKey,
fixed: f.isFixed === true, fixed: f.isFixed === true,
remarks: f.remarks ?? "",
}; };
} }
function mergeRegFieldsFromApi(fields: FormField[] | undefined, qTypes: string[]): Question[] { function mergeRegFieldsFromApi(fields: FormField[] | undefined, qTypes: string[]): Question[] {
const base = initialRegQuestions(); const base = initialRegQuestions();
if (!fields?.length) return base; if (!fields?.length) return base;
const converted = fields.map((f, i) => fromApiFieldSingle(f, i, qTypes)); const converted = fields.map((f, i) => fromApiFieldSingle(f, i, qTypes, "registration"));
if (!converted.some((q) => q.mapsTo === "firstName")) return [...base, ...converted]; const merged = !converted.some((q) => q.mapsTo === "firstName") ? [...base, ...converted] : converted;
return converted; return assignUniqueFieldNames(merged);
} }
export function CreateFormPanel({ export function CreateFormPanel({
@ -495,10 +762,11 @@ export function CreateFormPanel({
try { try {
const data = await formsApi.get(formId); const data = await formsApi.get(formId);
if (cancelled) return; if (cancelled) return;
const loadMode: FormMode = isReg ? "registration" : isOther ? "other" : "feedback";
const questions = const questions =
data.type === "registration" data.type === "registration"
? mergeRegFieldsFromApi(data.fields, qTypes) ? mergeRegFieldsFromApi(data.fields, qTypes)
: fromApiFields(data.fields, qTypes); : fromApiFields(data.fields, qTypes, loadMode);
const s = data.settings ?? {}; const s = data.settings ?? {};
const tags = Array.isArray(s.onSubmitAddTags) ? s.onSubmitAddTags.join(", ") : ""; const tags = Array.isArray(s.onSubmitAddTags) ? s.onSubmitAddTags.join(", ") : "";
setForm({ setForm({
@ -532,6 +800,22 @@ export function CreateFormPanel({
const updateQuestion = (id: string, patch: Partial<Question>) => const updateQuestion = (id: string, patch: Partial<Question>) =>
setForm(p => ({ ...p, questions: p.questions.map(q => (q.id === id ? { ...q, ...patch } : q)) })); setForm(p => ({ ...p, questions: p.questions.map(q => (q.id === id ? { ...q, ...patch } : q)) }));
const commitQuestionLabel = useCallback(() => {
setForm((p) => ({ ...p, questions: assignUniqueFieldNames(p.questions) }));
}, []);
const reorderQuestions = useCallback((from: number, to: number) => {
setForm((p) => ({ ...p, questions: reorderList(p.questions, from, to) }));
}, []);
const reorderRegExtras = useCallback((from: number, to: number) => {
setForm((p) => {
const core = p.questions.filter((q) => q.fixed);
const extras = p.questions.filter((q) => !q.fixed);
return { ...p, questions: [...core, ...reorderList(extras, from, to)] };
});
}, []);
const validateSection = useCallback((f: FForm, n: number): boolean => { const validateSection = useCallback((f: FForm, n: number): boolean => {
if (n === 1) return f.name.trim().length > 0 && selectedEventId.trim().length > 0; if (n === 1) return f.name.trim().length > 0 && selectedEventId.trim().length > 0;
if (n === 2) { if (n === 2) {
@ -540,6 +824,7 @@ export function CreateFormPanel({
const extras = f.questions.filter((q) => !q.fixed); const extras = f.questions.filter((q) => !q.fixed);
const extrasOk = extras.every((q) => { const extrasOk = extras.every((q) => {
if (!q.label.trim()) return false; if (!q.label.trim()) return false;
if (isSectionFieldType(q.type)) return true;
if (q.mapsTo === "custom" && !q.customMapsKey?.trim()) return false; if (q.mapsTo === "custom" && !q.customMapsKey?.trim()) return false;
if (!fieldChoicesAreValid(q)) return false; if (!fieldChoicesAreValid(q)) return false;
return true; return true;
@ -547,7 +832,7 @@ export function CreateFormPanel({
return coreOk && extrasOk; return coreOk && extrasOk;
} }
const anyLabel = f.questions.some(q => q.label.trim().length > 0); const anyLabel = f.questions.some(q => q.label.trim().length > 0);
const choicesOk = f.questions.every(q => !q.label.trim() || fieldChoicesAreValid(q)); const choicesOk = f.questions.every(q => isSectionFieldType(q.type) || !q.label.trim() || fieldChoicesAreValid(q));
return anyLabel && choicesOk; return anyLabel && choicesOk;
} }
if (n === 3 && isReg) return true; if (n === 3 && isReg) return true;
@ -564,9 +849,11 @@ export function CreateFormPanel({
const buildPayload = useCallback(() => { const buildPayload = useCallback(() => {
const apiType = isReg ? "registration" : isOther ? "other" : "feedback"; const apiType = isReg ? "registration" : isOther ? "other" : "feedback";
const saveMode: FormMode = apiType;
const uniqueQuestions = assignUniqueFieldNames(form.questions);
const fields = isReg const fields = isReg
? form.questions.filter(q => q.label.trim()).map(q => toApiFieldReg(q)) ? uniqueQuestions.filter(q => q.label.trim()).map(q => toApiFieldReg(q))
: form.questions.filter(q => q.label.trim()).map(q => toApiField(q, isFeedbackOrOther)); : uniqueQuestions.filter(q => q.label.trim()).map(q => toApiField(q, saveMode));
const isActive = form.publish === "Published"; const isActive = form.publish === "Published";
const tagArr = form.onSubmitTags.split(/[,;\n]+/).map(t => t.trim()).filter(Boolean); const tagArr = form.onSubmitTags.split(/[,;\n]+/).map(t => t.trim()).filter(Boolean);
return { return {
@ -731,15 +1018,15 @@ export function CreateFormPanel({
{isReg ? ( {isReg ? (
<> <>
{form.questions.filter((q) => q.fixed).map((q, i) => ( {form.questions.filter((q) => q.fixed).map((q, i) => (
<RegCoreFieldRow key={q.id} q={q} idx={i} onPatch={(patch) => updateQuestion(q.id, patch)} /> <RegCoreFieldRow key={q.id} q={q} idx={i} onPatch={(patch) => updateQuestion(q.id, patch)} onLabelCommit={commitQuestionLabel} />
))} ))}
{form.questions.filter((q) => !q.fixed).map((q, i) => ( {form.questions.filter((q) => !q.fixed).map((q, i) => (
<RegExtraFieldRow key={q.id} q={q} types={qTypes} idx={i} onPatch={(patch) => updateQuestion(q.id, patch)} onRemove={() => removeQ(q.id)} /> <RegExtraFieldRow key={q.id} q={q} types={qTypes} idx={i} dragIndex={i} onReorder={reorderRegExtras} onPatch={(patch) => updateQuestion(q.id, patch)} onRemove={() => removeQ(q.id)} onLabelCommit={commitQuestionLabel} />
))} ))}
</> </>
) : ( ) : (
form.questions.map((q, i) => ( form.questions.map((q, i) => (
<QuestionRow key={q.id} q={q} types={qTypes} idx={i} onPatch={(patch) => updateQuestion(q.id, patch)} onRemove={() => removeQ(q.id)} canRemove={form.questions.length > 1} /> <QuestionRow key={q.id} q={q} types={qTypes} idx={i} dragIndex={i} onReorder={reorderQuestions} onPatch={(patch) => updateQuestion(q.id, patch)} onRemove={() => removeQ(q.id)} canRemove={form.questions.length > 1} onLabelCommit={commitQuestionLabel} />
)) ))
)} )}
</div> </div>
@ -754,7 +1041,7 @@ export function CreateFormPanel({
<Plus size={13} /> Add {isFeedbackOrOther ? "Question" : "Field"} <Plus size={13} /> Add {isFeedbackOrOther ? "Question" : "Field"}
</button> </button>
)} )}
<HelperText text={isReg ? "Submissions must include the email field. Map extra fields to participant columns or customFields." : "Drag the grip icon to reorder questions."} /> <HelperText text={isReg ? "Core fields stay at the top. Drag extra fields by the grip icon to reorder." : "Drag the grip icon to reorder questions."} />
</AccordionSection> </AccordionSection>
{isReg && ( {isReg && (

View File

@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useState } from "react";
import { X, Download, ChevronLeft, ChevronRight } from "lucide-react"; import { X, Download, ChevronLeft, ChevronRight } from "lucide-react";
import { formsApi } from "../../../services/api"; import { formsApi } from "../../../services/api";
import type { Form, FormField, FormSubmissionRecord } from "../../../services/api"; import type { Form, FormField, FormSubmissionRecord } from "../../../services/api";
import { buildFormSubmissionsExportFilename } from "../../utils/formExportFilename";
function dataToObj(data: unknown): Record<string, unknown> { function dataToObj(data: unknown): Record<string, unknown> {
if (!data || typeof data !== "object") return {}; if (!data || typeof data !== "object") return {};
@ -27,6 +28,12 @@ function participantEmail(
return p.email?.trim() || "—"; return p.email?.trim() || "—";
} }
function eventNameFromForm(form: Form | null): string {
const ev = form?.event;
if (ev && typeof ev === "object" && ev.name?.trim()) return ev.name.trim();
return "Event";
}
type Props = { type Props = {
formId: string; formId: string;
formName: string; formName: string;
@ -107,15 +114,19 @@ export function FormSubmissionsPanel({ formId, formName, onClose }: Props) {
const onExport = async () => { const onExport = async () => {
setExporting(true); setExporting(true);
try { try {
const blob = await formsApi.exportSubmissionsCsv(formId, { const { blob } = await formsApi.exportSubmissionsCsv(formId, {
q: appliedQ || undefined, q: appliedQ || undefined,
dateFrom: appliedFrom || undefined, dateFrom: appliedFrom || undefined,
dateTo: appliedTo || undefined, dateTo: appliedTo || undefined,
}); });
const filename = buildFormSubmissionsExportFilename(
eventNameFromForm(formDef),
formName || formDef?.name || "Form",
);
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
a.href = url; a.href = url;
a.download = `form-${formId}-submissions.csv`; a.download = filename;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} catch (e) { } catch (e) {

View File

@ -1,4 +1,10 @@
import React, { createContext, useContext, useState } from "react"; import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import {
buildEventNavPath,
isEventScopedNav,
readEventIdFromSearch,
} from "../utils/eventNavUrl";
/** Set from Configure microsite → open Website builder with optional page + mode. Consumed once by WebsiteBuilderPage. */ /** Set from Configure microsite → open Website builder with optional page + mode. Consumed once by WebsiteBuilderPage. */
export type PendingMicrositeBuilder = { export type PendingMicrositeBuilder = {
@ -47,16 +53,24 @@ const AppNavContext = createContext<AppNavContextValue | null>(null);
interface AppNavProviderProps { interface AppNavProviderProps {
children: React.ReactNode; children: React.ReactNode;
/** When using URL-based routing, pass current path segment and navigator */ /** When set, nav tab comes from the URL and event id is synced via ?event= */
activeNavFromUrl?: string; activeNavFromUrl?: string;
setActiveNavToUrl?: (id: string) => void;
} }
const DEFAULT_NAV = "home"; const DEFAULT_NAV = "home";
export function AppNavProvider({ children, activeNavFromUrl, setActiveNavToUrl }: AppNavProviderProps) { export function AppNavProvider({ children, activeNavFromUrl }: AppNavProviderProps) {
const navigate = useNavigate();
const location = useLocation();
const urlRouting = activeNavFromUrl !== undefined;
const [internalNav, setInternalNav] = useState(DEFAULT_NAV); const [internalNav, setInternalNav] = useState(DEFAULT_NAV);
const [selectedEventId, setSelectedEventId] = useState<string | null>(null); const [selectedEventId, setSelectedEventIdState] = useState<string | null>(() =>
urlRouting ? readEventIdFromSearch(window.location.search) : null,
);
const selectedEventIdRef = useRef(selectedEventId);
selectedEventIdRef.current = selectedEventId;
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
@ -66,7 +80,42 @@ export function AppNavProvider({ children, activeNavFromUrl, setActiveNavToUrl }
const [pendingMicrositeBuilder, setPendingMicrositeBuilder] = useState<PendingMicrositeBuilder | null>(null); const [pendingMicrositeBuilder, setPendingMicrositeBuilder] = useState<PendingMicrositeBuilder | null>(null);
const activeNav = activeNavFromUrl ?? internalNav; const activeNav = activeNavFromUrl ?? internalNav;
const setActiveNav = setActiveNavToUrl ?? setInternalNav;
// Restore selected event from URL on refresh / back-forward.
useEffect(() => {
if (!urlRouting) return;
const fromUrl = readEventIdFromSearch(location.search);
setSelectedEventIdState((prev) => (prev === fromUrl ? prev : fromUrl));
}, [location.search, urlRouting]);
// Keep ?event= on event-scoped tabs when selection changes in memory.
useEffect(() => {
if (!urlRouting || !selectedEventId || !isEventScopedNav(activeNav)) return;
const fromUrl = readEventIdFromSearch(location.search);
if (fromUrl === selectedEventId) return;
navigate(buildEventNavPath(activeNav, selectedEventId), { replace: true });
}, [activeNav, selectedEventId, urlRouting, location.search, navigate]);
const setSelectedEventId = useCallback(
(id: string | null) => {
setSelectedEventIdState(id);
selectedEventIdRef.current = id;
if (!urlRouting || !isEventScopedNav(activeNav)) return;
navigate(buildEventNavPath(activeNav, id), { replace: true });
},
[activeNav, urlRouting, navigate],
);
const setActiveNav = useCallback(
(id: string) => {
if (urlRouting) {
navigate(buildEventNavPath(id, selectedEventIdRef.current));
return;
}
setInternalNav(id);
},
[urlRouting, navigate],
);
const value: AppNavContextValue = { const value: AppNavContextValue = {
activeNav, activeNav,
@ -89,9 +138,7 @@ export function AppNavProvider({ children, activeNavFromUrl, setActiveNavToUrl }
setPendingMicrositeBuilder, setPendingMicrositeBuilder,
}; };
return ( return <AppNavContext.Provider value={value}>{children}</AppNavContext.Provider>;
<AppNavContext.Provider value={value}>{children}</AppNavContext.Provider>
);
} }
export function useAppNav(): AppNavContextValue { export function useAppNav(): AppNavContextValue {

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,13 @@
import countriesData from './country-states.json';
export type CountryStatesMap = Record<string, string[]>;
export const COUNTRY_STATES = countriesData as CountryStatesMap;
export const DEFAULT_COUNTRY = 'India';
export const COUNTRY_NAMES = Object.keys(COUNTRY_STATES).sort((a, b) => a.localeCompare(b));
export function statesForCountry(country: string): string[] {
return COUNTRY_STATES[country] ?? [];
}

View File

@ -0,0 +1,21 @@
import { EVENT_NAV_IDS } from "../components/NavBar";
export const EVENT_QUERY_PARAM = "event";
export function isEventScopedNav(navId: string): boolean {
return EVENT_NAV_IDS.has(navId);
}
export function readEventIdFromSearch(search: string): string | null {
const value = new URLSearchParams(search).get(EVENT_QUERY_PARAM)?.trim();
return value || null;
}
/** Build app path for a nav tab, attaching ?event= when the tab is event-scoped. */
export function buildEventNavPath(navId: string, eventId?: string | null): string {
const path = `/${navId}`;
if (eventId && isEventScopedNav(navId)) {
return `${path}?${EVENT_QUERY_PARAM}=${encodeURIComponent(eventId)}`;
}
return path;
}

View File

@ -0,0 +1,24 @@
function sanitizeExportFilenamePart(value: string, fallback: string): string {
const trimmed = value.trim();
if (!trimmed) return fallback;
return (
trimmed
.replace(/[\\/:*?"<>|]+/g, "-")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 80) || fallback
);
}
function formatExportDateTime(date = new Date()): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}-${pad(date.getHours())}-${pad(date.getMinutes())}-${pad(date.getSeconds())}`;
}
/** `<Event-Name>-<Form-Name>-<YYYY-MM-DD-HH-MM-SS>.csv` */
export function buildFormSubmissionsExportFilename(eventName: string, formName: string): string {
const eventPart = sanitizeExportFilenamePart(eventName, "Event");
const formPart = sanitizeExportFilenamePart(formName, "Form");
return `${eventPart}-${formPart}-${formatExportDateTime()}.csv`;
}

View File

@ -0,0 +1,43 @@
/** Internal field `name` keys — derived from label; duplicates get `-1`, `-2`, … (label stays unchanged). */
type NamedField = { label?: string; name?: string; type?: string };
function isNonInputField(item: NamedField): boolean {
return String(item.type || "").toLowerCase() === "section";
}
/** Recompute unique `name` from each field's `label` (display text unchanged). */
export function assignUniqueFieldNames<T extends NamedField>(items: T[]): T[] {
const used = new Set<string>();
return items.map((item) => {
if (isNonInputField(item)) return item;
const base = (item.label || "").trim();
if (!base) return { ...item, name: "" };
if (!used.has(base)) {
used.add(base);
return { ...item, name: base };
}
let n = 1;
while (used.has(`${base}-${n}`)) n += 1;
const name = `${base}-${n}`;
used.add(name);
return { ...item, name };
});
}
export function fieldDataKey(field: { name?: string; label?: string }): string {
return (field.name || field.label || "").trim();
}
export function fieldDisplayLabel(field: { name?: string; label?: string }): string {
return (field.label || field.name || "").trim();
}
export function isCountryStateField(field: { type?: string }): boolean {
return field.type === "country_state";
}
export function countryStateKeys(field: { name?: string; label?: string }): { country: string; state: string } {
const base = fieldDataKey(field);
return { country: `${base} - Country`, state: `${base} - State` };
}

View File

@ -0,0 +1,22 @@
import type { FormWithSubmissionCount } from "../../services/api/types";
export function formEventId(f: FormWithSubmissionCount): string | null {
const ev = f.event;
if (!ev) return null;
if (typeof ev === "string") return ev.trim().length === 24 ? ev.trim() : null;
const id = (ev as { _id?: string })._id;
return id && String(id).trim().length === 24 ? String(id).trim() : null;
}
export function publicFormPageUrl(
f: FormWithSubmissionCount,
fallbackEventId?: string | null,
): string {
const base =
(typeof import.meta.env.VITE_PUBLIC_WEB_BASE === "string" && import.meta.env.VITE_PUBLIC_WEB_BASE.trim()) ||
window.location.origin.replace(/\/$/, "");
const eventId = formEventId(f) ?? fallbackEventId ?? null;
const fid = f._id;
if (eventId) return `${base.replace(/\/$/, "")}/event/${eventId}/form/${fid}`;
return `${base.replace(/\/$/, "")}/form/${fid}`;
}

View File

@ -0,0 +1,14 @@
import type { FormField } from "../../services/api/types";
const CORE_DEFS: FormField[] = [
{ label: "First name", name: "First name", type: "text", required: true, mapsTo: "firstName", isFixed: true },
{ label: "Last name", name: "Last name", type: "text", required: true, mapsTo: "lastName", isFixed: true },
{ label: "Email", name: "Email", type: "email", required: true, mapsTo: "email", isFixed: true },
];
export function ensureRegistrationCoreFields(fields: FormField[]): FormField[] {
const list = [...fields];
const missing = CORE_DEFS.filter((def) => !list.some((f) => f.mapsTo === def.mapsTo));
if (!missing.length) return list;
return [...missing, ...list];
}

View File

@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import { NavLink, Outlet, Navigate, useNavigate } from "react-router-dom"; import { NavLink, Outlet, Navigate, useNavigate } from "react-router-dom";
import { Building2, Layers, ArrowLeft } from "lucide-react"; import { Building2, Globe, Layers, ArrowLeft } from "lucide-react";
import { useAuth } from "../contexts/AuthContext"; import { useAuth } from "../contexts/AuthContext";
import { LoadingState } from "../components/shared"; import { LoadingState } from "../components/shared";
@ -84,6 +84,18 @@ export function PlatformAdminLayout() {
<Layers size={16} /> <Layers size={16} />
Plans Plans
</NavLink> </NavLink>
<NavLink
to="/super-admin/microsite-templates"
style={({ isActive }) => ({
...navLinkStyle,
marginTop: "4px",
background: isActive ? "var(--accent)" : "transparent",
fontWeight: isActive ? 600 : 400,
})}
>
<Globe size={16} />
Microsite Templates
</NavLink>
<div style={{ flex: 1 }} /> <div style={{ flex: 1 }} />
<button <button
type="button" type="button"

View File

@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { Navigate, useLocation, Outlet, useParams, useNavigate } from "react-router-dom"; import { Navigate, useLocation, Outlet, useParams } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext"; import { useAuth } from "../contexts/AuthContext";
import { AppNavProvider } from "../contexts/AppNavContext"; import { AppNavProvider } from "../contexts/AppNavContext";
import { AppShell } from "../components/AppShell"; import { AppShell } from "../components/AppShell";
@ -26,11 +26,9 @@ export function ProtectedLayout() {
function AppShellWithNav() { function AppShellWithNav() {
const { navId } = useParams<{ navId: string }>(); const { navId } = useParams<{ navId: string }>();
const navigate = useNavigate();
const activeNav = navId || "home"; const activeNav = navId || "home";
const setActiveNav = (id: string) => navigate(`/${id}`);
return ( return (
<AppNavProvider activeNavFromUrl={activeNav} setActiveNavToUrl={setActiveNav}> <AppNavProvider activeNavFromUrl={activeNav}>
<AppShell /> <AppShell />
</AppNavProvider> </AppNavProvider>
); );

View File

@ -0,0 +1,198 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Eye, Pencil, Plus, Trash2 } from 'lucide-react';
import { staticMicrositeApi, type MicrositeV2Template } from '../../services/api';
import { UploadMicrositeV2TemplatePanel } from '../components/microsite-v2/UploadMicrositeV2TemplatePanel';
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '8px 12px',
borderRadius: 'var(--radius-button)',
border: '1px solid var(--border)',
background: 'var(--input-background)',
color: 'var(--foreground)',
fontSize: 'var(--text-sm)',
boxSizing: 'border-box',
};
export function SuperAdminMicrositeV2TemplatesPage() {
const [templates, setTemplates] = useState<MicrositeV2Template[]>([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState('');
const [panelOpen, setPanelOpen] = useState(false);
const [editTarget, setEditTarget] = useState<MicrositeV2Template | null>(null);
const load = useCallback(async () => {
setErr('');
setLoading(true);
try {
const res = await staticMicrositeApi.listAdminTemplates();
if (res.error) throw new Error(res.error);
setTemplates(res.data || []);
} catch (e) {
setErr(e instanceof Error ? e.message : 'Failed to load templates');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const toggleActive = async (t: MicrositeV2Template) => {
const res = await staticMicrositeApi.updateTemplate(t._id, { isActive: !t.isActive });
if (res.error) {
alert(res.error);
return;
}
void load();
};
const handleDelete = async (t: MicrositeV2Template) => {
if (!window.confirm(`Delete template "${t.name}"?`)) return;
const res = await staticMicrositeApi.deleteTemplate(t._id);
if (res.error) {
alert(res.error);
return;
}
void load();
};
const handlePreview = async (t: MicrositeV2Template) => {
try {
const html = await staticMicrositeApi.previewTemplateHtml(t._id);
const blob = new Blob([html], { type: 'text/html' });
const url = URL.createObjectURL(blob);
window.open(url, '_blank', 'noopener');
window.setTimeout(() => URL.revokeObjectURL(url), 60000);
} catch (e) {
alert(e instanceof Error ? e.message : 'Preview failed');
}
};
return (
<>
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '12px' }}>
<div>
<p style={{ fontSize: '10px', color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '0.06em', margin: 0 }}>
Platform
</p>
<h1 style={{ fontFamily: 'var(--font-inter)', fontSize: 'var(--text-2xl)', margin: '4px 0 0' }}>
Microsite Templates
</h1>
<p style={{ fontSize: 'var(--text-sm)', color: 'var(--muted)', marginTop: '4px' }}>
Upload Handlebars templates for the static microsite system.
</p>
</div>
<button
type="button"
onClick={() => {
setEditTarget(null);
setPanelOpen(true);
}}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
padding: '8px 14px',
borderRadius: 'var(--radius-button)',
border: 'none',
background: 'var(--primary)',
color: 'var(--primary-foreground)',
cursor: 'pointer',
fontSize: 'var(--text-sm)',
}}
>
<Plus size={14} />
New template
</button>
</div>
{err ? <p style={{ color: 'var(--destructive)', fontSize: 'var(--text-sm)' }}>{err}</p> : null}
{loading ? (
<p style={{ color: 'var(--muted)' }}>Loading…</p>
) : (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 'var(--text-sm)' }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--border)', textAlign: 'left' }}>
<th style={{ padding: '10px 8px' }}>Preview</th>
<th style={{ padding: '10px 8px' }}>Name</th>
<th style={{ padding: '10px 8px' }}>Slug</th>
<th style={{ padding: '10px 8px' }}>Version</th>
<th style={{ padding: '10px 8px' }}>Active</th>
<th style={{ padding: '10px 8px' }}>Actions</th>
</tr>
</thead>
<tbody>
{templates.map((t) => (
<tr key={t._id} style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '10px 8px' }}>
{t.thumbnailUrl ? (
<img src={t.thumbnailUrl} alt="" style={{ width: 64, height: 40, objectFit: 'cover', borderRadius: 4 }} />
) : (
'—'
)}
</td>
<td style={{ padding: '10px 8px' }}>{t.name}</td>
<td style={{ padding: '10px 8px', fontFamily: 'monospace' }}>{t.slug}</td>
<td style={{ padding: '10px 8px' }}>v{t.version}</td>
<td style={{ padding: '10px 8px' }}>
<button
type="button"
onClick={() => void toggleActive(t)}
style={{
padding: '4px 10px',
borderRadius: 'var(--radius-button)',
border: '1px solid var(--border)',
background: t.isActive ? 'color-mix(in srgb, var(--primary) 15%, transparent)' : 'var(--card)',
cursor: 'pointer',
fontSize: 'var(--text-xs)',
}}
>
{t.isActive ? 'Active' : 'Inactive'}
</button>
</td>
<td style={{ padding: '10px 8px' }}>
<div style={{ display: 'flex', gap: '6px' }}>
<button type="button" title="Edit" onClick={() => { setEditTarget(t); setPanelOpen(true); }} style={{ border: 'none', background: 'none', cursor: 'pointer' }}>
<Pencil size={16} />
</button>
<button type="button" title="Preview" onClick={() => void handlePreview(t)} style={{ border: 'none', background: 'none', cursor: 'pointer' }}>
<Eye size={16} />
</button>
<button type="button" title="Delete" onClick={() => void handleDelete(t)} style={{ border: 'none', background: 'none', cursor: 'pointer', color: 'var(--destructive)' }}>
<Trash2 size={16} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{templates.length === 0 ? (
<p style={{ padding: '24px', textAlign: 'center', color: 'var(--muted)' }}>No templates yet.</p>
) : null}
</div>
)}
</div>
{panelOpen ? (
<UploadMicrositeV2TemplatePanel
template={editTarget}
onClose={() => {
setPanelOpen(false);
setEditTarget(null);
}}
onSaved={() => {
setPanelOpen(false);
setEditTarget(null);
void load();
}}
/>
) : null}
</>
);
}

View File

@ -121,7 +121,7 @@ export const formsApi = {
async exportSubmissionsCsv( async exportSubmissionsCsv(
formId: string, formId: string,
params?: { q?: string; dateFrom?: string; dateTo?: string } params?: { q?: string; dateFrom?: string; dateTo?: string }
): Promise<Blob> { ): Promise<{ blob: Blob; filename: string }> {
const sp = new URLSearchParams(); const sp = new URLSearchParams();
sp.set('export', 'csv'); sp.set('export', 'csv');
if (params?.q) sp.set('q', params.q); if (params?.q) sp.set('q', params.q);
@ -138,6 +138,24 @@ export const formsApi = {
} }
throw new Error(msg); throw new Error(msg);
} }
return res.blob(); const filename = parseContentDispositionFilename(res.headers.get('Content-Disposition')) ?? 'form-export.csv';
return { blob: await res.blob(), filename };
}, },
}; };
function parseContentDispositionFilename(header: string | null): string | null {
if (!header) return null;
const star = /filename\*=UTF-8''([^;\n]+)/i.exec(header);
if (star?.[1]) {
try {
return decodeURIComponent(star[1].trim());
} catch {
/* fall through */
}
}
const quoted = /filename="([^"]+)"/i.exec(header);
if (quoted?.[1]) return quoted[1];
const bare = /filename=([^;\n]+)/i.exec(header);
if (bare?.[1]) return bare[1].trim().replace(/^"|"$/g, '');
return null;
}

View File

@ -82,6 +82,7 @@ export {
} from './campaigns'; } from './campaigns';
export { subscriptionApi, loadRazorpayScript } from './subscription'; export { subscriptionApi, loadRazorpayScript } from './subscription';
export { platformAdminApi } from './platformAdmin'; export { platformAdminApi } from './platformAdmin';
export { staticMicrositeApi, staticMicrositePublicUrl } from './staticMicrosite';
export { adminRequestsApi } from './adminRequests'; export { adminRequestsApi } from './adminRequests';
export { paymentApi } from './payment'; export { paymentApi } from './payment';
export * from './types'; export * from './types';

View File

@ -38,6 +38,8 @@ export type PublicFormField = {
type?: string; type?: string;
required?: boolean; required?: boolean;
options?: string[]; options?: string[];
allowOther?: boolean;
otherLabel?: string;
placeholder?: string; placeholder?: string;
mapsTo?: string; mapsTo?: string;
}; };

View File

@ -0,0 +1,110 @@
import { api, fetchWithAuth } from './client';
import type {
EventStaticMicrosite,
MicrositeV2Template,
StaticMicrositeConfig,
SubdomainAvailability,
} from './types';
export const staticMicrositeApi = {
listTemplates() {
return api.get<MicrositeV2Template[]>('/microsite-v2-templates');
},
listAdminTemplates() {
return api.get<MicrositeV2Template[]>('/microsite-v2-templates/admin');
},
getTemplate(id: string) {
return api.get<MicrositeV2Template>(`/microsite-v2-templates/${id}`);
},
createTemplate(payload: { slug: string; name: string; description?: string; version?: number }) {
return api.post<MicrositeV2Template>('/microsite-v2-templates', payload);
},
updateTemplate(
id: string,
payload: Partial<{ name: string; description: string; isActive: boolean; version: number }>,
) {
return api.put<MicrositeV2Template>(`/microsite-v2-templates/${id}`, payload);
},
deleteTemplate(id: string) {
return api.delete<null>(`/microsite-v2-templates/${id}`);
},
async uploadTemplateFiles(
id: string,
files: {
templateHtml?: File;
templateJson?: File;
thumbnail?: File;
assetsZip?: File;
},
) {
const formData = new FormData();
if (files.templateHtml) formData.append('templateHtml', files.templateHtml);
if (files.templateJson) formData.append('templateJson', files.templateJson);
if (files.thumbnail) formData.append('thumbnail', files.thumbnail);
if (files.assetsZip) formData.append('assetsZip', files.assetsZip);
const res = await fetchWithAuth(`/microsite-v2-templates/${id}/upload`, {
method: 'POST',
body: formData,
});
const data = await res.json();
if (data.success && data.data) return { data: data.data as MicrositeV2Template, error: undefined, status: res.status };
return { data: undefined, error: data.error?.message || 'Upload failed', status: res.status };
},
getForEvent(eventId: string) {
return api.get<EventStaticMicrosite>(`/events/${eventId}/static-microsite`);
},
upsertForEvent(
eventId: string,
payload: { subdomain: string; templateId: string; config: StaticMicrositeConfig },
) {
return api.put<EventStaticMicrosite>(`/events/${eventId}/static-microsite`, payload);
},
checkSubdomain(eventId: string, subdomain: string) {
const q = new URLSearchParams({ subdomain });
return api.get<SubdomainAvailability>(`/events/${eventId}/static-microsite/check-subdomain?${q}`);
},
publish(eventId: string) {
return api.post<EventStaticMicrosite & { publicUrl?: string }>(
`/events/${eventId}/static-microsite/publish`,
{},
);
},
async fetchPreviewHtml(path: string): Promise<string> {
const res = await fetchWithAuth(path, { method: 'GET', skipGlobalLoading: true });
if (!res.ok) {
let msg = 'Preview failed';
try {
const j = await res.json();
msg = j.error?.message || msg;
} catch {
/* html error body */
}
throw new Error(msg);
}
return res.text();
},
previewEventHtml(eventId: string) {
return staticMicrositeApi.fetchPreviewHtml(`/events/${eventId}/static-microsite/preview-html`);
},
previewTemplateHtml(templateId: string) {
return staticMicrositeApi.fetchPreviewHtml(`/microsite-v2-templates/${templateId}/preview-html`);
},
};
export function staticMicrositePublicUrl(subdomain: string): string {
return `https://${subdomain}.eventbuzz.live`;
}

View File

@ -495,10 +495,19 @@ export interface WhatsAppTemplateRecord {
export interface FormField { export interface FormField {
label?: string; label?: string;
/** Unique key for submission data; auto-suffixed when labels duplicate. */
name?: string;
type?: string; type?: string;
required?: boolean; required?: boolean;
options?: string[]; options?: string[];
/** Dropdown only: include an Other option; submitted value is the custom text. */
allowOther?: boolean;
otherLabel?: string;
placeholder?: string; placeholder?: string;
/** Shown below the field on public forms in small text. */
remarks?: string;
/** Preserved builder label for forms that share the same API type (e.g. Text vs Short Text). */
uiType?: string;
mapsTo?: string; mapsTo?: string;
customMapsKey?: string; customMapsKey?: string;
isFixed?: boolean; isFixed?: boolean;
@ -745,3 +754,81 @@ export interface PublicAttendanceSession {
status: string; status: string;
event: PublicAttendanceEventSummary; event: PublicAttendanceEventSummary;
} }
// --- Microsite V2 (static Handlebars microsites) ---
export type MicrositeV2FieldType =
| 'text'
| 'textarea'
| 'richtext'
| 'url'
| 'color'
| 'boolean'
| 'image'
| 'select'
| 'array';
export interface MicrositeV2FieldSchema {
key: string;
type: MicrositeV2FieldType;
label?: string;
default?: unknown;
options?: { value: string; label: string }[];
itemFields?: MicrositeV2FieldSchema[];
}
export interface MicrositeV2SectionSchema {
id: string;
label: string;
show?: boolean;
fields: MicrositeV2FieldSchema[];
}
export interface MicrositeV2TemplateSchema {
version: number;
meta?: Record<string, unknown>;
theme?: { fields?: MicrositeV2FieldSchema[] };
sections: MicrositeV2SectionSchema[];
}
export interface MicrositeV2Template {
_id: string;
slug: string;
name: string;
description?: string;
thumbnailUrl?: string;
htmlS3Key?: string;
schemaS3Key?: string;
schema?: MicrositeV2TemplateSchema;
assetsBaseUrl?: string;
isActive: boolean;
version: number;
createdAt?: string;
updatedAt?: string;
}
export interface StaticMicrositeConfig {
meta?: Record<string, unknown>;
theme?: Record<string, unknown>;
sections?: Record<string, Record<string, unknown>>;
}
export interface EventStaticMicrosite {
_id: string;
event: string;
organization: string;
subdomain: string;
template: MicrositeV2Template | string;
config: StaticMicrositeConfig;
status: 'draft' | 'published';
publishedHtmlUrl?: string;
publishedAt?: string;
publicUrl?: string;
createdAt?: string;
updatedAt?: string;
}
export interface SubdomainAvailability {
available: boolean;
message?: string;
}

View File

@ -10,6 +10,7 @@ import {
type TicketQuote, type TicketQuote,
} from "../services/api/publicEventTickets"; } from "../services/api/publicEventTickets";
import { buildUpiPayUri } from "../utils/upiPayUri"; import { buildUpiPayUri } from "../utils/upiPayUri";
import { SelectWithOtherInput } from "../app/components/forms/SelectWithOtherInput";
/** ISO 3166-2:IN style codes for billing / tax */ /** ISO 3166-2:IN style codes for billing / tax */
const INDIAN_STATES = [ const INDIAN_STATES = [
@ -997,18 +998,32 @@ export function EventTicketCheckoutView() {
{f.required ? " *" : ""} {f.required ? " *" : ""}
</label> </label>
{f.type === "select" || f.type === "radio" ? ( {f.type === "select" || f.type === "radio" ? (
<select f.type === "select" && f.allowOther ? (
value={a.formData[f.label!] || ""} <div style={{ marginTop: 4 }}>
onChange={(e) => updateFormData(i, f.label!, e.target.value)} <SelectWithOtherInput
style={{ ...inputStyle, marginTop: 4 }} value={a.formData[f.label!] || ""}
> onChange={(v) => updateFormData(i, f.label!, v)}
<option value="">Choose…</option> options={f.options || []}
{(f.options || []).map((o) => ( allowOther
<option key={o} value={o}> otherLabel={f.otherLabel}
{o} required={!!f.required}
</option> inputStyle={{ ...inputStyle, marginBottom: 0 }}
))} />
</select> </div>
) : (
<select
value={a.formData[f.label!] || ""}
onChange={(e) => updateFormData(i, f.label!, e.target.value)}
style={{ ...inputStyle, marginTop: 4 }}
>
<option value="">Choose…</option>
{(f.options || []).map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
)
) : ( ) : (
<input <input
type={f.type === "email" ? "email" : f.type === "phone" ? "tel" : "text"} type={f.type === "email" ? "email" : f.type === "phone" ? "tel" : "text"}

View File

@ -2,6 +2,10 @@ import React, { useCallback, useEffect, useState } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { formsApi } from "../services/api"; import { formsApi } from "../services/api";
import type { FormField, PublicFormDefinition, PublicFormEventSummary } from "../services/api"; import type { FormField, PublicFormDefinition, PublicFormEventSummary } from "../services/api";
import { assignUniqueFieldNames, fieldDataKey, fieldDisplayLabel, countryStateKeys, isCountryStateField } from "../app/utils/formFieldNames";
import { SelectWithOtherInput } from "../app/components/forms/SelectWithOtherInput";
import { ensureRegistrationCoreFields } from "../app/utils/registrationFormFields";
import { COUNTRY_NAMES, DEFAULT_COUNTRY, statesForCountry } from "../app/data/countryStates";
const EVENT_TYPE_LABEL: Record<string, string> = { const EVENT_TYPE_LABEL: Record<string, string> = {
conference: "Conference", conference: "Conference",
@ -174,16 +178,18 @@ function PublicFormEventHeader({
); );
} }
function dataKey(label?: string) {
return (label || "").trim();
}
function fieldValueToData( function fieldValueToData(
field: FormField, field: FormField,
value: unknown values: Record<string, unknown>
): Record<string, unknown> { ): Record<string, unknown> {
const k = dataKey(field.label); if (field.type === "section") return {};
if (isCountryStateField(field)) {
const { country, state } = countryStateKeys(field);
return { [country]: values[country] ?? "", [state]: values[state] ?? "" };
}
const k = fieldDataKey(field);
if (!k) return {}; if (!k) return {};
const value = values[k];
if (field.type === "checkboxes" && Array.isArray(value)) { if (field.type === "checkboxes" && Array.isArray(value)) {
return { [k]: value }; return { [k]: value };
} }
@ -209,6 +215,16 @@ function starRatingMax(field: FormField): number {
return 5; return 5;
} }
function FieldRemarks({ text }: { text?: string }) {
const trimmed = text?.trim();
if (!trimmed) return null;
return (
<p style={{ margin: "6px 0 0", fontSize: "0.75rem", lineHeight: 1.45, color: "var(--muted, #64748b)" }}>
{trimmed}
</p>
);
}
export function PublicFormView() { export function PublicFormView() {
const { formId = "" } = useParams(); const { formId = "" } = useParams();
const [form, setForm] = useState<PublicFormDefinition | null>(null); const [form, setForm] = useState<PublicFormDefinition | null>(null);
@ -224,7 +240,8 @@ export function PublicFormView() {
setLoadError(null); setLoadError(null);
try { try {
const f = await formsApi.getPublic(formId); const f = await formsApi.getPublic(formId);
setForm(f); const rawFields = f.type === "registration" ? ensureRegistrationCoreFields(f.fields || []) : f.fields || [];
setForm({ ...f, fields: assignUniqueFieldNames(rawFields) });
} catch (e) { } catch (e) {
setLoadError(e instanceof Error ? e.message : "Could not load form."); setLoadError(e instanceof Error ? e.message : "Could not load form.");
} }
@ -241,21 +258,42 @@ export function PublicFormView() {
const validate = useCallback((): string | null => { const validate = useCallback((): string | null => {
if (!form?.fields?.length) return null; if (!form?.fields?.length) return null;
for (const field of form.fields) { for (const field of form.fields) {
const k = dataKey(field.label); if (field.type === "section") continue;
if (!k || !field.required) continue; const display = fieldDisplayLabel(field);
if (!field.required) continue;
if (isCountryStateField(field)) {
const { country, state } = countryStateKeys(field);
const countryVal = values[country];
const stateVal = values[state];
if (countryVal == null || (typeof countryVal === "string" && countryVal.trim() === "")) {
return `“${display}” country is required.`;
}
const stateOptions = statesForCountry(String(countryVal));
if (
stateOptions.length > 0 &&
(stateVal == null || (typeof stateVal === "string" && stateVal.trim() === ""))
) {
return `“${display}” state is required.`;
}
continue;
}
const k = fieldDataKey(field);
if (!k) continue;
const t = field.type; const t = field.type;
if (t === "checkboxes") { if (t === "checkboxes") {
const arr = values[k]; const arr = values[k];
if (!Array.isArray(arr) || arr.length === 0) return `“${k}” is required.`; if (!Array.isArray(arr) || arr.length === 0) return `“${display}” is required.`;
continue; continue;
} }
if (t === "checkbox") { if (t === "checkbox") {
if (!values[k]) return `“${k}” is required.`; if (!values[k]) return `“${display}” is required.`;
continue; continue;
} }
const v = values[k]; const v = values[k];
if (v == null || (typeof v === "string" && v.trim() === "")) { if (v == null || (typeof v === "string" && v.trim() === "")) {
return `“${k}” is required.`; return `“${display}” is required.`;
} }
} }
return null; return null;
@ -272,9 +310,8 @@ export function PublicFormView() {
} }
const data: Record<string, unknown> = {}; const data: Record<string, unknown> = {};
for (const field of form.fields || []) { for (const field of form.fields || []) {
const k = dataKey(field.label); if (field.type === "section") continue;
if (!k) continue; Object.assign(data, fieldValueToData(field, values));
Object.assign(data, fieldValueToData(field, values[k]));
} }
setSubmitting(true); setSubmitting(true);
try { try {
@ -433,15 +470,39 @@ export function PublicFormView() {
boxShadow: "0 1px 3px rgba(0,0,0,0.06)", boxShadow: "0 1px 3px rgba(0,0,0,0.06)",
}} }}
> >
{(form.fields || []).map((field) => { {(form.fields || []).map((field, index) => {
const k = dataKey(field.label); if (field.type === "section") {
const title = (field.label || "").trim();
if (!title) return null;
return (
<div key={`section-${index}`} role="separator" aria-label={title} style={{ margin: "4px 0 0" }}>
{index > 0 ? (
<hr style={{ border: "none", borderTop: "1px solid var(--border, #e2e8f0)", margin: "0 0 12px" }} />
) : null}
<h3
style={{
margin: 0,
padding: 0,
fontSize: "1rem",
fontWeight: 600,
color: "#0f3072",
lineHeight: 1.35,
}}
>
{title}
</h3>
</div>
);
}
const k = fieldDataKey(field);
const display = fieldDisplayLabel(field);
if (!k) return null; if (!k) return null;
const req = field.required; const req = field.required;
if (field.type === "textarea") { if (field.type === "textarea") {
return ( return (
<div key={k}> <div key={`field-${index}-${k}`}>
<label style={labelStyle}> <label style={labelStyle}>
{k} {display}
{req ? <span style={{ color: "var(--destructive, #dc2626)" }}> *</span> : null} {req ? <span style={{ color: "var(--destructive, #dc2626)" }}> *</span> : null}
</label> </label>
<textarea <textarea
@ -452,14 +513,15 @@ export function PublicFormView() {
rows={4} rows={4}
style={{ ...inputStyle, resize: "vertical", minHeight: 96 }} style={{ ...inputStyle, resize: "vertical", minHeight: 96 }}
/> />
<FieldRemarks text={field.remarks} />
</div> </div>
); );
} }
if (field.type === "text" || field.type === "email" || field.type === "phone") { if (field.type === "text" || field.type === "email" || field.type === "phone") {
return ( return (
<div key={k}> <div key={`field-${index}-${k}`}>
<label style={labelStyle}> <label style={labelStyle}>
{k} {display}
{req ? <span style={{ color: "var(--destructive, #dc2626)" }}> *</span> : null} {req ? <span style={{ color: "var(--destructive, #dc2626)" }}> *</span> : null}
</label> </label>
<input <input
@ -470,6 +532,43 @@ export function PublicFormView() {
required={!!req} required={!!req}
style={inputStyle} style={inputStyle}
/> />
<FieldRemarks text={field.remarks} />
</div>
);
}
if (field.type === "date") {
return (
<div key={`field-${index}-${k}`}>
<label style={labelStyle}>
{display}
{req ? <span style={{ color: "var(--destructive, #dc2626)" }}> *</span> : null}
</label>
<input
type="date"
value={String(values[k] ?? "")}
onChange={(e) => setField(k, e.target.value)}
required={!!req}
style={inputStyle}
/>
<FieldRemarks text={field.remarks} />
</div>
);
}
if (field.type === "time") {
return (
<div key={`field-${index}-${k}`}>
<label style={labelStyle}>
{display}
{req ? <span style={{ color: "var(--destructive, #dc2626)" }}> *</span> : null}
</label>
<input
type="time"
value={String(values[k] ?? "")}
onChange={(e) => setField(k, e.target.value)}
required={!!req}
style={inputStyle}
/>
<FieldRemarks text={field.remarks} />
</div> </div>
); );
} }
@ -479,9 +578,9 @@ export function PublicFormView() {
const v = raw != null && raw !== "" ? parseInt(String(raw), 10) : 0; const v = raw != null && raw !== "" ? parseInt(String(raw), 10) : 0;
const selected = !Number.isNaN(v) && v >= 1 && v <= max ? v : 0; const selected = !Number.isNaN(v) && v >= 1 && v <= max ? v : 0;
return ( return (
<div key={k}> <div key={`field-${index}-${k}`}>
<div style={labelStyle} id={`star-legend-${k}`}> <div style={labelStyle} id={`star-legend-${k}`}>
{k} {display}
{req ? <span style={{ color: "var(--destructive, #dc2626)" }}> *</span> : null} {req ? <span style={{ color: "var(--destructive, #dc2626)" }}> *</span> : null}
</div> </div>
<div <div
@ -518,37 +617,35 @@ export function PublicFormView() {
); );
})} })}
</div> </div>
<FieldRemarks text={field.remarks} />
</div> </div>
); );
} }
if (field.type === "select" && (field.options?.length ?? 0) > 0) { if (field.type === "select" && (field.options?.length ?? 0) > 0) {
return ( return (
<div key={k}> <div key={`field-${index}-${k}`}>
<label style={labelStyle}> <label style={labelStyle}>
{k} {display}
{req ? <span style={{ color: "var(--destructive, #dc2626)" }}> *</span> : null} {req ? <span style={{ color: "var(--destructive, #dc2626)" }}> *</span> : null}
</label> </label>
<select <SelectWithOtherInput
value={String(values[k] ?? "")} value={values[k]}
onChange={(e) => setField(k, e.target.value)} onChange={(v) => setField(k, v)}
options={field.options!}
allowOther={field.allowOther}
otherLabel={field.otherLabel}
required={!!req} required={!!req}
style={{ ...inputStyle, cursor: "pointer" }} inputStyle={inputStyle}
> />
<option value="">Select…</option> <FieldRemarks text={field.remarks} />
{field.options!.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
</div> </div>
); );
} }
if (field.type === "radio" && (field.options?.length ?? 0) > 0) { if (field.type === "radio" && (field.options?.length ?? 0) > 0) {
return ( return (
<fieldset key={k} style={{ border: "none", padding: 0, margin: 0 }}> <fieldset key={`field-${index}-${k}`} style={{ border: "none", padding: 0, margin: 0 }}>
<legend style={{ ...labelStyle, marginBottom: 8 }}> <legend style={{ ...labelStyle, marginBottom: 8 }}>
{k} {display}
{req ? <span style={{ color: "var(--destructive, #dc2626)" }}> *</span> : null} {req ? <span style={{ color: "var(--destructive, #dc2626)" }}> *</span> : null}
</legend> </legend>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}> <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
@ -568,15 +665,16 @@ export function PublicFormView() {
</label> </label>
))} ))}
</div> </div>
<FieldRemarks text={field.remarks} />
</fieldset> </fieldset>
); );
} }
if (field.type === "checkboxes" && (field.options?.length ?? 0) > 0) { if (field.type === "checkboxes" && (field.options?.length ?? 0) > 0) {
const cur = (values[k] as string[]) || []; const cur = (values[k] as string[]) || [];
return ( return (
<fieldset key={k} style={{ border: "none", padding: 0, margin: 0 }}> <fieldset key={`field-${index}-${k}`} style={{ border: "none", padding: 0, margin: 0 }}>
<legend style={{ ...labelStyle, marginBottom: 8 }}> <legend style={{ ...labelStyle, marginBottom: 8 }}>
{k} {display}
{req ? <span style={{ color: "var(--destructive, #dc2626)" }}> *</span> : null} {req ? <span style={{ color: "var(--destructive, #dc2626)" }}> *</span> : null}
</legend> </legend>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}> <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
@ -602,27 +700,91 @@ export function PublicFormView() {
); );
})} })}
</div> </div>
<FieldRemarks text={field.remarks} />
</fieldset> </fieldset>
); );
} }
if (isCountryStateField(field)) {
const { country: countryKey, state: stateKey } = countryStateKeys(field);
const countryVal = String(values[countryKey] ?? "") || DEFAULT_COUNTRY;
const stateVal = String(values[stateKey] ?? "");
const stateOptions = statesForCountry(countryVal);
return (
<div key={`field-${index}-${k}`}>
<div style={labelStyle}>
{display}
{req ? <span style={{ color: "var(--destructive, #dc2626)" }}> *</span> : null}
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<div>
<label style={{ ...labelStyle, textTransform: "none", letterSpacing: 0, fontSize: "0.8125rem" }}>
Country
{req ? <span style={{ color: "var(--destructive, #dc2626)" }}> *</span> : null}
</label>
<select
value={countryVal}
onChange={(e) => {
const next = e.target.value;
setField(countryKey, next);
const nextStates = statesForCountry(next);
if (stateVal && !nextStates.includes(stateVal)) setField(stateKey, "");
}}
required={!!req}
style={{ ...inputStyle, cursor: "pointer" }}
>
{COUNTRY_NAMES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</div>
<div>
<label style={{ ...labelStyle, textTransform: "none", letterSpacing: 0, fontSize: "0.8125rem" }}>
State
{req ? <span style={{ color: "var(--destructive, #dc2626)" }}> *</span> : null}
</label>
<select
value={stateVal}
onChange={(e) => setField(stateKey, e.target.value)}
required={!!req && stateOptions.length > 0}
disabled={stateOptions.length === 0}
style={{ ...inputStyle, cursor: stateOptions.length ? "pointer" : "not-allowed" }}
>
<option value="">{stateOptions.length ? "Select state…" : "No states available"}</option>
{stateOptions.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
</div>
<FieldRemarks text={field.remarks} />
</div>
);
}
if (field.type === "checkbox") { if (field.type === "checkbox") {
return ( return (
<label key={k} style={{ display: "flex", alignItems: "center", gap: 10, fontSize: "0.9rem", cursor: "pointer" }}> <div key={`field-${index}-${k}`}>
<input <label style={{ display: "flex", alignItems: "center", gap: 10, fontSize: "0.9rem", cursor: "pointer" }}>
type="checkbox" <input
checked={!!values[k]} type="checkbox"
onChange={(e) => setField(k, e.target.checked)} checked={!!values[k]}
/> onChange={(e) => setField(k, e.target.checked)}
{k} />
{req ? <span style={{ color: "var(--destructive, #dc2626)" }}> *</span> : null} {display}
</label> {req ? <span style={{ color: "var(--destructive, #dc2626)" }}> *</span> : null}
</label>
<FieldRemarks text={field.remarks} />
</div>
); );
} }
if (field.type === "file") { if (field.type === "file") {
return ( return (
<div key={k}> <div key={`field-${index}-${k}`}>
<span style={labelStyle}> <span style={labelStyle}>
{k} {display}
{req ? <span style={{ color: "var(--destructive, #dc2626)" }}> *</span> : null} {req ? <span style={{ color: "var(--destructive, #dc2626)" }}> *</span> : null}
</span> </span>
<input <input
@ -645,18 +807,20 @@ export function PublicFormView() {
{fileNames[k] ? ( {fileNames[k] ? (
<p style={{ margin: "6px 0 0", fontSize: "0.8rem", color: "var(--muted)" }}>{fileNames[k]}</p> <p style={{ margin: "6px 0 0", fontSize: "0.8rem", color: "var(--muted)" }}>{fileNames[k]}</p>
) : null} ) : null}
<FieldRemarks text={field.remarks} />
</div> </div>
); );
} }
return ( return (
<div key={k}> <div key={`field-${index}-${k}`}>
<label style={labelStyle}>{k}</label> <label style={labelStyle}>{display}</label>
<input <input
type="text" type="text"
value={String(values[k] ?? "")} value={String(values[k] ?? "")}
onChange={(e) => setField(k, e.target.value)} onChange={(e) => setField(k, e.target.value)}
style={inputStyle} style={inputStyle}
/> />
<FieldRemarks text={field.remarks} />
</div> </div>
); );
})} })}