NGU-Web/src/pages/Feedback.tsx
2026-09-25 02:34:15 -05:00

307 lines
10 KiB
TypeScript

import { useState } from "react";
import PageShell from "../components/PageShell.jsx";
/* ═══════════════════════════════════════════════════════════════
Feedback page.
Section ids match the nav hashes. Right now there's one:
#website. Add more entries to SECTIONS (bottom of file) as the
page grows — program feedback, retreat surveys, etc.
Nothing is persisted. handleSubmit just flips to the thank-you
panel; the POST goes where the comment marks it.
═══════════════════════════════════════════════════════════════ */
const ACCENT = "#138ba0";
const MUTED = "#4a6b72";
const MAX_CHARS = 1500;
// Selectable feedback types. Add or reword freely — the grid reflows.
const FEEDBACK_TYPES = [
{
id: "broken",
label: "Something's broken",
hint: "A link, image, or button that doesn't work",
},
{
id: "confusing",
label: "Hard to use",
hint: "Something you couldn't find or follow",
},
{
id: "outdated",
label: "Wrong or missing info",
hint: "Old dates, typos, an event that isn't listed",
},
{
id: "request",
label: "Feature request",
hint: "Something you'd like the site to do",
},
{
id: "praise",
label: "Kind words",
hint: "Tell us what's working well",
},
{
id: "other",
label: "Something else",
hint: "Anything that doesn't fit the boxes above",
},
];
/* ── Shared field chrome ─────────────────────────────────────── */
const fieldClass =
"w-full rounded-xl border border-[#4a6b72]/25 bg-white px-4 py-3 text-[#26454c] " +
"placeholder:text-[#4a6b72]/50 outline-none transition-colors " +
"focus:border-[#138ba0] focus:ring-2 focus:ring-[#138ba0]/25";
function OptionalTag() {
return (
<span className="ml-2 rounded-full bg-[#eef9fb] px-2 py-0.5 text-xs font-medium text-[#138ba0]">
Optional
</span>
);
}
/* ── Type picker ─────────────────────────────────────────────── */
function TypePicker({ value, onChange }) {
return (
<fieldset>
<legend className="text-base font-semibold text-[#26454c]">
What kind of feedback is this?
</legend>
<p className="mt-1 text-sm" style={{ color: MUTED }}>
Pick the closest fit. It helps us route it to the right person.
</p>
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{FEEDBACK_TYPES.map((type) => {
const selected = value === type.id;
return (
<label key={type.id} className="cursor-pointer">
<input
type="radio"
name="feedbackType"
value={type.id}
checked={selected}
onChange={() => onChange(type.id)}
className="peer sr-only"
/>
<span
className={
"flex h-full flex-col gap-1 rounded-xl border p-4 transition-colors " +
"peer-focus-visible:ring-2 peer-focus-visible:ring-[#138ba0]/40 " +
(selected
? "border-[#138ba0] bg-[#eef9fb]"
: "border-[#4a6b72]/20 bg-white hover:border-[#138ba0]/50")
}
>
<span className="flex items-start justify-between gap-2">
<span className="font-semibold text-[#26454c]">
{type.label}
</span>
<span
aria-hidden="true"
className={
"mt-0.5 h-4 w-4 shrink-0 rounded-full border-2 transition-colors " +
(selected
? "border-[#138ba0] bg-[#138ba0] ring-2 ring-inset ring-white"
: "border-[#4a6b72]/30")
}
/>
</span>
<span className="text-sm leading-snug" style={{ color: MUTED }}>
{type.hint}
</span>
</span>
</label>
);
})}
</div>
</fieldset>
);
}
/* ── The form ────────────────────────────────────────────────── */
function WebsiteFeedbackForm() {
const [type, setType] = useState(null);
const [message, setMessage] = useState("");
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [sent, setSent] = useState(false);
const ready = Boolean(type) && message.trim().length > 0;
function handleSubmit(event) {
event.preventDefault();
if (!ready) return;
// TODO: POST { type, message, name, email } somewhere.
setSent(true);
}
function reset() {
setType(null);
setMessage("");
setName("");
setEmail("");
setSent(false);
}
if (sent) {
return (
<div
aria-live="polite"
className="rounded-2xl border border-[#138ba0]/20 bg-white p-8 sm:p-10"
>
<h3 className="text-2xl font-bold" style={{ color: ACCENT }}>
Thanks — we've got it
</h3>
<p className="mt-3 max-w-prose" style={{ color: MUTED }}>
{email
? `We'll follow up at ${email} if we have questions.`
: "You sent this anonymously, so we won't be able to reply — but we read everything that comes in."}
</p>
<button
type="button"
onClick={reset}
className="mt-6 rounded-full border border-[#138ba0] px-5 py-2.5 font-semibold text-[#138ba0] transition-colors hover:bg-[#eef9fb] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#138ba0]/40"
>
Send more feedback
</button>
</div>
);
}
return (
<form
onSubmit={handleSubmit}
noValidate
className="rounded-2xl border border-[#138ba0]/20 bg-white p-6 sm:p-8"
>
<TypePicker value={type} onChange={setType} />
{/* Message */}
<div className="mt-10">
<label
htmlFor="feedback-message"
className="text-base font-semibold text-[#26454c]"
>
Tell us more
</label>
<p className="mt-1 text-sm" style={{ color: MUTED }}>
The page you were on and what you expected to happen are the two most
useful things you can give us.
</p>
<textarea
id="feedback-message"
rows={7}
maxLength={MAX_CHARS}
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="I was looking for the summer retreat dates on the Events page and…"
className={`mt-4 resize-y ${fieldClass}`}
/>
<div className="mt-2 text-right text-xs tabular-nums" style={{ color: MUTED }}>
{message.length} / {MAX_CHARS}
</div>
</div>
{/* Optional contact details */}
<div className="mt-8 rounded-xl border border-dashed border-[#4a6b72]/30 bg-[#eef9fb]/60 p-5 sm:p-6">
<div className="flex flex-wrap items-center">
<h3 className="text-base font-semibold text-[#26454c]">
Your details
</h3>
<OptionalTag />
</div>
<p className="mt-1 max-w-prose text-sm" style={{ color: MUTED }}>
Leave these blank and your feedback comes through anonymously. Fill
them in only if you'd like a reply — we won't add you to any list.
</p>
<div className="mt-5 grid gap-4 sm:grid-cols-2">
<div>
<label
htmlFor="feedback-name"
className="block text-sm font-medium text-[#26454c]"
>
Name <span className="font-normal text-[#4a6b72]">(optional)</span>
</label>
<input
id="feedback-name"
type="text"
autoComplete="name"
value={name}
onChange={(e) => setName(e.target.value)}
className={`mt-2 ${fieldClass}`}
/>
</div>
<div>
<label
htmlFor="feedback-email"
className="block text-sm font-medium text-[#26454c]"
>
Email <span className="font-normal text-[#4a6b72]">(optional)</span>
</label>
<input
id="feedback-email"
type="email"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className={`mt-2 ${fieldClass}`}
/>
</div>
</div>
</div>
{/* Submit */}
<div className="mt-8 flex flex-wrap items-center gap-4">
<button
type="submit"
disabled={!ready}
className="rounded-full bg-[#138ba0] px-7 py-3 font-semibold text-white transition-colors hover:bg-[#0f7183] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#138ba0]/40 disabled:cursor-not-allowed disabled:bg-[#4a6b72]/25 disabled:text-white/80"
>
Send feedback
</button>
{!ready && (
<p className="text-sm" style={{ color: MUTED }}>
Choose a type and write a note to send.
</p>
)}
</div>
</form>
);
}
/* ── Page ────────────────────────────────────────────────────── */
const SECTIONS = [
{
id: "website",
title: "Website Feedback and Requests",
blurb:
"Found a broken link, spotted something out of date, or want the site to do something it doesn't? This form goes straight to the people who maintain it.",
accent: ACCENT,
background: "#eef9fb",
content: (
<div className="mx-auto max-w-3xl px-6">
<WebsiteFeedbackForm />
</div>
),
},
];
export default function FeedbackPage() {
return (
<PageShell
title="Feedback"
intro="Tell us what's working, what isn't, and what you'd like to see next."
sections={SECTIONS}
/>
);
}