EventBuzz_Console/src/views/PublicFormView.tsx

913 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}