Files
threadcount-community/components/ContactForm.tsx
T
ThreadCount 344b1701dd ThreadCount Community edition
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
2026-09-13 08:54:35 +10:00

105 lines
6.4 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.
"use client";
import { useState } from "react";
import Turnstile, { awaitTurnstile, resetTurnstile, turnstileOn } from "@/components/Turnstile";
import { Field, LiveRegion } from "@/components/ui";
const TOPICS = ["A question", "Book a walkthrough", "Security review", "Somethings missing"];
const SLOTS = ["A weekday morning", "A weekday afternoon", "Tell me what suits"];
const label: React.CSSProperties = { fontSize: 11, fontWeight: 600, letterSpacing: "0.08em", textTransform: "uppercase", color: "var(--color-neutral-700)" };
export default function ContactForm() {
const [f, setF] = useState({ name: "", role: "", facility: "", email: "", message: "", company: "" });
const [topic, setTopic] = useState(TOPICS[0]);
const [slot, setSlot] = useState("");
const [cfToken, setCfToken] = useState("");
const [busy, setBusy] = useState(false);
const [sent, setSent] = useState(false);
const [err, setErr] = useState("");
const set = (p: Partial<typeof f>) => setF({ ...f, ...p });
async function submit(e: React.FormEvent) {
e.preventDefault();
if (busy || sent) return;
setBusy(true); setErr("");
try {
/* Waited for at submit, rather than demanded before the button works.
*
* Send used to be disabled until Turnstile handed over a token, and Turnstile's error
* callback publishes an empty one — so a network that blocks or inspects
* challenges.cloudflare.com left a permanently grey button with nothing said, on the only
* support channel this site offers. Now the token is waited for, the message goes either way,
* and if the check really is missing the server says so in words. */
const token = cfToken || (turnstileOn() ? await awaitTurnstile() : "");
const r = await fetch("/api/contact", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ ...f, topic, slot, cfToken: token }),
});
const j = await r.json().catch(() => ({}));
if (!r.ok) { setErr(j.error || "Couldnt send that. Try again, or email hello@threadcount.tech."); setCfToken(""); resetTurnstile(); return; }
setSent(true);
} catch {
setErr("Network error — nothing was sent. Try again, or email hello@threadcount.tech.");
} finally { setBusy(false); }
}
return (
<form onSubmit={submit}>
<div className="tcm-2col" style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20 }}>
{/* The inline label style each of these carried is what .field label already applies, so the
fields draw exactly as they did — and, unlike the old markup, each label is now tied to
the box it names rather than merely sitting above it. */}
<Field label="Your name">{(c) => <input {...c} className="input" value={f.name} onChange={(e) => set({ name: e.target.value })} autoComplete="name" required />}</Field>
<Field label="Your role">{(c) => <input {...c} className="input" value={f.role} onChange={(e) => set({ role: e.target.value })} placeholder="Uniform coordinator" />}</Field>
<Field label="Hospital, home or clinic">{(c) => <input {...c} className="input" value={f.facility} onChange={(e) => set({ facility: e.target.value })} />}</Field>
<Field label="Email">{(c) => <input {...c} className="input" type="email" value={f.email} onChange={(e) => set({ email: e.target.value })} autoComplete="email" required />}</Field>
</div>
<div style={{ marginTop: 22 }}>
<div id="tc-topic" style={label}>What&rsquo;s this about</div>
{/* The chosen topic is a red fill and nothing else, and the heading above was a loose <div>
attached to nothing. Both are now said in words. */}
<div className="seg" role="group" aria-labelledby="tc-topic" style={{ marginTop: 8, flexWrap: "wrap" }}>
{TOPICS.map((t) => <button type="button" key={t} aria-pressed={topic === t} className={"seg-opt" + (topic === t ? " btn-primary" : "")} onClick={() => setTopic(t)}>{t}</button>)}
</div>
</div>
{topic === "Book a walkthrough" && (
<div style={{ marginTop: 18 }}>
<div id="tc-slot" style={label}>When suits</div>
<div role="group" aria-labelledby="tc-slot" style={{ display: "flex", gap: 8, marginTop: 8, flexWrap: "wrap" }}>
{SLOTS.map((s) => <button type="button" key={s} aria-pressed={slot === s} className={"btn " + (slot === s ? "btn-primary" : "btn-secondary")} onClick={() => setSlot(s)}>{s}</button>)}
</div>
</div>
)}
<Field label="Message" style={{ marginTop: 22 }}>
{(c) => <textarea {...c} className="input" rows={6} value={f.message} onChange={(e) => set({ message: e.target.value })} placeholder="What are you trying to do, and whats in the way?" required />}
</Field>
{/* Honeypot — hidden from people, irresistible to bots. */}
<div aria-hidden="true" style={{ position: "absolute", left: -9999, width: 1, height: 1, overflow: "hidden" }}>
<label>Company<input tabIndex={-1} autoComplete="off" value={f.company} onChange={(e) => set({ company: e.target.value })} /></label>
</div>
<div style={{ marginTop: 18 }}><Turnstile onToken={setCfToken} action="contact" /></div>
<LiveRegion tone="alert" msg={err} style={{ border: "2px solid var(--color-accent)", padding: "8px 12px", fontSize: 13, fontWeight: 600, color: "var(--color-accent-700)", marginTop: 14 }} />
<div style={{ display: "flex", gap: 16, alignItems: "center", marginTop: 22, flexWrap: "wrap" }}>
<button type="submit" className="btn btn-primary" disabled={busy || sent}>{sent ? "Sent" : busy ? "Sending…" : "Send"}</button>
<span style={{ fontSize: 13.5, color: sent ? "var(--color-accent-700)" : "var(--color-neutral-700)", fontWeight: sent ? 600 : 400 }}>
{sent ? "Thanks — Ill get back to you, usually within a working day." : "No mailing list, no follow-up sequence."}
</span>
</div>
{/* Said here rather than only in the privacy policy: the point of collection is the moment it
is worth knowing, and the sweep in /api/contact makes it a fact rather than a promise. */}
{!sent && (
<p style={{ fontSize: 12.5, lineHeight: 1.6, color: "var(--color-neutral-700)", marginTop: 12, maxWidth: "60ch" }}>
Your message and the address you give are kept for 12 months so the enquiry can be followed
up, then deleted.
</p>
)}
</form>
);
}