ThreadCount Community edition

Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
This commit is contained in:
ThreadCount
2026-09-13 08:45:19 +10:00
commit 1bc2de655a
505 changed files with 56223 additions and 0 deletions
+104
View File
@@ -0,0 +1,104 @@
"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>
);
}