8 july
This commit is contained in:
parent
980e0d9156
commit
17b3592df4
File diff suppressed because one or more lines are too long
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 "Other" 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 "Other"." />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 ?? '');
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 || {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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] ?? [];
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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`;
|
||||||
|
}
|
||||||
|
|
@ -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` };
|
||||||
|
}
|
||||||
|
|
@ -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}`;
|
||||||
|
}
|
||||||
|
|
@ -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];
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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`;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue