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"
|
||||
/>
|
||||
<script src="https://accounts.google.com/gsi/client" async defer></script>
|
||||
<script type="module" crossorigin src="/assets/index-DBI6VQnt.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B2aqbk3U.css">
|
||||
<script type="module" crossorigin src="/assets/index-D03jbJi-.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-YPkmj8ow.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { ProtectedLayout } from "./views/ProtectedLayout";
|
|||
import { PlatformAdminLayout } from "./views/PlatformAdminLayout";
|
||||
import { SuperAdminClientsPage } from "./views/SuperAdminClientsPage";
|
||||
import { SuperAdminPlansPage } from "./views/SuperAdminPlansPage";
|
||||
import { SuperAdminMicrositeV2TemplatesPage } from "./views/SuperAdminMicrositeV2TemplatesPage";
|
||||
import { EventTicketCheckoutView } from "../views/EventTicketCheckoutView";
|
||||
import { PublicFormView } from "../views/PublicFormView";
|
||||
import { PublicAttendanceCheckInView } from "../views/PublicAttendanceCheckInView";
|
||||
|
|
@ -33,6 +34,7 @@ export default function App() {
|
|||
<Route index element={<Navigate to="clients" replace />} />
|
||||
<Route path="clients" element={<SuperAdminClientsPage />} />
|
||||
<Route path="plans" element={<SuperAdminPlansPage />} />
|
||||
<Route path="microsite-templates" element={<SuperAdminMicrositeV2TemplatesPage />} />
|
||||
</Route>
|
||||
<Route path=":navId" element={<ProtectedLayout.AppShellWithNav />} />
|
||||
</Route>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 { useAppNav } from "../../contexts/AppNavContext";
|
||||
import { eventsApi } from "../../../services/api/events";
|
||||
|
|
@ -8,6 +8,7 @@ import { quizPollsApi } from "../../../services/api/quizPolls";
|
|||
import { badgeBatchesApi } from "../../../services/api/badgeBatches";
|
||||
import { eventTicketsApi } from "../../../services/api/eventTickets";
|
||||
import { countConfiguredRewardRules, RewardRulesSettingsPanel } from "../rewards/rewardRulesSettings";
|
||||
import { ManageStaticMicrositePanel } from "../microsite-v2/ManageStaticMicrositePanel";
|
||||
import { CreateFormPanel } from "../panels/CreateFormPanel";
|
||||
import { FormSubmissionsPanel } from "../panels/FormSubmissionsPanel";
|
||||
import { CreateBadgePanel } from "../panels/CreateBadgePanel";
|
||||
|
|
@ -16,20 +17,23 @@ import { AddQuizPollModal, buildQuizPollSettingsPayload } from "../quiz/AddQuizP
|
|||
import type { QuizPollModalForm } from "../quiz/AddQuizPollModal";
|
||||
import type { Question } from "../quiz/AddQuestionModal";
|
||||
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 { publicFormPageUrl } from "../../utils/publicFormUrl";
|
||||
|
||||
function ModuleCard({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
meta,
|
||||
headerAction,
|
||||
actions,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
description?: string;
|
||||
meta?: string;
|
||||
headerAction?: React.ReactNode;
|
||||
actions: React.ReactNode;
|
||||
}) {
|
||||
const bp = useBreakpoint();
|
||||
|
|
@ -65,18 +69,23 @@ function ModuleCard({
|
|||
{icon}
|
||||
</div>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<p
|
||||
style={{
|
||||
fontFamily: "var(--font-inter)",
|
||||
fontSize: "var(--text-base)",
|
||||
fontWeight: "var(--font-weight-medium)",
|
||||
color: "var(--foreground)",
|
||||
margin: 0,
|
||||
lineHeight: 1.3,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
<div style={{ display: "flex", alignItems: "flex-start", gap: "6px" }}>
|
||||
<p
|
||||
style={{
|
||||
fontFamily: "var(--font-inter)",
|
||||
fontSize: "var(--text-base)",
|
||||
fontWeight: "var(--font-weight-medium)",
|
||||
color: "var(--foreground)",
|
||||
margin: 0,
|
||||
lineHeight: 1.3,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
{headerAction}
|
||||
</div>
|
||||
{description ? (
|
||||
<p
|
||||
style={{
|
||||
|
|
@ -148,6 +157,22 @@ function formMetaLine(form: FormWithSubmissionCount): string {
|
|||
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() {
|
||||
const { selectedEventId, setActiveNav, setPermissionsOpenAddOnce, setOpenAgendaSessionPanelOnce } = useAppNav();
|
||||
const bp = useBreakpoint();
|
||||
|
|
@ -162,6 +187,7 @@ export function EventHomeModules() {
|
|||
const [formEditTarget, setFormEditTarget] = useState<FormWithSubmissionCount | null>(null);
|
||||
const [formResponsesTarget, setFormResponsesTarget] = useState<FormWithSubmissionCount | 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 [pollQuizLoading, setPollQuizLoading] = useState(false);
|
||||
const [showAddPoll, setShowAddPoll] = useState(false);
|
||||
|
|
@ -179,6 +205,9 @@ export function EventHomeModules() {
|
|||
const [rewardRulesCount, setRewardRulesCount] = useState(0);
|
||||
const [rewardLoading, setRewardLoading] = 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 [agendaLoading, setAgendaLoading] = useState(false);
|
||||
|
||||
|
|
@ -333,6 +362,32 @@ export function EventHomeModules() {
|
|||
void 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 () => {
|
||||
if (!selectedEventId) {
|
||||
setAgendaCounts({ tracks: 0, sessions: 0 });
|
||||
|
|
@ -386,6 +441,19 @@ export function EventHomeModules() {
|
|||
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 pollQuizMeta = !selectedEventId
|
||||
? "Select an event to create polls and quizzes"
|
||||
|
|
@ -414,6 +482,11 @@ export function EventHomeModules() {
|
|||
: rewardRulesCount === 0
|
||||
? "No rules configured"
|
||||
: `${rewardRulesCount} rule${rewardRulesCount === 1 ? "" : "s"} configured`;
|
||||
const micrositeMeta = !selectedEventId
|
||||
? "Select an event to manage microsite"
|
||||
: staticMicrositeLoading
|
||||
? "Loading…"
|
||||
: staticMicrositeMeta || "Not configured";
|
||||
const agendaMeta = !selectedEventId
|
||||
? "Select an event to manage the agenda"
|
||||
: agendaLoading
|
||||
|
|
@ -499,12 +572,39 @@ export function EventHomeModules() {
|
|||
</>
|
||||
}
|
||||
/>
|
||||
{eventForms.map((form) => (
|
||||
{eventForms.map((form) => {
|
||||
const linkCopied = copiedFormLinkId === form._id;
|
||||
return (
|
||||
<ModuleCard
|
||||
key={form._id}
|
||||
icon={<FileText size={18} />}
|
||||
title={form.name?.trim() || "Untitled 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={
|
||||
<>
|
||||
<button
|
||||
|
|
@ -544,7 +644,8 @@ export function EventHomeModules() {
|
|||
</>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
<ModuleCard
|
||||
icon={<ShieldCheck size={18} />}
|
||||
title="Users & Permissions"
|
||||
|
|
@ -696,6 +797,25 @@ export function EventHomeModules() {
|
|||
</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
|
||||
icon={<Trophy size={18} />}
|
||||
title="Reward Points Config"
|
||||
|
|
@ -860,6 +980,14 @@ export function EventHomeModules() {
|
|||
onSaved={() => void loadRewardRulesMeta()}
|
||||
/>
|
||||
) : 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 { eventsApi, formsApi, participantListsApi, portfolioListOrganizationId } 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";
|
||||
|
||||
|
|
@ -17,14 +19,122 @@ function eventIdFromFormRef(ev: Form["event"] | undefined): string {
|
|||
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 REG_FIELD_TYPES = ["Text", "Email", "Phone", "Dropdown", "Radio", "Checkboxes", "Yes / No", "Date", "File Upload"];
|
||||
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", "Time", "File Upload", "Country State", "Section"];
|
||||
/** 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 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). */
|
||||
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"]> = {
|
||||
Text: "text",
|
||||
Email: "email",
|
||||
|
|
@ -33,8 +143,10 @@ const REG_TYPE_TO_API: Record<string, FormField["type"]> = {
|
|||
Radio: "radio",
|
||||
Checkboxes: "checkboxes",
|
||||
"Yes / No": "checkbox",
|
||||
Date: "text",
|
||||
Date: "date",
|
||||
Time: "time",
|
||||
"File Upload": "file",
|
||||
"Country State": "country_state",
|
||||
};
|
||||
|
||||
const REG_EXTRA_MAP_OPTIONS: { value: string; label: string }[] = [
|
||||
|
|
@ -50,13 +162,20 @@ const REG_EXTRA_MAP_OPTIONS: { value: string; label: string }[] = [
|
|||
interface Question {
|
||||
id: string;
|
||||
label: string;
|
||||
/** Unique submission key (derived from label; not shown to respondents). */
|
||||
name?: string;
|
||||
type: string;
|
||||
required: "Yes" | "No";
|
||||
/** Choice labels for Multiple Choice, Dropdown, Radio, Checkboxes. */
|
||||
options?: string[];
|
||||
/** Dropdown only: include an Other option with a custom label. */
|
||||
allowOther?: boolean;
|
||||
otherLabel?: string;
|
||||
mapsTo?: string;
|
||||
customMapsKey?: string;
|
||||
fixed?: boolean;
|
||||
/** Optional note shown below the field on the public form. */
|
||||
remarks?: string;
|
||||
}
|
||||
|
||||
function normalizedOptions(q: Question): string[] {
|
||||
|
|
@ -72,6 +191,23 @@ function defaultChoiceOptions(): string[] {
|
|||
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 }) {
|
||||
const rows = options.length ? options : ["", ""];
|
||||
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;
|
||||
types: string[];
|
||||
onPatch: (patch: Partial<Question>) => void;
|
||||
onRemove: () => void;
|
||||
idx: number;
|
||||
canRemove: boolean;
|
||||
dragIndex: number;
|
||||
onReorder: (from: number, to: number) => void;
|
||||
onLabelCommit: () => void;
|
||||
}) {
|
||||
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 (
|
||||
<div style={{ display: "flex", gap: "8px", alignItems: "flex-start", padding: "10px 12px", background: "var(--secondary)", borderRadius: "var(--radius-button)", border: "1px solid var(--border)" }}>
|
||||
<div style={{ color: "var(--muted)", paddingTop: "6px", flexShrink: 0 }}><GripVertical size={14} /></div>
|
||||
<DraggableRowShell index={dragIndex} onReorder={onReorder} style={rowStyle}>
|
||||
<DragGrip index={dragIndex} />
|
||||
<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" }}>
|
||||
<input style={IS} placeholder={`Question ${idx + 1}`} value={q.label}
|
||||
onChange={e => onPatch({ label: e.target.value })} onFocus={focusIn} onBlur={focusOut} />
|
||||
<input style={IS} placeholder={isSection ? "Section title" : `Question ${idx + 1}`} value={q.label}
|
||||
onChange={e => onPatch({ label: e.target.value })} onFocus={focusIn} onBlur={() => { focusOut(); onLabelCommit(); }} />
|
||||
<div style={{ position: "relative" }}>
|
||||
<select style={{ ...SS, width: "auto", minWidth: "130px" }} value={q.type} onChange={(e) => {
|
||||
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}>
|
||||
<select style={{ ...SS, width: "auto", minWidth: "130px" }} value={q.type} onChange={(e) => onPatch(patchForFieldTypeChange(e.target.value, q))} onFocus={focusIn} onBlur={focusOut}>
|
||||
{types.map(t => <option key={t}>{t}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
|
@ -152,6 +295,14 @@ function QuestionRow({ q, types, onPatch, onRemove, idx, canRemove }: {
|
|||
{showChoices && (
|
||||
<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" }}>
|
||||
<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" })}
|
||||
|
|
@ -159,56 +310,70 @@ function QuestionRow({ q, types, onPatch, onRemove, idx, canRemove }: {
|
|||
Required field
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
{!isSection ? (
|
||||
<FieldRemarksEditor value={q.remarks} onChange={(remarks) => onPatch({ remarks })} />
|
||||
) : null}
|
||||
</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 (
|
||||
<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={{ 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>
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
function RegExtraFieldRow({ q, types, onPatch, onRemove, idx }: {
|
||||
function RegExtraFieldRow({ q, types, onPatch, onRemove, idx, dragIndex, onReorder, onLabelCommit }: {
|
||||
q: Question;
|
||||
types: string[];
|
||||
onPatch: (patch: Partial<Question>) => void;
|
||||
onRemove: () => void;
|
||||
idx: number;
|
||||
dragIndex: number;
|
||||
onReorder: (from: number, to: number) => void;
|
||||
onLabelCommit: () => void;
|
||||
}) {
|
||||
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 (
|
||||
<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={{ 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" }}>
|
||||
{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>
|
||||
<Label text="Field label" />
|
||||
<input style={IS} placeholder={`Field ${idx + 1}`} value={q.label} onChange={e => onPatch({ label: e.target.value })} onFocus={focusIn} onBlur={focusOut} />
|
||||
<Label text={isSection ? "Section title" : "Field label"} />
|
||||
<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>
|
||||
<Label text="Field type" />
|
||||
<div style={{ position: "relative" }}>
|
||||
<select style={SS} value={q.type} onChange={(e) => {
|
||||
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}>
|
||||
<select style={SS} value={q.type} onChange={(e) => onPatch(patchForFieldTypeChange(e.target.value, q))} onFocus={focusIn} onBlur={focusOut}>
|
||||
{types.map(t => <option key={t}>{t}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
|
@ -218,6 +383,17 @@ function RegExtraFieldRow({ q, types, onPatch, onRemove, idx }: {
|
|||
<FieldOptionsEditor options={q.options ?? []} onChange={(opts) => onPatch({ options: opts })} />
|
||||
</Field>
|
||||
)}
|
||||
{q.type === "Dropdown" && (
|
||||
<Field span2>
|
||||
<DropdownOtherConfigEditor
|
||||
allowOther={q.allowOther}
|
||||
otherLabel={q.otherLabel}
|
||||
onChange={(patch) => onPatch(patch)}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
{!isSection ? (
|
||||
<>
|
||||
<Field>
|
||||
<Label text="Map to participant" />
|
||||
<div style={{ position: "relative" }}>
|
||||
|
|
@ -237,13 +413,20 @@ function RegExtraFieldRow({ q, types, onPatch, onRemove, idx }: {
|
|||
<HelperText text="Stored under Participant.customFields[key]." />
|
||||
</Field>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
{!isSection ? (
|
||||
<Field span2>
|
||||
<FieldRemarksEditor value={q.remarks} onChange={(remarks) => onPatch({ remarks })} />
|
||||
</Field>
|
||||
) : null}
|
||||
</div>
|
||||
<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 }}>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DraggableRowShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -293,26 +476,40 @@ const initialForm = (qTypes: string[], isFeedback: boolean): FForm => ({
|
|||
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 === "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 === "radio") return "Radio";
|
||||
if (t === "checkboxes") return "Checkboxes";
|
||||
if (t === "select") {
|
||||
const mcIdx = qTypes?.indexOf("Multiple Choice") ?? -1;
|
||||
const ddIdx = qTypes?.indexOf("Dropdown") ?? -1;
|
||||
if (ddIdx >= 0 && (mcIdx < 0 || ddIdx < mcIdx)) return "Dropdown";
|
||||
if (mcIdx >= 0) return "Multiple Choice";
|
||||
return "Multiple Choice";
|
||||
if (mode === "registration") return "Dropdown";
|
||||
if (mode === "feedback") return "Multiple Choice";
|
||||
return qTypes.includes("Dropdown") ? "Dropdown" : "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 === "phone") return "Phone";
|
||||
if (t === "date") return "Date";
|
||||
if (t === "time") return "Time";
|
||||
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[] {
|
||||
|
|
@ -320,33 +517,94 @@ function optionsFromApi(f: FormField): string[] {
|
|||
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 name = q.name?.trim() || label;
|
||||
const required = q.required === "Yes";
|
||||
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 (q.type === "Star Rating") return { label, type: "stars", required, options: ["1", "2", "3", "4", "5"] };
|
||||
if (q.type === "NPS (0–10)") return { label, type: "select", required, options: Array.from({ length: 11 }, (_, i) => String(i)) };
|
||||
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 === "Checkboxes") return { label, type: "checkboxes", required, options: choiceOpts };
|
||||
if (q.type === "Short Text") return { label, type: "text", required };
|
||||
if (q.type === "Long Text") return { label, type: "textarea", required };
|
||||
if (q.type === "Yes / No") return { label, type: "checkbox", required };
|
||||
if (q.type === "Star Rating") {
|
||||
return withName(withUiType({ label, type: "stars", required, options: ["1", "2", "3", "4", "5"] }, q.type, persistUiType));
|
||||
}
|
||||
if (q.type === "NPS (0–10)") {
|
||||
return withName(withUiType(
|
||||
{ label, type: "select", required, options: Array.from({ length: 11 }, (_, i) => String(i)) },
|
||||
q.type,
|
||||
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];
|
||||
if (regType) {
|
||||
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"];
|
||||
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 {
|
||||
|
|
@ -354,7 +612,7 @@ function isCoreRegFieldMapsTo(mapsTo: string | undefined): boolean {
|
|||
}
|
||||
|
||||
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;
|
||||
return {
|
||||
...base,
|
||||
|
|
@ -370,43 +628,52 @@ function requiredFromApiRegField(f: FormField): "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)];
|
||||
return fields.map((f, i) => {
|
||||
const typ = typeFromApi(f.type ?? "text", f.options, qTypes);
|
||||
const questions = fields.map((f, i) => {
|
||||
const typ = typeFromApi(f.type ?? "text", f.options, qTypes, mode, f.uiType);
|
||||
return {
|
||||
id: Math.random().toString(36).slice(2) + String(i),
|
||||
label: f.label ?? "",
|
||||
name: f.name ?? f.label ?? "",
|
||||
type: typ,
|
||||
required: f.required ? "Yes" : "No",
|
||||
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,
|
||||
customMapsKey: f.customMapsKey,
|
||||
fixed: f.isFixed,
|
||||
remarks: f.remarks ?? "",
|
||||
};
|
||||
});
|
||||
return assignUniqueFieldNames(questions);
|
||||
}
|
||||
|
||||
function fromApiFieldSingle(f: FormField, i: number, qTypes: string[]): Question {
|
||||
const typ = typeFromApi(f.type ?? "text", f.options, qTypes);
|
||||
function fromApiFieldSingle(f: FormField, i: number, qTypes: string[], mode: FormMode): Question {
|
||||
const typ = typeFromApi(f.type ?? "text", f.options, qTypes, mode, f.uiType);
|
||||
return {
|
||||
id: `imp-${i}-${Math.random().toString(36).slice(2)}`,
|
||||
label: f.label ?? "",
|
||||
name: f.name ?? f.label ?? "",
|
||||
type: typ,
|
||||
required: requiredFromApiRegField(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,
|
||||
customMapsKey: f.customMapsKey,
|
||||
fixed: f.isFixed === true,
|
||||
remarks: f.remarks ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function mergeRegFieldsFromApi(fields: FormField[] | undefined, qTypes: string[]): Question[] {
|
||||
const base = initialRegQuestions();
|
||||
if (!fields?.length) return base;
|
||||
const converted = fields.map((f, i) => fromApiFieldSingle(f, i, qTypes));
|
||||
if (!converted.some((q) => q.mapsTo === "firstName")) return [...base, ...converted];
|
||||
return converted;
|
||||
const converted = fields.map((f, i) => fromApiFieldSingle(f, i, qTypes, "registration"));
|
||||
const merged = !converted.some((q) => q.mapsTo === "firstName") ? [...base, ...converted] : converted;
|
||||
return assignUniqueFieldNames(merged);
|
||||
}
|
||||
|
||||
export function CreateFormPanel({
|
||||
|
|
@ -495,10 +762,11 @@ export function CreateFormPanel({
|
|||
try {
|
||||
const data = await formsApi.get(formId);
|
||||
if (cancelled) return;
|
||||
const loadMode: FormMode = isReg ? "registration" : isOther ? "other" : "feedback";
|
||||
const questions =
|
||||
data.type === "registration"
|
||||
? mergeRegFieldsFromApi(data.fields, qTypes)
|
||||
: fromApiFields(data.fields, qTypes);
|
||||
: fromApiFields(data.fields, qTypes, loadMode);
|
||||
const s = data.settings ?? {};
|
||||
const tags = Array.isArray(s.onSubmitAddTags) ? s.onSubmitAddTags.join(", ") : "";
|
||||
setForm({
|
||||
|
|
@ -532,6 +800,22 @@ export function CreateFormPanel({
|
|||
const updateQuestion = (id: string, patch: Partial<Question>) =>
|
||||
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 => {
|
||||
if (n === 1) return f.name.trim().length > 0 && selectedEventId.trim().length > 0;
|
||||
if (n === 2) {
|
||||
|
|
@ -540,6 +824,7 @@ export function CreateFormPanel({
|
|||
const extras = f.questions.filter((q) => !q.fixed);
|
||||
const extrasOk = extras.every((q) => {
|
||||
if (!q.label.trim()) return false;
|
||||
if (isSectionFieldType(q.type)) return true;
|
||||
if (q.mapsTo === "custom" && !q.customMapsKey?.trim()) return false;
|
||||
if (!fieldChoicesAreValid(q)) return false;
|
||||
return true;
|
||||
|
|
@ -547,7 +832,7 @@ export function CreateFormPanel({
|
|||
return coreOk && extrasOk;
|
||||
}
|
||||
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;
|
||||
}
|
||||
if (n === 3 && isReg) return true;
|
||||
|
|
@ -564,9 +849,11 @@ export function CreateFormPanel({
|
|||
|
||||
const buildPayload = useCallback(() => {
|
||||
const apiType = isReg ? "registration" : isOther ? "other" : "feedback";
|
||||
const saveMode: FormMode = apiType;
|
||||
const uniqueQuestions = assignUniqueFieldNames(form.questions);
|
||||
const fields = isReg
|
||||
? form.questions.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 => toApiFieldReg(q))
|
||||
: uniqueQuestions.filter(q => q.label.trim()).map(q => toApiField(q, saveMode));
|
||||
const isActive = form.publish === "Published";
|
||||
const tagArr = form.onSubmitTags.split(/[,;\n]+/).map(t => t.trim()).filter(Boolean);
|
||||
return {
|
||||
|
|
@ -731,15 +1018,15 @@ export function CreateFormPanel({
|
|||
{isReg ? (
|
||||
<>
|
||||
{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) => (
|
||||
<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) => (
|
||||
<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>
|
||||
|
|
@ -754,7 +1041,7 @@ export function CreateFormPanel({
|
|||
<Plus size={13} /> Add {isFeedbackOrOther ? "Question" : "Field"}
|
||||
</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>
|
||||
|
||||
{isReg && (
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useState } from "react";
|
|||
import { X, Download, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { formsApi } from "../../../services/api";
|
||||
import type { Form, FormField, FormSubmissionRecord } from "../../../services/api";
|
||||
import { buildFormSubmissionsExportFilename } from "../../utils/formExportFilename";
|
||||
|
||||
function dataToObj(data: unknown): Record<string, unknown> {
|
||||
if (!data || typeof data !== "object") return {};
|
||||
|
|
@ -27,6 +28,12 @@ function participantEmail(
|
|||
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 = {
|
||||
formId: string;
|
||||
formName: string;
|
||||
|
|
@ -107,15 +114,19 @@ export function FormSubmissionsPanel({ formId, formName, onClose }: Props) {
|
|||
const onExport = async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const blob = await formsApi.exportSubmissionsCsv(formId, {
|
||||
const { blob } = await formsApi.exportSubmissionsCsv(formId, {
|
||||
q: appliedQ || undefined,
|
||||
dateFrom: appliedFrom || undefined,
|
||||
dateTo: appliedTo || undefined,
|
||||
});
|
||||
const filename = buildFormSubmissionsExportFilename(
|
||||
eventNameFromForm(formDef),
|
||||
formName || formDef?.name || "Form",
|
||||
);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `form-${formId}-submissions.csv`;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} 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. */
|
||||
export type PendingMicrositeBuilder = {
|
||||
|
|
@ -47,16 +53,24 @@ const AppNavContext = createContext<AppNavContextValue | null>(null);
|
|||
|
||||
interface AppNavProviderProps {
|
||||
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;
|
||||
setActiveNavToUrl?: (id: string) => void;
|
||||
}
|
||||
|
||||
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 [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 [sidebarCollapsed, setSidebarCollapsed] = 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 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 = {
|
||||
activeNav,
|
||||
|
|
@ -89,9 +138,7 @@ export function AppNavProvider({ children, activeNavFromUrl, setActiveNavToUrl }
|
|||
setPendingMicrositeBuilder,
|
||||
};
|
||||
|
||||
return (
|
||||
<AppNavContext.Provider value={value}>{children}</AppNavContext.Provider>
|
||||
);
|
||||
return <AppNavContext.Provider value={value}>{children}</AppNavContext.Provider>;
|
||||
}
|
||||
|
||||
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 { 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 { LoadingState } from "../components/shared";
|
||||
|
||||
|
|
@ -84,6 +84,18 @@ export function PlatformAdminLayout() {
|
|||
<Layers size={16} />
|
||||
Plans
|
||||
</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 }} />
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 { AppNavProvider } from "../contexts/AppNavContext";
|
||||
import { AppShell } from "../components/AppShell";
|
||||
|
|
@ -26,11 +26,9 @@ export function ProtectedLayout() {
|
|||
|
||||
function AppShellWithNav() {
|
||||
const { navId } = useParams<{ navId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const activeNav = navId || "home";
|
||||
const setActiveNav = (id: string) => navigate(`/${id}`);
|
||||
return (
|
||||
<AppNavProvider activeNavFromUrl={activeNav} setActiveNavToUrl={setActiveNav}>
|
||||
<AppNavProvider activeNavFromUrl={activeNav}>
|
||||
<AppShell />
|
||||
</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(
|
||||
formId: string,
|
||||
params?: { q?: string; dateFrom?: string; dateTo?: string }
|
||||
): Promise<Blob> {
|
||||
): Promise<{ blob: Blob; filename: string }> {
|
||||
const sp = new URLSearchParams();
|
||||
sp.set('export', 'csv');
|
||||
if (params?.q) sp.set('q', params.q);
|
||||
|
|
@ -138,6 +138,24 @@ export const formsApi = {
|
|||
}
|
||||
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';
|
||||
export { subscriptionApi, loadRazorpayScript } from './subscription';
|
||||
export { platformAdminApi } from './platformAdmin';
|
||||
export { staticMicrositeApi, staticMicrositePublicUrl } from './staticMicrosite';
|
||||
export { adminRequestsApi } from './adminRequests';
|
||||
export { paymentApi } from './payment';
|
||||
export * from './types';
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ export type PublicFormField = {
|
|||
type?: string;
|
||||
required?: boolean;
|
||||
options?: string[];
|
||||
allowOther?: boolean;
|
||||
otherLabel?: string;
|
||||
placeholder?: 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 {
|
||||
label?: string;
|
||||
/** Unique key for submission data; auto-suffixed when labels duplicate. */
|
||||
name?: string;
|
||||
type?: string;
|
||||
required?: boolean;
|
||||
options?: string[];
|
||||
/** Dropdown only: include an Other option; submitted value is the custom text. */
|
||||
allowOther?: boolean;
|
||||
otherLabel?: 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;
|
||||
customMapsKey?: string;
|
||||
isFixed?: boolean;
|
||||
|
|
@ -745,3 +754,81 @@ export interface PublicAttendanceSession {
|
|||
status: string;
|
||||
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,
|
||||
} from "../services/api/publicEventTickets";
|
||||
import { buildUpiPayUri } from "../utils/upiPayUri";
|
||||
import { SelectWithOtherInput } from "../app/components/forms/SelectWithOtherInput";
|
||||
|
||||
/** ISO 3166-2:IN style codes for billing / tax */
|
||||
const INDIAN_STATES = [
|
||||
|
|
@ -997,18 +998,32 @@ export function EventTicketCheckoutView() {
|
|||
{f.required ? " *" : ""}
|
||||
</label>
|
||||
{f.type === "select" || f.type === "radio" ? (
|
||||
<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>
|
||||
f.type === "select" && f.allowOther ? (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<SelectWithOtherInput
|
||||
value={a.formData[f.label!] || ""}
|
||||
onChange={(v) => updateFormData(i, f.label!, v)}
|
||||
options={f.options || []}
|
||||
allowOther
|
||||
otherLabel={f.otherLabel}
|
||||
required={!!f.required}
|
||||
inputStyle={{ ...inputStyle, marginBottom: 0 }}
|
||||
/>
|
||||
</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
|
||||
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 { formsApi } 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> = {
|
||||
conference: "Conference",
|
||||
|
|
@ -174,16 +178,18 @@ function PublicFormEventHeader({
|
|||
);
|
||||
}
|
||||
|
||||
function dataKey(label?: string) {
|
||||
return (label || "").trim();
|
||||
}
|
||||
|
||||
function fieldValueToData(
|
||||
field: FormField,
|
||||
value: unknown
|
||||
values: 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 {};
|
||||
const value = values[k];
|
||||
if (field.type === "checkboxes" && Array.isArray(value)) {
|
||||
return { [k]: value };
|
||||
}
|
||||
|
|
@ -209,6 +215,16 @@ function starRatingMax(field: FormField): number {
|
|||
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() {
|
||||
const { formId = "" } = useParams();
|
||||
const [form, setForm] = useState<PublicFormDefinition | null>(null);
|
||||
|
|
@ -224,7 +240,8 @@ export function PublicFormView() {
|
|||
setLoadError(null);
|
||||
try {
|
||||
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) {
|
||||
setLoadError(e instanceof Error ? e.message : "Could not load form.");
|
||||
}
|
||||
|
|
@ -241,21 +258,42 @@ export function PublicFormView() {
|
|||
const validate = useCallback((): string | null => {
|
||||
if (!form?.fields?.length) return null;
|
||||
for (const field of form.fields) {
|
||||
const k = dataKey(field.label);
|
||||
if (!k || !field.required) continue;
|
||||
if (field.type === "section") 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;
|
||||
if (t === "checkboxes") {
|
||||
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;
|
||||
}
|
||||
if (t === "checkbox") {
|
||||
if (!values[k]) return `“${k}” is required.`;
|
||||
if (!values[k]) return `“${display}” is required.`;
|
||||
continue;
|
||||
}
|
||||
const v = values[k];
|
||||
if (v == null || (typeof v === "string" && v.trim() === "")) {
|
||||
return `“${k}” is required.`;
|
||||
return `“${display}” is required.`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
|
@ -272,9 +310,8 @@ export function PublicFormView() {
|
|||
}
|
||||
const data: Record<string, unknown> = {};
|
||||
for (const field of form.fields || []) {
|
||||
const k = dataKey(field.label);
|
||||
if (!k) continue;
|
||||
Object.assign(data, fieldValueToData(field, values[k]));
|
||||
if (field.type === "section") continue;
|
||||
Object.assign(data, fieldValueToData(field, values));
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
|
|
@ -433,15 +470,39 @@ export function PublicFormView() {
|
|||
boxShadow: "0 1px 3px rgba(0,0,0,0.06)",
|
||||
}}
|
||||
>
|
||||
{(form.fields || []).map((field) => {
|
||||
const k = dataKey(field.label);
|
||||
{(form.fields || []).map((field, index) => {
|
||||
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;
|
||||
const req = field.required;
|
||||
if (field.type === "textarea") {
|
||||
return (
|
||||
<div key={k}>
|
||||
<div key={`field-${index}-${k}`}>
|
||||
<label style={labelStyle}>
|
||||
{k}
|
||||
{display}
|
||||
{req ? <span style={{ color: "var(--destructive, #dc2626)" }}> *</span> : null}
|
||||
</label>
|
||||
<textarea
|
||||
|
|
@ -452,14 +513,15 @@ export function PublicFormView() {
|
|||
rows={4}
|
||||
style={{ ...inputStyle, resize: "vertical", minHeight: 96 }}
|
||||
/>
|
||||
<FieldRemarks text={field.remarks} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (field.type === "text" || field.type === "email" || field.type === "phone") {
|
||||
return (
|
||||
<div key={k}>
|
||||
<div key={`field-${index}-${k}`}>
|
||||
<label style={labelStyle}>
|
||||
{k}
|
||||
{display}
|
||||
{req ? <span style={{ color: "var(--destructive, #dc2626)" }}> *</span> : null}
|
||||
</label>
|
||||
<input
|
||||
|
|
@ -470,6 +532,43 @@ export function PublicFormView() {
|
|||
required={!!req}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -479,9 +578,9 @@ export function PublicFormView() {
|
|||
const v = raw != null && raw !== "" ? parseInt(String(raw), 10) : 0;
|
||||
const selected = !Number.isNaN(v) && v >= 1 && v <= max ? v : 0;
|
||||
return (
|
||||
<div key={k}>
|
||||
<div key={`field-${index}-${k}`}>
|
||||
<div style={labelStyle} id={`star-legend-${k}`}>
|
||||
{k}
|
||||
{display}
|
||||
{req ? <span style={{ color: "var(--destructive, #dc2626)" }}> *</span> : null}
|
||||
</div>
|
||||
<div
|
||||
|
|
@ -518,37 +617,35 @@ export function PublicFormView() {
|
|||
);
|
||||
})}
|
||||
</div>
|
||||
<FieldRemarks text={field.remarks} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (field.type === "select" && (field.options?.length ?? 0) > 0) {
|
||||
return (
|
||||
<div key={k}>
|
||||
<div key={`field-${index}-${k}`}>
|
||||
<label style={labelStyle}>
|
||||
{k}
|
||||
{display}
|
||||
{req ? <span style={{ color: "var(--destructive, #dc2626)" }}> *</span> : null}
|
||||
</label>
|
||||
<select
|
||||
value={String(values[k] ?? "")}
|
||||
onChange={(e) => setField(k, e.target.value)}
|
||||
<SelectWithOtherInput
|
||||
value={values[k]}
|
||||
onChange={(v) => setField(k, v)}
|
||||
options={field.options!}
|
||||
allowOther={field.allowOther}
|
||||
otherLabel={field.otherLabel}
|
||||
required={!!req}
|
||||
style={{ ...inputStyle, cursor: "pointer" }}
|
||||
>
|
||||
<option value="">Select…</option>
|
||||
{field.options!.map((o) => (
|
||||
<option key={o} value={o}>
|
||||
{o}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
inputStyle={inputStyle}
|
||||
/>
|
||||
<FieldRemarks text={field.remarks} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (field.type === "radio" && (field.options?.length ?? 0) > 0) {
|
||||
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 }}>
|
||||
{k}
|
||||
{display}
|
||||
{req ? <span style={{ color: "var(--destructive, #dc2626)" }}> *</span> : null}
|
||||
</legend>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
|
|
@ -568,15 +665,16 @@ export function PublicFormView() {
|
|||
</label>
|
||||
))}
|
||||
</div>
|
||||
<FieldRemarks text={field.remarks} />
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
if (field.type === "checkboxes" && (field.options?.length ?? 0) > 0) {
|
||||
const cur = (values[k] as string[]) || [];
|
||||
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 }}>
|
||||
{k}
|
||||
{display}
|
||||
{req ? <span style={{ color: "var(--destructive, #dc2626)" }}> *</span> : null}
|
||||
</legend>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
|
|
@ -602,27 +700,91 @@ export function PublicFormView() {
|
|||
);
|
||||
})}
|
||||
</div>
|
||||
<FieldRemarks text={field.remarks} />
|
||||
</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") {
|
||||
return (
|
||||
<label key={k} style={{ display: "flex", alignItems: "center", gap: 10, fontSize: "0.9rem", cursor: "pointer" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!values[k]}
|
||||
onChange={(e) => setField(k, e.target.checked)}
|
||||
/>
|
||||
{k}
|
||||
{req ? <span style={{ color: "var(--destructive, #dc2626)" }}> *</span> : null}
|
||||
</label>
|
||||
<div key={`field-${index}-${k}`}>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 10, fontSize: "0.9rem", cursor: "pointer" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!values[k]}
|
||||
onChange={(e) => setField(k, e.target.checked)}
|
||||
/>
|
||||
{display}
|
||||
{req ? <span style={{ color: "var(--destructive, #dc2626)" }}> *</span> : null}
|
||||
</label>
|
||||
<FieldRemarks text={field.remarks} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (field.type === "file") {
|
||||
return (
|
||||
<div key={k}>
|
||||
<div key={`field-${index}-${k}`}>
|
||||
<span style={labelStyle}>
|
||||
{k}
|
||||
{display}
|
||||
{req ? <span style={{ color: "var(--destructive, #dc2626)" }}> *</span> : null}
|
||||
</span>
|
||||
<input
|
||||
|
|
@ -645,18 +807,20 @@ export function PublicFormView() {
|
|||
{fileNames[k] ? (
|
||||
<p style={{ margin: "6px 0 0", fontSize: "0.8rem", color: "var(--muted)" }}>{fileNames[k]}</p>
|
||||
) : null}
|
||||
<FieldRemarks text={field.remarks} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={k}>
|
||||
<label style={labelStyle}>{k}</label>
|
||||
<div key={`field-${index}-${k}`}>
|
||||
<label style={labelStyle}>{display}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={String(values[k] ?? "")}
|
||||
onChange={(e) => setField(k, e.target.value)}
|
||||
style={inputStyle}
|
||||
/>
|
||||
<FieldRemarks text={field.remarks} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
Loading…
Reference in New Issue