344b1701dd
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
95 lines
4.5 KiB
TypeScript
95 lines
4.5 KiB
TypeScript
"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("Couldn’t reach the server. Check the connection and try again."); return; }
|
||
if (!r.ok) { setErr(j.error || "That didn’t 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’s a confirmation link on its way from hello@threadcount.tech. You’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>
|
||
);
|
||
}
|