913 lines
32 KiB
TypeScript
913 lines
32 KiB
TypeScript
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 type { FormEventHeaderDisplay } from "../app/utils/formEventHeaderDisplay";
|
||
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",
|
||
workshop: "Workshop",
|
||
summit: "Summit",
|
||
cme: "CME",
|
||
webinar: "Webinar",
|
||
};
|
||
|
||
function formatEventType(t?: string): string {
|
||
if (!t) return "";
|
||
return EVENT_TYPE_LABEL[t] || t.replace(/^\w/, (c) => c.toUpperCase());
|
||
}
|
||
|
||
function formatLocationKind(locationType?: string): string {
|
||
if (locationType === "virtual") return "Virtual";
|
||
if (locationType === "hybrid") return "Hybrid";
|
||
return "In person";
|
||
}
|
||
|
||
function formatEventDateRange(ev: PublicFormEventSummary): string {
|
||
const { startDate, endDate } = ev;
|
||
if (!startDate) return "—";
|
||
const s = new Date(startDate);
|
||
const e = endDate ? new Date(endDate) : s;
|
||
if (Number.isNaN(s.getTime())) return "—";
|
||
const sameDay = !Number.isNaN(e.getTime()) && s.toDateString() === e.toDateString();
|
||
const dOpt: Intl.DateTimeFormatOptions = { month: "short", day: "numeric", year: "numeric" };
|
||
if (sameDay) {
|
||
return s.toLocaleDateString(undefined, { weekday: "short", ...dOpt });
|
||
}
|
||
return `${s.toLocaleDateString(undefined, dOpt)} – ${e.toLocaleDateString(undefined, dOpt)}`;
|
||
}
|
||
|
||
function shouldShowLocation(ev: PublicFormEventSummary): boolean {
|
||
const loc = ev.location?.trim();
|
||
if (!loc) return false;
|
||
return ev.locationType === "physical" || ev.locationType === "hybrid";
|
||
}
|
||
|
||
function PublicFormEventHeader({
|
||
event,
|
||
variant = "default",
|
||
display,
|
||
}: {
|
||
event: PublicFormEventSummary;
|
||
variant?: "default" | "embedded";
|
||
display?: FormEventHeaderDisplay | null;
|
||
}) {
|
||
const d = {
|
||
showLogo: display?.showLogo !== false,
|
||
showTitle: display?.showTitle !== false,
|
||
showDateRange: display?.showDateRange !== false,
|
||
showEventType: display?.showEventType !== false,
|
||
showLocationType: display?.showLocationType !== false,
|
||
showVenue: display?.showVenue !== false,
|
||
};
|
||
const typeLabel = formatEventType(event.eventType);
|
||
const dateLine = formatEventDateRange(event);
|
||
const kind = formatLocationKind(event.locationType);
|
||
const showLoc = d.showVenue && shouldShowLocation(event);
|
||
const embedded = variant === "embedded";
|
||
const showBanner = d.showLogo && !!event.imageUrl && !event.logoUrl;
|
||
const showLogo = d.showLogo && !!event.logoUrl;
|
||
const showTitle = d.showTitle && !!event.name;
|
||
const showSubtitle = d.showTitle && !!event.subtitle?.trim();
|
||
|
||
const metaItems: Array<{ key: string; node: React.ReactNode }> = [];
|
||
if (d.showDateRange && dateLine) {
|
||
metaItems.push({
|
||
key: "date",
|
||
node: <span style={{ color: "var(--foreground, #334155)", fontWeight: 500 }}>{dateLine}</span>,
|
||
});
|
||
}
|
||
if (d.showEventType && typeLabel) {
|
||
metaItems.push({ key: "type", node: <span>{typeLabel}</span> });
|
||
}
|
||
if (d.showLocationType && kind) {
|
||
metaItems.push({
|
||
key: "kind",
|
||
node: (
|
||
<span
|
||
style={{
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
padding: "2px 8px",
|
||
borderRadius: 999,
|
||
background:
|
||
event.locationType === "virtual"
|
||
? "rgba(59, 130, 246, 0.12)"
|
||
: event.locationType === "hybrid"
|
||
? "rgba(147, 51, 234, 0.12)"
|
||
: "rgba(22, 163, 74, 0.12)",
|
||
color:
|
||
event.locationType === "virtual"
|
||
? "#1d4ed8"
|
||
: event.locationType === "hybrid"
|
||
? "#7e22ce"
|
||
: "#15803d",
|
||
fontWeight: 600,
|
||
fontSize: "0.75rem",
|
||
}}
|
||
>
|
||
{kind}
|
||
</span>
|
||
),
|
||
});
|
||
}
|
||
if (showLoc) {
|
||
metaItems.push({
|
||
key: "venue",
|
||
node: <span style={{ color: "var(--foreground, #475569)" }}>{event.location!.trim()}</span>,
|
||
});
|
||
}
|
||
|
||
if (!showBanner && !showLogo && !showTitle && !showSubtitle && metaItems.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
marginBottom: embedded ? 18 : 28,
|
||
paddingBottom: embedded ? 0 : 22,
|
||
borderBottom: embedded ? "none" : "1px solid var(--border, #e2e8f0)",
|
||
}}
|
||
>
|
||
{showBanner ? (
|
||
<div style={{ marginBottom: 16, borderRadius: 10, overflow: "hidden", maxHeight: 160 }}>
|
||
<img
|
||
src={event.imageUrl}
|
||
alt=""
|
||
style={{ width: "100%", height: "100%", maxHeight: 160, objectFit: "cover", display: "block" }}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
<div style={{ display: "flex", alignItems: "flex-start", gap: 16, flexWrap: "wrap" }}>
|
||
{showLogo ? (
|
||
<img
|
||
src={event.logoUrl}
|
||
alt={event.name}
|
||
style={{
|
||
maxHeight: 64,
|
||
maxWidth: 220,
|
||
width: "auto",
|
||
height: "auto",
|
||
objectFit: "contain",
|
||
flexShrink: 0,
|
||
}}
|
||
/>
|
||
) : null}
|
||
<div style={{ minWidth: 0, flex: "1 1 200px" }}>
|
||
{showTitle ? (
|
||
<h1
|
||
style={{
|
||
fontFamily: "var(--font-inter, system-ui, sans-serif)",
|
||
fontSize: "1.375rem",
|
||
fontWeight: 600,
|
||
margin: "0 0 6px",
|
||
lineHeight: 1.25,
|
||
color: "var(--foreground, #0f172a)",
|
||
}}
|
||
>
|
||
{event.name}
|
||
</h1>
|
||
) : null}
|
||
{showSubtitle ? (
|
||
<p
|
||
style={{
|
||
margin: "0 0 10px",
|
||
fontSize: "0.95rem",
|
||
color: "var(--muted, #64748b)",
|
||
lineHeight: 1.45,
|
||
}}
|
||
>
|
||
{event.subtitle}
|
||
</p>
|
||
) : null}
|
||
{metaItems.length > 0 ? (
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
flexWrap: "wrap",
|
||
alignItems: "center",
|
||
gap: "6px 10px",
|
||
fontSize: "0.8125rem",
|
||
color: "var(--muted, #64748b)",
|
||
fontFamily: "var(--font-noto-sans, system-ui, sans-serif)",
|
||
}}
|
||
>
|
||
{metaItems.map((item, index) => (
|
||
<React.Fragment key={item.key}>
|
||
{index > 0 ? (
|
||
<span aria-hidden="true" style={{ opacity: 0.5 }}>
|
||
·
|
||
</span>
|
||
) : null}
|
||
{item.node}
|
||
</React.Fragment>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function fieldValueToData(
|
||
field: FormField,
|
||
values: Record<string, unknown>
|
||
): Record<string, unknown> {
|
||
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 };
|
||
}
|
||
if (field.type === "checkbox") {
|
||
return { [k]: value === true || value === "true" ? "Yes" : "" };
|
||
}
|
||
return { [k]: value };
|
||
}
|
||
|
||
/** `stars` type or legacy `select` with options "1"…"5" (form builder star rating). */
|
||
function isStarRatingField(field: FormField): boolean {
|
||
if (field.type === "stars") return true;
|
||
if (field.type !== "select" || !field.options?.length) return false;
|
||
const o = field.options.map((x) => String(x));
|
||
return o.length === 5 && o.every((v, i) => v === String(i + 1));
|
||
}
|
||
|
||
function starRatingMax(field: FormField): number {
|
||
if (field.type === "stars") {
|
||
const n = field.options?.length;
|
||
return n && n > 0 ? n : 5;
|
||
}
|
||
return 5;
|
||
}
|
||
|
||
function FieldRemarks({ text }: { text?: string }) {
|
||
const trimmed = text?.trim();
|
||
if (!trimmed) return null;
|
||
return (
|
||
<p style={{ margin: "6px 0 0", fontSize: "0.75rem", lineHeight: 1.45, color: "var(--muted, #64748b)" }}>
|
||
{trimmed}
|
||
</p>
|
||
);
|
||
}
|
||
|
||
export function PublicFormView() {
|
||
const { formId = "" } = useParams();
|
||
const [form, setForm] = useState<PublicFormDefinition | null>(null);
|
||
const [loadError, setLoadError] = useState<string | null>(null);
|
||
const [values, setValues] = useState<Record<string, unknown>>({});
|
||
const [fileNames, setFileNames] = useState<Record<string, string>>({});
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||
const [done, setDone] = useState(false);
|
||
|
||
const load = useCallback(async () => {
|
||
if (!formId) return;
|
||
setLoadError(null);
|
||
try {
|
||
const f = await formsApi.getPublic(formId);
|
||
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.");
|
||
}
|
||
}, [formId]);
|
||
|
||
useEffect(() => {
|
||
void load();
|
||
}, [load]);
|
||
|
||
const setField = (key: string, v: unknown) => {
|
||
setValues((prev) => ({ ...prev, [key]: v }));
|
||
};
|
||
|
||
const validate = useCallback((): string | null => {
|
||
if (!form?.fields?.length) return null;
|
||
for (const field of form.fields) {
|
||
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 `“${display}” is required.`;
|
||
continue;
|
||
}
|
||
if (t === "checkbox") {
|
||
if (!values[k]) return `“${display}” is required.`;
|
||
continue;
|
||
}
|
||
const v = values[k];
|
||
if (v == null || (typeof v === "string" && v.trim() === "")) {
|
||
return `“${display}” is required.`;
|
||
}
|
||
}
|
||
return null;
|
||
}, [form, values]);
|
||
|
||
const onSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!formId || !form) return;
|
||
setSubmitError(null);
|
||
const err = validate();
|
||
if (err) {
|
||
setSubmitError(err);
|
||
return;
|
||
}
|
||
const data: Record<string, unknown> = {};
|
||
for (const field of form.fields || []) {
|
||
if (field.type === "section") continue;
|
||
Object.assign(data, fieldValueToData(field, values));
|
||
}
|
||
setSubmitting(true);
|
||
try {
|
||
await formsApi.submit(formId, { data });
|
||
setDone(true);
|
||
} catch (e) {
|
||
setSubmitError(
|
||
e instanceof Error
|
||
? e.message
|
||
: form.settings?.submitErrorMessage?.trim() || "Submit failed.",
|
||
);
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
if (loadError) {
|
||
return (
|
||
<div
|
||
style={{
|
||
minHeight: "100vh",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
padding: 24,
|
||
background: "var(--background, #f8fafc)",
|
||
color: "var(--foreground, #0f172a)",
|
||
fontFamily: "var(--font-noto-sans, system-ui, sans-serif)",
|
||
}}
|
||
>
|
||
<p style={{ margin: 0, fontSize: "0.95rem" }}>{loadError}</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!form) {
|
||
return (
|
||
<div
|
||
style={{
|
||
minHeight: "100vh",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
background: "var(--background, #f8fafc)",
|
||
color: "var(--muted, #64748b)",
|
||
}}
|
||
>
|
||
Loading…
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (done) {
|
||
const showThanks = form.settings?.showThankYou !== false;
|
||
const msg = form.settings?.thankYouMessage?.trim() || "Thank you for your response.";
|
||
const ev = form.event;
|
||
return (
|
||
<div
|
||
style={{
|
||
minHeight: "100vh",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
padding: 24,
|
||
background: "var(--background, #f8fafc)",
|
||
color: "var(--foreground, #0f172a)",
|
||
fontFamily: "var(--font-noto-sans, system-ui, sans-serif)",
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
maxWidth: 520,
|
||
width: "100%",
|
||
textAlign: ev ? "left" : "center",
|
||
padding: 32,
|
||
background: "var(--card, #fff)",
|
||
border: "1px solid var(--border, #e2e8f0)",
|
||
borderRadius: "var(--radius-card, 12px)",
|
||
boxShadow: "0 1px 3px rgba(0,0,0,0.08)",
|
||
}}
|
||
>
|
||
{ev ? <PublicFormEventHeader event={ev} variant="embedded" display={form.settings?.eventHeader} /> : null}
|
||
<h1
|
||
style={{
|
||
fontFamily: "var(--font-inter, system-ui, sans-serif)",
|
||
fontSize: "1.2rem",
|
||
fontWeight: 600,
|
||
margin: "0 0 8px",
|
||
}}
|
||
>
|
||
{form.name}
|
||
</h1>
|
||
{showThanks ? (
|
||
<p
|
||
style={{
|
||
margin: 0,
|
||
padding: "12px 16px",
|
||
borderRadius: 6,
|
||
backgroundColor: "#d1e7dd",
|
||
color: "#198754",
|
||
fontWeight: 700,
|
||
fontSize: "0.95rem",
|
||
lineHeight: 1.5,
|
||
border: "1px solid #badbcc",
|
||
}}
|
||
>
|
||
{msg}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const labelStyle: React.CSSProperties = {
|
||
display: "block",
|
||
fontSize: "0.75rem",
|
||
fontWeight: 600,
|
||
color: "var(--muted, #64748b)",
|
||
marginBottom: 6,
|
||
textTransform: "uppercase",
|
||
letterSpacing: "0.04em",
|
||
};
|
||
const inputStyle: React.CSSProperties = {
|
||
width: "100%",
|
||
padding: "10px 12px",
|
||
fontSize: "0.9rem",
|
||
borderRadius: 8,
|
||
border: "1px solid var(--border, #e2e8f0)",
|
||
background: "var(--card, #fff)",
|
||
color: "var(--foreground, #0f172a)",
|
||
boxSizing: "border-box",
|
||
};
|
||
|
||
const ev = form.event;
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
minHeight: "100vh",
|
||
padding: "32px 16px 48px",
|
||
background: "var(--background, #f8fafc)",
|
||
color: "var(--foreground, #0f172a)",
|
||
fontFamily: "var(--font-noto-sans, system-ui, sans-serif)",
|
||
}}
|
||
>
|
||
<div style={{ maxWidth: 520, margin: "0 auto" }}>
|
||
{ev ? <PublicFormEventHeader event={ev} display={form.settings?.eventHeader} /> : null}
|
||
<h1
|
||
style={{
|
||
fontFamily: "var(--font-inter, system-ui, sans-serif)",
|
||
fontSize: ev ? "1.25rem" : "1.5rem",
|
||
fontWeight: 600,
|
||
margin: "0 0 8px",
|
||
lineHeight: 1.3,
|
||
color: "var(--foreground, #0f172a)",
|
||
}}
|
||
>
|
||
{form.name}
|
||
</h1>
|
||
{form.description ? (
|
||
<p style={{ margin: "0 0 24px", color: "var(--muted, #64748b)", fontSize: "0.9rem", lineHeight: 1.5 }}>{form.description}</p>
|
||
) : (
|
||
<div style={{ marginBottom: 24 }} />
|
||
)}
|
||
|
||
<form
|
||
onSubmit={(e) => void onSubmit(e)}
|
||
style={{
|
||
padding: 24,
|
||
background: "var(--card, #fff)",
|
||
border: "1px solid var(--border, #e2e8f0)",
|
||
borderRadius: "var(--radius-card, 12px)",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: 16,
|
||
boxShadow: "0 1px 3px rgba(0,0,0,0.06)",
|
||
}}
|
||
>
|
||
{(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={`field-${index}-${k}`}>
|
||
<label style={labelStyle}>
|
||
{display}
|
||
{req ? <span style={{ color: "var(--destructive, #dc2626)" }}> *</span> : null}
|
||
</label>
|
||
<textarea
|
||
value={String(values[k] ?? "")}
|
||
onChange={(e) => setField(k, e.target.value)}
|
||
placeholder={field.placeholder}
|
||
required={!!req}
|
||
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={`field-${index}-${k}`}>
|
||
<label style={labelStyle}>
|
||
{display}
|
||
{req ? <span style={{ color: "var(--destructive, #dc2626)" }}> *</span> : null}
|
||
</label>
|
||
<input
|
||
type={field.type === "email" ? "email" : field.type === "phone" ? "tel" : "text"}
|
||
value={String(values[k] ?? "")}
|
||
onChange={(e) => setField(k, e.target.value)}
|
||
placeholder={field.placeholder}
|
||
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>
|
||
);
|
||
}
|
||
if (isStarRatingField(field)) {
|
||
const max = starRatingMax(field);
|
||
const raw = values[k];
|
||
const v = raw != null && raw !== "" ? parseInt(String(raw), 10) : 0;
|
||
const selected = !Number.isNaN(v) && v >= 1 && v <= max ? v : 0;
|
||
return (
|
||
<div key={`field-${index}-${k}`}>
|
||
<div style={labelStyle} id={`star-legend-${k}`}>
|
||
{display}
|
||
{req ? <span style={{ color: "var(--destructive, #dc2626)" }}> *</span> : null}
|
||
</div>
|
||
<div
|
||
role="radiogroup"
|
||
aria-labelledby={`star-legend-${k}`}
|
||
style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 4 }}
|
||
>
|
||
{Array.from({ length: max }, (_, i) => {
|
||
const starN = i + 1;
|
||
const filled = selected >= starN;
|
||
return (
|
||
<button
|
||
key={starN}
|
||
type="button"
|
||
role="radio"
|
||
aria-checked={selected === starN}
|
||
aria-label={`${starN} of ${max} stars`}
|
||
onClick={() => setField(k, String(starN))}
|
||
style={{
|
||
fontSize: "2rem",
|
||
lineHeight: 1,
|
||
width: 44,
|
||
height: 44,
|
||
border: "none",
|
||
background: "transparent",
|
||
cursor: "pointer",
|
||
padding: 0,
|
||
color: filled ? "#ca8a04" : "var(--border, #e2e8f0)",
|
||
transition: "color 0.12s, transform 0.12s",
|
||
}}
|
||
>
|
||
★
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
<FieldRemarks text={field.remarks} />
|
||
</div>
|
||
);
|
||
}
|
||
if (field.type === "select" && (field.options?.length ?? 0) > 0) {
|
||
return (
|
||
<div key={`field-${index}-${k}`}>
|
||
<label style={labelStyle}>
|
||
{display}
|
||
{req ? <span style={{ color: "var(--destructive, #dc2626)" }}> *</span> : null}
|
||
</label>
|
||
<SelectWithOtherInput
|
||
value={values[k]}
|
||
onChange={(v) => setField(k, v)}
|
||
options={field.options!}
|
||
allowOther={field.allowOther}
|
||
otherLabel={field.otherLabel}
|
||
required={!!req}
|
||
inputStyle={inputStyle}
|
||
/>
|
||
<FieldRemarks text={field.remarks} />
|
||
</div>
|
||
);
|
||
}
|
||
if (field.type === "radio" && (field.options?.length ?? 0) > 0) {
|
||
return (
|
||
<fieldset key={`field-${index}-${k}`} style={{ border: "none", padding: 0, margin: 0 }}>
|
||
<legend style={{ ...labelStyle, marginBottom: 8 }}>
|
||
{display}
|
||
{req ? <span style={{ color: "var(--destructive, #dc2626)" }}> *</span> : null}
|
||
</legend>
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||
{field.options!.map((o) => (
|
||
<label
|
||
key={o}
|
||
style={{ display: "flex", alignItems: "center", gap: 8, fontSize: "0.9rem", cursor: "pointer" }}
|
||
>
|
||
<input
|
||
type="radio"
|
||
name={k}
|
||
value={o}
|
||
checked={values[k] === o}
|
||
onChange={() => setField(k, o)}
|
||
/>
|
||
{o}
|
||
</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={`field-${index}-${k}`} style={{ border: "none", padding: 0, margin: 0 }}>
|
||
<legend style={{ ...labelStyle, marginBottom: 8 }}>
|
||
{display}
|
||
{req ? <span style={{ color: "var(--destructive, #dc2626)" }}> *</span> : null}
|
||
</legend>
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||
{field.options!.map((o) => {
|
||
const checked = cur.includes(o);
|
||
return (
|
||
<label
|
||
key={o}
|
||
style={{ display: "flex", alignItems: "center", gap: 8, fontSize: "0.9rem", cursor: "pointer" }}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={checked}
|
||
onChange={() => {
|
||
setField(
|
||
k,
|
||
checked ? cur.filter((x) => x !== o) : [...cur, o]
|
||
);
|
||
}}
|
||
/>
|
||
{o}
|
||
</label>
|
||
);
|
||
})}
|
||
</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 (
|
||
<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={`field-${index}-${k}`}>
|
||
<span style={labelStyle}>
|
||
{display}
|
||
{req ? <span style={{ color: "var(--destructive, #dc2626)" }}> *</span> : null}
|
||
</span>
|
||
<input
|
||
type="file"
|
||
onChange={async (e) => {
|
||
const file = e.target.files?.[0];
|
||
if (!file) {
|
||
setField(k, "");
|
||
setFileNames((f) => ({ ...f, [k]: "" }));
|
||
return;
|
||
}
|
||
setFileNames((f) => ({ ...f, [k]: file.name }));
|
||
const reader = new FileReader();
|
||
reader.onload = () => setField(k, String(reader.result || ""));
|
||
reader.onerror = () => setField(k, "");
|
||
reader.readAsDataURL(file);
|
||
}}
|
||
style={{ fontSize: "0.85rem" }}
|
||
/>
|
||
{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={`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>
|
||
);
|
||
})}
|
||
|
||
{submitError ? (
|
||
<p style={{ margin: 0, fontSize: "0.85rem", color: "var(--destructive, #dc2626)" }}>{submitError}</p>
|
||
) : null}
|
||
|
||
<button
|
||
type="submit"
|
||
disabled={submitting}
|
||
style={{
|
||
marginTop: 8,
|
||
padding: "12px 20px",
|
||
borderRadius: "var(--radius-button, 8px)",
|
||
border: "none",
|
||
background: "var(--primary, #0f3072)",
|
||
color: "var(--primary-foreground, #fff)",
|
||
fontSize: "0.9rem",
|
||
fontWeight: 600,
|
||
cursor: submitting ? "not-allowed" : "pointer",
|
||
opacity: submitting ? 0.75 : 1,
|
||
}}
|
||
>
|
||
{submitting ? "Submitting…" : "Submit"}
|
||
</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|