Files
threadcount-community/components/Subscribe.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

95 lines
4.5 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";
/* 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>
);
}