EventBuzz_Website/src/components/DynamicFeedbackForm.tsx

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