358 lines
12 KiB
TypeScript
358 lines
12 KiB
TypeScript
import { useMemo, useState, type FormEvent } from "react";
|
|
import { Star } from "lucide-react";
|
|
import type { VisitorPublicFeedbackForm, VisitorPublicFeedbackFormField } from "../lib/api";
|
|
import { postPublicFormSubmit } from "../lib/api";
|
|
import { getAttendeeParticipant } from "../lib/attendeeAuth";
|
|
|
|
function isStarRatingField(f: VisitorPublicFeedbackFormField): boolean {
|
|
const t = (f.type || "").toLowerCase().trim();
|
|
if (t === "stars" || t === "star rating" || t === "star ratings") return true;
|
|
if (t === "select") {
|
|
const opts = (f.options || []).map((o) => String(o).trim());
|
|
return opts.length === 5 && opts.every((v, i) => v === String(i + 1));
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function starValueValid(raw: unknown): boolean {
|
|
const n = typeof raw === "number" ? raw : parseInt(String(raw ?? ""), 10);
|
|
return Number.isFinite(n) && n >= 1 && n <= 5;
|
|
}
|
|
|
|
export function DynamicFeedbackForm({
|
|
eventId,
|
|
schema,
|
|
variant = "light",
|
|
}: {
|
|
eventId: string;
|
|
schema: VisitorPublicFeedbackForm;
|
|
variant?: "light" | "gradient";
|
|
}) {
|
|
const [values, setValues] = useState<Record<string, unknown>>({});
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [err, setErr] = useState<string | null>(null);
|
|
const [done, setDone] = useState(false);
|
|
|
|
const fields = useMemo(() => (schema.fields || []).filter((f) => f.label?.trim()), [schema.fields]);
|
|
|
|
const setVal = (label: string, v: unknown) => {
|
|
setValues((prev) => ({ ...prev, [label]: v }));
|
|
};
|
|
|
|
const validate = (): boolean => {
|
|
for (const f of fields) {
|
|
const key = String(f.label).trim();
|
|
if (!f.required) continue;
|
|
const raw = values[key];
|
|
if ((f.type || "").toLowerCase() === "checkboxes") {
|
|
if (!Array.isArray(raw) || raw.length === 0) return false;
|
|
} else if (isStarRatingField(f)) {
|
|
if (!starValueValid(raw)) return false;
|
|
} else if (raw === undefined || raw === null || String(raw).trim() === "") {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
};
|
|
|
|
const onSubmit = async (e: FormEvent) => {
|
|
e.preventDefault();
|
|
setErr(null);
|
|
if (!validate()) {
|
|
setErr("Please fill all required fields.");
|
|
return;
|
|
}
|
|
const data: Record<string, unknown> = {};
|
|
for (const f of fields) {
|
|
const key = String(f.label).trim();
|
|
data[key] = values[key] ?? "";
|
|
}
|
|
setSubmitting(true);
|
|
try {
|
|
const participant = getAttendeeParticipant();
|
|
const pid =
|
|
participant && String(participant.event) === String(eventId) ? participant._id : undefined;
|
|
await postPublicFormSubmit(schema._id, data, pid);
|
|
setDone(true);
|
|
} catch (er) {
|
|
setErr(er instanceof Error ? er.message : "Could not submit.");
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
if (done && schema.showThankYou !== false) {
|
|
return (
|
|
<div
|
|
className={
|
|
variant === "gradient"
|
|
? "rounded-xl bg-white p-6 text-center shadow-[0_8px_12px_6px_rgba(0,0,0,0.15),0_4px_4px_rgba(0,0,0,0.3)]"
|
|
: "rounded-md bg-white p-4 text-center shadow-[0_1px_4px_rgba(12,12,13,0.05)]"
|
|
}
|
|
>
|
|
<p className="font-inter text-sm font-semibold text-[#24282f]">Thank you</p>
|
|
<p className="mt-2 rounded-md border border-[#badbcc] bg-[#d1e7dd] px-4 py-3 text-sm font-bold text-[#198754]">
|
|
{schema.thankYouMessage?.trim() || "Your response has been recorded."}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const fieldShell =
|
|
variant === "gradient"
|
|
? "rounded-xl bg-white p-6 shadow-[0_8px_12px_6px_rgba(0,0,0,0.15),0_4px_4px_rgba(0,0,0,0.3)]"
|
|
: "rounded-md bg-white p-4 shadow-[0_1px_4px_rgba(12,12,13,0.05)]";
|
|
|
|
return (
|
|
<form onSubmit={onSubmit} className={fieldShell}>
|
|
{schema.description?.trim() ? (
|
|
<p className="mb-4 text-sm leading-snug text-[rgba(36,40,47,0.67)]">{stripHtml(schema.description.trim())}</p>
|
|
) : null}
|
|
<div className="flex flex-col gap-4">
|
|
{fields.map((f) => (
|
|
<div key={String(f.label)} className="flex flex-col">
|
|
<FieldInput f={f} value={values[String(f.label).trim()]} onChange={setVal} />
|
|
<FieldRemarks text={f.remarks} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
{err ? <p className="mt-3 text-sm text-red-600">{err}</p> : null}
|
|
<button
|
|
type="submit"
|
|
disabled={submitting}
|
|
className="mt-4 w-full rounded-full bg-[#ef8d1d] py-3 text-xs font-semibold uppercase tracking-wide text-white shadow-[0_4px_4px_rgba(0,0,0,0.15)] disabled:opacity-60"
|
|
>
|
|
{submitting ? "Submitting…" : "Submit"}
|
|
</button>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
/** Minimal strip — form descriptions are plain text from dashboard */
|
|
function stripHtml(s: string): string {
|
|
return s.replace(/<[^>]*>/g, "").trim() || s;
|
|
}
|
|
|
|
function FieldRemarks({ text }: { text?: string }) {
|
|
const trimmed = text?.trim();
|
|
if (!trimmed) return null;
|
|
return <p className="mt-1.5 text-xs leading-snug text-[rgba(36,40,47,0.55)]">{trimmed}</p>;
|
|
}
|
|
|
|
function FieldInput({
|
|
f,
|
|
value,
|
|
onChange,
|
|
}: {
|
|
f: VisitorPublicFeedbackFormField;
|
|
value: unknown;
|
|
onChange: (label: string, v: unknown) => void;
|
|
}) {
|
|
const label = String(f.label || "").trim();
|
|
const ph = f.placeholder?.trim() || "";
|
|
const opts = (f.options || []).map((o) => String(o).trim()).filter(Boolean);
|
|
const t = (f.type || "text").toLowerCase();
|
|
|
|
const baseInput =
|
|
"w-full rounded-lg border border-[rgba(116,112,112,0.3)] bg-[rgba(157,157,172,0.07)] px-3 py-2 font-inter text-sm text-[#24282f] placeholder:text-[rgba(36,40,47,0.45)]";
|
|
|
|
if (t === "file") {
|
|
return (
|
|
<p className="text-sm text-[rgba(36,40,47,0.67)]">
|
|
<span className="font-semibold text-[#24282f]">{label}</span>
|
|
<span className="mt-1 block">File upload isn't supported in the mobile app yet.</span>
|
|
</p>
|
|
);
|
|
}
|
|
|
|
if (isStarRatingField(f)) {
|
|
const n = typeof value === "number" ? value : parseInt(String(value ?? ""), 10);
|
|
const selected = Number.isFinite(n) ? n : 0;
|
|
return (
|
|
<fieldset className="flex flex-col gap-2">
|
|
<legend className="text-sm font-semibold text-[#24282f]">
|
|
{label}
|
|
{f.required ? <span className="text-red-600"> *</span> : null}
|
|
</legend>
|
|
<div className="flex gap-2">
|
|
{Array.from({ length: 5 }, (_, i) => {
|
|
const star = i + 1;
|
|
return (
|
|
<button
|
|
key={star}
|
|
type="button"
|
|
onClick={() => onChange(label, star)}
|
|
className="text-[#ef8d1d]"
|
|
aria-label={`${star} star${star === 1 ? "" : "s"}`}
|
|
>
|
|
<Star
|
|
className={`size-7 ${star <= selected ? "fill-[#ef8d1d]" : "fill-transparent"}`}
|
|
strokeWidth={1.75}
|
|
/>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</fieldset>
|
|
);
|
|
}
|
|
|
|
if (t === "select") {
|
|
return (
|
|
<label className="flex flex-col gap-2">
|
|
<span className="text-sm font-semibold text-[#24282f]">
|
|
{label}
|
|
{f.required ? <span className="text-red-600"> *</span> : null}
|
|
</span>
|
|
<select
|
|
value={typeof value === "string" ? value : ""}
|
|
onChange={(e) => onChange(label, e.target.value)}
|
|
className={baseInput}
|
|
>
|
|
<option value="">Select…</option>
|
|
{opts.map((o) => (
|
|
<option key={o} value={o}>
|
|
{o}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
);
|
|
}
|
|
|
|
if (t === "radio") {
|
|
return (
|
|
<fieldset className="flex flex-col gap-2">
|
|
<legend className="text-sm font-semibold text-[#24282f]">
|
|
{label}
|
|
{f.required ? <span className="text-red-600"> *</span> : null}
|
|
</legend>
|
|
<div className="flex flex-col gap-2">
|
|
{(opts.length ? opts : ["Yes", "No"]).map((o) => (
|
|
<label key={o} className="flex items-center gap-2 text-sm text-[#24282f]">
|
|
<input type="radio" name={label} checked={value === o} onChange={() => onChange(label, o)} />
|
|
{o}
|
|
</label>
|
|
))}
|
|
</div>
|
|
</fieldset>
|
|
);
|
|
}
|
|
|
|
if (t === "checkboxes") {
|
|
const arr = Array.isArray(value) ? (value as string[]) : [];
|
|
const toggle = (o: string) => {
|
|
if (arr.includes(o)) onChange(label, arr.filter((x) => x !== o));
|
|
else onChange(label, [...arr, o]);
|
|
};
|
|
return (
|
|
<fieldset className="flex flex-col gap-2">
|
|
<legend className="text-sm font-semibold text-[#24282f]">
|
|
{label}
|
|
{f.required ? <span className="text-red-600"> *</span> : null}
|
|
</legend>
|
|
<div className="flex flex-col gap-2">
|
|
{opts.map((o) => (
|
|
<label key={o} className="flex items-center gap-2 text-sm text-[#24282f]">
|
|
<input type="checkbox" checked={arr.includes(o)} onChange={() => toggle(o)} />
|
|
{o}
|
|
</label>
|
|
))}
|
|
</div>
|
|
</fieldset>
|
|
);
|
|
}
|
|
|
|
if (t === "checkbox") {
|
|
return (
|
|
<label className="flex items-center gap-2 text-sm text-[#24282f]">
|
|
<input
|
|
type="checkbox"
|
|
checked={value === true || value === "yes" || value === "Yes"}
|
|
onChange={(e) => onChange(label, e.target.checked ? "Yes" : "No")}
|
|
/>
|
|
<span>
|
|
{label}
|
|
{f.required ? <span className="text-red-600"> *</span> : null}
|
|
</span>
|
|
</label>
|
|
);
|
|
}
|
|
|
|
if (t === "email" || t === "phone") {
|
|
return (
|
|
<label className="flex flex-col gap-2">
|
|
<span className="text-sm font-semibold text-[#24282f]">
|
|
{label}
|
|
{f.required ? <span className="text-red-600"> *</span> : null}
|
|
</span>
|
|
<input
|
|
type={t === "email" ? "email" : "tel"}
|
|
value={typeof value === "string" ? value : ""}
|
|
onChange={(e) => onChange(label, e.target.value)}
|
|
placeholder={ph}
|
|
className={baseInput}
|
|
/>
|
|
</label>
|
|
);
|
|
}
|
|
|
|
if (t === "textarea") {
|
|
return (
|
|
<label className="flex flex-col gap-2">
|
|
<span className="text-sm font-semibold text-[#24282f]">
|
|
{label}
|
|
{f.required ? <span className="text-red-600"> *</span> : null}
|
|
</span>
|
|
<textarea
|
|
value={typeof value === "string" ? value : ""}
|
|
onChange={(e) => onChange(label, e.target.value)}
|
|
rows={4}
|
|
placeholder={ph}
|
|
className={`${baseInput} resize-none`}
|
|
/>
|
|
</label>
|
|
);
|
|
}
|
|
|
|
const longText =
|
|
label.toLowerCase().includes("comment") ||
|
|
label.toLowerCase().includes("describe") ||
|
|
label.toLowerCase().includes("feedback") ||
|
|
ph.length > 50;
|
|
|
|
if (t === "text" && longText) {
|
|
return (
|
|
<label className="flex flex-col gap-2">
|
|
<span className="text-sm font-semibold text-[#24282f]">
|
|
{label}
|
|
{f.required ? <span className="text-red-600"> *</span> : null}
|
|
</span>
|
|
<textarea
|
|
value={typeof value === "string" ? value : ""}
|
|
onChange={(e) => onChange(label, e.target.value)}
|
|
rows={4}
|
|
placeholder={ph}
|
|
className={`${baseInput} resize-none`}
|
|
/>
|
|
</label>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<label className="flex flex-col gap-2">
|
|
<span className="text-sm font-semibold text-[#24282f]">
|
|
{label}
|
|
{f.required ? <span className="text-red-600"> *</span> : null}
|
|
</span>
|
|
<input
|
|
type="text"
|
|
value={typeof value === "string" ? value : ""}
|
|
onChange={(e) => onChange(label, e.target.value)}
|
|
placeholder={ph}
|
|
className={baseInput}
|
|
/>
|
|
</label>
|
|
);
|
|
}
|