Files
ipv6-sims/app/components/forms/FeedbackForm.tsx
T

428 lines
17 KiB
TypeScript

"use client";
import SubmitToast from "@/app/components/ui/SubmitToast";
import { useMemo, useState } from "react";
type FeedbackJson = {
sections: {
satisfaction: {
title: string;
eventRating: { label: string; left: string; right: string; min: number; max: number };
venueSatisfaction: { label: string; left: string; right: string; min: number; max: number };
};
content: {
title: string;
session: { label: string; placeholder: string; options: { value: string; label: string }[] };
speakerRating: { label: string; left: string; right: string; min: number; max: number };
};
qualitative: {
title: string;
improve2027: { label: string; placeholder: string };
comments: { label: string; placeholder: string };
};
industry: {
title: string;
ipv6Readiness: { label: string; hint: string; left: string; right: string; min: number; max: number };
challenge: {
label: string;
hint: string;
placeholder: string;
options: { value: string; label: string }[];
};
};
networking: {
title: string;
introductions: { label: string; hint: string; placeholder: string };
collaboration: { label: string; hint: string; options: { value: string; label: string }[] };
};
logistics: {
title: string;
venueRating: { label: string; hint: string; left: string; right: string; min: number; max: number };
};
};
submit: { label: string; disclaimer: string };
};
function RadioScale({
name,
label,
left,
right,
min,
max,
value,
onChange,
}: {
name: string;
label: string;
left: string;
right: string;
min: number;
max: number;
value: string;
onChange: (v: string) => void;
}) {
const values = useMemo(() => {
const out: number[] = [];
for (let i = min; i <= max; i++) out.push(i);
return out;
}, [min, max]);
return (
<div className="space-y-2">
<span className="sr-only">{label}</span>
<div className="flex justify-between items-center gap-2 max-w-md flex-wrap">
<span className="text-sm opacity-50">{left}</span>
<div className="flex gap-2 sm:gap-4 flex-wrap justify-center">
{values.map((n) => (
<label key={n} className="cursor-pointer group">
<span className="sr-only">
{label} {n}
</span>
<input
className="peer sr-only"
name={name}
type="radio"
value={String(n)}
checked={value === String(n)}
onChange={() => onChange(String(n))}
/>
<div className="w-11 h-11 sm:w-12 sm:h-12 flex items-center justify-center border border-white/10 rounded peer-checked:bg-primary peer-checked:text-on-primary hover:border-primary transition-all">
{n}
</div>
</label>
))}
</div>
<span className="text-sm opacity-50">{right}</span>
</div>
</div>
);
}
const emptyFeedback = {
eventRating: "",
venueSatisfaction: "",
session: "",
speakerRating: "",
improve2027: "",
comments: "",
ipv6Readiness: "",
challenge: "",
introductions: "",
collaboration: [] as string[],
venueRating: "",
};
export default function FeedbackForm({ data }: { data: FeedbackJson }) {
const [form, setForm] = useState({ ...emptyFeedback, collaboration: [] as string[] });
const [submitState, setSubmitState] = useState<"idle" | "sending" | "ok" | "error">("idle");
const toggleCollaboration = (value: string) => {
setForm((s) => ({
...s,
collaboration: s.collaboration.includes(value)
? s.collaboration.filter((x) => x !== value)
: [...s.collaboration, value],
}));
};
return (
<form
className="space-y-10 md:space-y-14"
onSubmit={async (e) => {
e.preventDefault();
setSubmitState("sending");
const sessionLabel =
data.sections.content.session.options.find((o) => o.value === form.session)?.label ?? "";
const challengeLabel =
data.sections.industry.challenge.options.find((o) => o.value === form.challenge)?.label ?? "";
const collaborationLabels = form.collaboration
.map(
(v) =>
data.sections.networking.collaboration.options.find((o) => o.value === v)?.label ?? v,
)
.filter(Boolean);
try {
const res = await fetch("/api/submissions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "feedback",
...form,
sessionLabel,
challengeLabel,
collaborationLabels,
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(typeof err?.error === "string" ? err.error : res.statusText);
}
setSubmitState("ok");
} catch {
setSubmitState("error");
}
}}
>
{/* 1 */}
<section className="space-y-6">
<div className="border-l-2 border-primary pl-4">
<h2 className="font-[var(--font-display-lg)] text-xl md:text-2xl text-on-surface uppercase tracking-tight">
{data.sections.satisfaction.title}
</h2>
</div>
<div className="space-y-4">
<label className="block font-[var(--font-label-caps)] text-xs text-primary uppercase tracking-wider">
{data.sections.satisfaction.eventRating.label}
</label>
<RadioScale
name="event-rating"
label={data.sections.satisfaction.eventRating.label}
left={data.sections.satisfaction.eventRating.left}
right={data.sections.satisfaction.eventRating.right}
min={data.sections.satisfaction.eventRating.min}
max={data.sections.satisfaction.eventRating.max}
value={form.eventRating}
onChange={(v) => setForm((s) => ({ ...s, eventRating: v }))}
/>
</div>
<div className="space-y-4">
<label className="block font-[var(--font-label-caps)] text-xs text-primary uppercase tracking-wider">
{data.sections.satisfaction.venueSatisfaction.label}
</label>
<RadioScale
name="venue-satisfaction"
label={data.sections.satisfaction.venueSatisfaction.label}
left={data.sections.satisfaction.venueSatisfaction.left}
right={data.sections.satisfaction.venueSatisfaction.right}
min={data.sections.satisfaction.venueSatisfaction.min}
max={data.sections.satisfaction.venueSatisfaction.max}
value={form.venueSatisfaction}
onChange={(v) => setForm((s) => ({ ...s, venueSatisfaction: v }))}
/>
</div>
</section>
{/* 2 */}
<section className="space-y-6">
<div className="border-l-2 border-primary pl-4">
<h2 className="font-[var(--font-display-lg)] text-xl md:text-2xl text-on-surface uppercase tracking-tight">
{data.sections.content.title}
</h2>
</div>
<div className="space-y-2">
<label className="block font-[var(--font-label-caps)] text-xs text-primary uppercase tracking-wider">
{data.sections.content.session.label}
</label>
<div className="relative">
<select
className="w-full bg-surface-container-low border border-white/10 rounded-lg px-4 py-3 text-on-surface appearance-none"
value={form.session}
onChange={(e) => setForm((s) => ({ ...s, session: e.target.value }))}
>
<option value="">{data.sections.content.session.placeholder}</option>
{data.sections.content.session.options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<span className="material-symbols-outlined absolute right-4 top-1/2 -translate-y-1/2 text-outline pointer-events-none">
expand_more
</span>
</div>
</div>
<div className="space-y-4">
<label className="block font-[var(--font-label-caps)] text-xs text-primary uppercase tracking-wider">
{data.sections.content.speakerRating.label}
</label>
<RadioScale
name="speaker-rating"
label={data.sections.content.speakerRating.label}
left={data.sections.content.speakerRating.left}
right={data.sections.content.speakerRating.right}
min={data.sections.content.speakerRating.min}
max={data.sections.content.speakerRating.max}
value={form.speakerRating}
onChange={(v) => setForm((s) => ({ ...s, speakerRating: v }))}
/>
</div>
</section>
{/* 3 */}
<section className="space-y-6">
<div className="border-l-2 border-primary pl-4">
<h2 className="font-[var(--font-display-lg)] text-xl md:text-2xl text-on-surface uppercase tracking-tight">
{data.sections.qualitative.title}
</h2>
</div>
<div className="space-y-2">
<label className="block font-[var(--font-label-caps)] text-xs text-primary uppercase tracking-wider">
{data.sections.qualitative.improve2027.label}
</label>
<textarea
className="w-full bg-surface-container-low border border-white/10 rounded-lg px-4 py-3 text-on-surface resize-none min-h-[100px]"
placeholder={data.sections.qualitative.improve2027.placeholder}
rows={4}
value={form.improve2027}
onChange={(e) => setForm((s) => ({ ...s, improve2027: e.target.value }))}
/>
</div>
<div className="space-y-2">
<label className="block font-[var(--font-label-caps)] text-xs text-primary uppercase tracking-wider">
{data.sections.qualitative.comments.label}
</label>
<textarea
className="w-full bg-surface-container-low border border-white/10 rounded-lg px-4 py-3 text-on-surface resize-none min-h-[88px]"
placeholder={data.sections.qualitative.comments.placeholder}
rows={3}
value={form.comments}
onChange={(e) => setForm((s) => ({ ...s, comments: e.target.value }))}
/>
</div>
</section>
{/* 4 */}
<section className="space-y-6">
<div className="border-l-2 border-primary pl-4">
<h2 className="font-[var(--font-display-lg)] text-xl md:text-2xl text-on-surface uppercase tracking-tight">
{data.sections.industry.title}
</h2>
</div>
<div className="space-y-4">
<label className="block font-[var(--font-label-caps)] text-xs text-primary uppercase tracking-wider">
{data.sections.industry.ipv6Readiness.label}
</label>
<p className="text-on-surface-variant text-sm">{data.sections.industry.ipv6Readiness.hint}</p>
<RadioScale
name="ipv6-readiness"
label={data.sections.industry.ipv6Readiness.label}
left={data.sections.industry.ipv6Readiness.left}
right={data.sections.industry.ipv6Readiness.right}
min={data.sections.industry.ipv6Readiness.min}
max={data.sections.industry.ipv6Readiness.max}
value={form.ipv6Readiness}
onChange={(v) => setForm((s) => ({ ...s, ipv6Readiness: v }))}
/>
</div>
<div className="space-y-2">
<label className="block font-[var(--font-label-caps)] text-xs text-primary uppercase tracking-wider">
{data.sections.industry.challenge.label}
</label>
<p className="text-on-surface-variant text-sm">{data.sections.industry.challenge.hint}</p>
<div className="relative">
<select
className="w-full bg-surface-container-low border border-white/10 rounded-lg px-4 py-3 text-on-surface appearance-none"
value={form.challenge}
onChange={(e) => setForm((s) => ({ ...s, challenge: e.target.value }))}
>
<option value="">{data.sections.industry.challenge.placeholder}</option>
{data.sections.industry.challenge.options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<span className="material-symbols-outlined absolute right-4 top-1/2 -translate-y-1/2 text-outline pointer-events-none">
expand_more
</span>
</div>
</div>
</section>
{/* 5 */}
<section className="space-y-6">
<div className="border-l-2 border-primary pl-4">
<h2 className="font-[var(--font-display-lg)] text-xl md:text-2xl text-on-surface uppercase tracking-tight">
{data.sections.networking.title}
</h2>
</div>
<div className="space-y-2">
<label className="block font-[var(--font-label-caps)] text-xs text-primary uppercase tracking-wider">
{data.sections.networking.introductions.label}
</label>
<p className="text-on-surface-variant text-sm">{data.sections.networking.introductions.hint}</p>
<input
className="w-full bg-surface-container-low border border-white/10 rounded-lg px-4 py-3 text-on-surface"
placeholder={data.sections.networking.introductions.placeholder}
type="text"
value={form.introductions}
onChange={(e) => setForm((s) => ({ ...s, introductions: e.target.value }))}
/>
</div>
<div className="space-y-3">
<label className="block font-[var(--font-label-caps)] text-xs text-primary uppercase tracking-wider">
{data.sections.networking.collaboration.label}
</label>
<p className="text-on-surface-variant text-sm">{data.sections.networking.collaboration.hint}</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{data.sections.networking.collaboration.options.map((opt) => (
<label
key={opt.value}
className="flex items-start gap-3 cursor-pointer group rounded-lg border border-white/10 bg-surface-container-low/50 px-4 py-3 hover:border-primary/40 transition-colors"
>
<input
type="checkbox"
className="mt-1 size-4 accent-primary"
checked={form.collaboration.includes(opt.value)}
onChange={() => toggleCollaboration(opt.value)}
/>
<span className="text-on-surface text-sm">{opt.label}</span>
</label>
))}
</div>
</div>
</section>
{/* 6 */}
<section className="space-y-6">
<div className="border-l-2 border-primary pl-4">
<h2 className="font-[var(--font-display-lg)] text-xl md:text-2xl text-on-surface uppercase tracking-tight">
{data.sections.logistics.title}
</h2>
</div>
<div className="space-y-4">
<label className="block font-[var(--font-label-caps)] text-xs text-primary uppercase tracking-wider">
{data.sections.logistics.venueRating.label}
</label>
<p className="text-on-surface-variant text-sm">{data.sections.logistics.venueRating.hint}</p>
<RadioScale
name="venue-rating"
label={data.sections.logistics.venueRating.label}
left={data.sections.logistics.venueRating.left}
right={data.sections.logistics.venueRating.right}
min={data.sections.logistics.venueRating.min}
max={data.sections.logistics.venueRating.max}
value={form.venueRating}
onChange={(v) => setForm((s) => ({ ...s, venueRating: v }))}
/>
</div>
</section>
<div className="pt-6 border-t border-white/5 flex flex-col items-center gap-4">
<button
className="gold-gradient-bg text-on-primary w-full md:w-auto px-12 py-4 rounded-lg font-[var(--font-display-lg)] text-lg uppercase tracking-wider hover:shadow-[0_0_20px_rgba(197,160,89,0.35)] transition-all duration-300 active:scale-[0.98] disabled:opacity-60 disabled:pointer-events-none"
type="submit"
disabled={submitState === "sending"}
>
{submitState === "sending" ? "Submitting…" : data.submit.label}
</button>
<SubmitToast
state={submitState}
successTitle="Feedback submitted"
successMessage="Thank you. Your responses were saved and will appear in the admin console."
onDismiss={() => {
setSubmitState("idle");
setForm({ ...emptyFeedback, collaboration: [] });
}}
/>
<p className="text-on-surface-variant font-[var(--font-label-caps)] text-[10px] opacity-60 uppercase text-center tracking-widest">
{data.submit.disclaimer}
</p>
</div>
</form>
);
}