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:54:35 +10:00
commit 344b1701dd
505 changed files with 56231 additions and 0 deletions
+94
View File
@@ -0,0 +1,94 @@
"use client";
/* Newsletter sign-up.
*
* Deliberately kept away from the contact form, which promises "No mailing list, no follow-up
* sequence" at the point of collection. That promise is about people who write to you; this is a
* separate, explicit act by someone who wants to hear about releases. Keeping them visually and
* physically apart is what makes both statements true.
*
* The copy says what it is and what it isn't, because a hospital coordinator deciding whether to
* hand over a work address deserves to know the frequency and the exit before they type it. */
import { useState } from "react";
import Turnstile, { awaitTurnstile, resetTurnstile, turnstileOn } from "@/components/Turnstile";
import { track } from "@/lib/analytics";
export default function Subscribe() {
const [email, setEmail] = useState("");
const [company, setCompany] = useState(""); // honeypot
const [cfToken, setCfToken] = useState("");
const [busy, setBusy] = useState(false);
const [done, setDone] = useState(false);
const [err, setErr] = useState("");
async function submit(e: React.FormEvent) {
e.preventDefault();
if (!email.trim()) { setErr("Add an email address."); return; }
setBusy(true);
setErr("");
const token = cfToken || (turnstileOn() ? await awaitTurnstile() : "");
const r = await fetch("/api/subscribe", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email, company, cfToken: token }),
}).catch(() => null);
const j = r ? await r.json().catch(() => ({})) : {};
setBusy(false);
setCfToken(""); resetTurnstile();
// A rejected fetch used to leave the button disabled on "Sending…" with nothing said, so the
// form looked like it had eaten the address. Sign-up is confirmed by email either way, so a
// second attempt costs nothing.
if (!r) { setErr("Couldnt reach the server. Check the connection and try again."); return; }
if (!r.ok) { setErr(j.error || "That didnt go through. Try again in a moment."); return; }
track("newsletter_signup");
setDone(true);
}
if (done) {
return (
<div style={{ borderLeft: "6px solid var(--color-accent)", padding: "14px 0 14px 16px" }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 17 }}>Check your inbox.</div>
<p style={{ fontSize: 13.5, lineHeight: 1.6, color: "var(--color-neutral-800)", margin: "6px 0 0", maxWidth: "40ch" }}>
There&rsquo;s a confirmation link on its way from hello@threadcount.tech. You&rsquo;re not
on the list until you click it.
</p>
</div>
);
}
return (
<form onSubmit={submit}>
<div style={{ fontSize: 11.5, letterSpacing: "0.12em", textTransform: "uppercase", fontWeight: 800, borderBottom: "2px solid var(--color-text)", paddingBottom: 9 }}>
Product updates
</div>
<p style={{ fontSize: 13.5, lineHeight: 1.65, color: "var(--color-neutral-800)", margin: "14px 0 0", maxWidth: "38ch" }}>
When something ships that changes how the linen room works. A few times a year, never more.
Unsubscribe in one click.
</p>
<div style={{ display: "flex", gap: 0, marginTop: 14, maxWidth: 420 }}>
<input
type="email"
inputMode="email"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
placeholder="you@yourfacility.org"
value={email}
onChange={(e) => { setEmail(e.target.value); setErr(""); }}
aria-label="Your work email"
style={{ flex: 1, minWidth: 0, font: "inherit", fontSize: 14.5, padding: "12px 12px", border: "2px solid var(--color-text)", borderRight: 0, background: "#fff", color: "var(--color-text)", borderRadius: 0 }}
/>
<button type="submit" disabled={busy} className="btn btn-primary" style={{ cursor: busy ? "wait" : "pointer", whiteSpace: "nowrap" }}>
{busy ? "Sending…" : "Sign up"}
</button>
</div>
{/* Honeypot — visually hidden, never announced, never focusable. */}
<input
type="text" tabIndex={-1} autoComplete="off" aria-hidden="true" value={company}
onChange={(e) => setCompany(e.target.value)}
style={{ position: "absolute", left: -9999, width: 1, height: 1, opacity: 0 }}
/>
{err ? <p style={{ fontSize: 13, color: "var(--color-accent-700)", fontWeight: 700, margin: "10px 0 0" }}>{err}</p> : null}
{turnstileOn() ? <Turnstile onToken={setCfToken} action="subscribe" quiet /> : null}
</form>
);
}