Files
threadcount-community/components/site.tsx
T
ThreadCount 1bc2de655a ThreadCount Community edition
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
2026-09-13 08:45:19 +10:00

188 lines
14 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.
import Link from "next/link";
import Subscribe from "@/components/Subscribe";
import { facilityToday } from "@/lib/compute";
import { PRICES } from "@/lib/plan";
import { plansLive } from "@/lib/plans-live";
/** Shared chrome for the public site, built to the design handoff (design/TC Nav, TC Footer).
* Sticky nav on a 2px ink rule, four content links, no rounded corners anywhere. */
export const NAV = [
["features", "Features", "/features"],
["how", "How it works", "/how-it-works"],
["reporting", "Reporting", "/reporting"],
["pricing", "Pricing", "/pricing"],
// The guides are the likeliest way a linen services manager arrives here at all, so they get a
// place in the main nav rather than being buried in the footer.
["guides", "Guides", "/guides"],
] as const;
const FOOT = [
{ h: "Product", links: [["Features", "/features"], ["How it works", "/how-it-works"], ["Reporting", "/reporting"], ["Pricing", "/pricing"], ["Roadmap", "/roadmap"], ["Open the demo", "/demo"]] },
{ h: "Get going", links: [["Getting started", "/getting-started"], ["Guides", "/guides"], ["Support", "/support"], ["FAQ", "/faq"], ["Security & data", "/security"], ["Who built it", "/about"], ["Contact", "/contact"]] },
{ h: "Legal", links: [["Privacy Policy", "/privacy"], ["Terms of Service", "/terms"], ["Data Security", "/data-security"], ["Acceptable Use", "/acceptable-use"], ["About & Contact", "/legal-about"], ["Delete Your Account", "/delete-account"]] },
] as const;
// ---- shared type styles, straight off the handoff's scale
export const GUTTER = 40;
export const wrap: React.CSSProperties = { maxWidth: 1440, margin: "0 auto", padding: `0 ${GUTTER}px`, width: "100%" };
export const kicker: React.CSSProperties = { fontSize: 12, letterSpacing: "0.15em", textTransform: "uppercase", fontWeight: 800, color: "var(--color-accent-700)" };
export const h1: React.CSSProperties = { fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: "clamp(38px, 5.2vw, 78px)", lineHeight: 0.98, letterSpacing: "-0.035em", margin: 0, textWrap: "balance" as never };
export const h2: React.CSSProperties = { fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: "clamp(28px, 3.4vw, 48px)", lineHeight: 1.02, letterSpacing: "-0.03em", margin: 0, textWrap: "balance" as never };
export const h3: React.CSSProperties = { fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: "clamp(20px, 2.2vw, 28px)", letterSpacing: "-0.02em", margin: 0 };
export const body: React.CSSProperties = { fontSize: 16.5, lineHeight: 1.7, color: "var(--color-neutral-800)" };
export const small: React.CSSProperties = { fontSize: 13.5, lineHeight: 1.65, color: "var(--color-neutral-700)" };
export const dash = <span style={{ display: "inline-block", width: 28, height: 3, background: "var(--color-accent)", verticalAlign: "middle", marginRight: 10 }} />;
/** The 44/60px accent mark that sits under a heading. */
export const Rule = ({ w = 60, color = "var(--color-accent)" }: { w?: number; color?: string }) => <div style={{ width: w, height: 4, background: color, margin: "18px 0 0" }} />;
export function Wordmark({ size = 19, light = false }: { size?: number; light?: boolean }) {
return (
<span style={{ display: "inline-flex", alignItems: "center", gap: 10 }}>
<span style={{ width: 15, height: 15, background: "var(--color-accent)", flex: "none" }} />
<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: size, letterSpacing: "-0.01em", color: light ? "#fff" : "var(--color-text)" }}>ThreadCount</span>
</span>
);
}
export function SiteNav({ current = "" }: { current?: string }) {
return (
<>
<nav style={{ position: "sticky", top: 0, zIndex: 60, background: "var(--color-bg)", borderBottom: "2px solid var(--color-text)" }}>
<div style={{ ...wrap, height: 72, display: "flex", alignItems: "center", gap: 40 }}>
<Link href="/" style={{ textDecoration: "none" }}><Wordmark /></Link>
<div className="tcm-navlinks" style={{ display: "flex", gap: 28, alignItems: "center" }}>
{NAV.map(([k, label, href]) => (
<Link key={k} href={href} style={{ textDecoration: "none", fontSize: 14, fontWeight: 700, letterSpacing: "0.01em", whiteSpace: "nowrap", paddingBottom: 3, borderBottom: "3px solid " + (k === current ? "var(--color-accent)" : "transparent"), color: k === current ? "var(--color-accent-700)" : "var(--color-text)" }}>{label}</Link>
))}
</div>
<div style={{ marginLeft: "auto", display: "flex", alignItems: "center", gap: 18 }}>
<Link href="/auth" className="tcm-navlogin" style={{ textDecoration: "none", color: "var(--color-text)", fontSize: 13.5, fontWeight: 700 }}>Log in</Link>
<Link href="/demo" className="btn btn-primary" data-umami-event="open-demo" data-umami-event-placement="nav">Open the demo</Link>
</div>
</div>
{/* Below 780px the handoff hides the link row; this keeps the pages reachable there. Log in leads,
because the desktop bars Log in is hidden at this width and a returning user needs it first. */}
<div className="tcm-navmobile" style={{ display: "none", borderTop: "1px solid var(--color-divider)", position: "relative" }}>
<div className="tcm-navscroll" style={{ display: "flex", gap: 18, padding: "10px 40px", whiteSpace: "nowrap", overflowX: "auto" }}>
<Link href="/auth" style={{ textDecoration: "none", fontSize: 13, fontWeight: 800, color: "var(--color-accent-700)", paddingRight: 18, borderRight: "1px solid var(--color-divider)" }}>Log in</Link>
{[...NAV, ["faq", "FAQ", "/faq"] as const, ["support", "Support", "/support"] as const, ["contact", "Contact", "/contact"] as const].map(([k, label, href]) => (
<Link key={k} href={href} style={{ textDecoration: "none", fontSize: 13, fontWeight: 700, color: k === current ? "var(--color-accent-700)" : "var(--color-text)", borderBottom: "3px solid " + (k === current ? "var(--color-accent)" : "transparent"), paddingBottom: 2 }}>{label}</Link>
))}
</div>
{/* The row is wider than a phone; this edge fade says so. */}
<div aria-hidden style={{ position: "absolute", top: 1, right: 0, bottom: 0, width: 40, pointerEvents: "none", background: "linear-gradient(to right, transparent, var(--color-bg) 78%)" }} />
</div>
</nav>
{/* The skip link in the site layout aims here. It has to be a real element after the nav rather
than an id on the layout's wrapper, because every page renders its own SiteNav inside that
wrapper so it can mark the active link — an id further out would sit above the nav and skip
nothing. tabIndex 1 so following the fragment moves focus, not just the scroll position. */}
<span id="content" tabIndex={-1} style={{ outline: "none" }} />
</>
);
}
/* The left cell of a two-column .tcm-split carries no left padding of its own: it sits on the page
* gutter, so the wrap's 40px is already there. Below 900px globals.css collapses .tcm-split to one
* column and that zero would become copy printed against the edge of the phone, so the same media
* query in globals.css puts the gutter back. Put this class on the cell and give it
* paddingTop/paddingRight/paddingBottom — never the four-value `padding` shorthand, which would set
* padding-left: 0 inline and beat the rule. */
export const SPLIT_PAD = "tcm-splitpad";
/** Page header band. `tone` picks the three treatments the handoff uses for opening a page. */
export function PageHead({ kicker: k, title, lede, tone = "light", right, children }: {
kicker: string; title: React.ReactNode; lede?: React.ReactNode; tone?: "light" | "ink" | "accent"; right?: React.ReactNode; children?: React.ReactNode;
}) {
const ink = tone === "ink", accent = tone === "accent";
// An accent band carries white body copy, so it is filled with --color-accent-600 rather than the
// brand #ec3013: white on the brand red is 4.20:1, and the kicker and lede sat further under that
// again in accent-300 and neutral-200. On accent-600, white clears 4.5:1 at every size used here.
// #ec3013 stays the brand mark on rules, edge marks and the logo square, where it carries no text.
const bg = ink ? "var(--color-text)" : accent ? "var(--color-accent-600)" : "var(--color-bg)";
const fg = ink || accent ? "#fff" : "var(--color-text)";
return (
<section style={{ background: bg, color: fg, borderBottom: "2px solid var(--color-text)" }}>
<div style={{ ...wrap, padding: accent ? "52px 40px 56px" : "clamp(56px,7vw,88px) 40px clamp(52px,6vw,76px)" }}>
<div className={right ? "tcm-headsplit" : undefined} style={right ? { display: "grid", gridTemplateColumns: "1.3fr 1fr", gap: 64, alignItems: "end" } : undefined}>
<div>
<div style={{ ...kicker, color: ink ? "var(--color-accent-300)" : accent ? "#fff" : "var(--color-accent-700)" }}>{k}</div>
<h1 style={{ ...h1, margin: "18px 0 0", color: fg }}>{title}</h1>
{lede && !right && <p style={{ ...body, color: ink ? "var(--color-neutral-200)" : accent ? "#fff" : "var(--color-neutral-800)", fontSize: 17, maxWidth: "52ch", margin: "22px 0 0" }}>{lede}</p>}
{children}
</div>
{right && <div>{right}</div>}
</div>
</div>
</section>
);
}
/** Standard content section with the 40px gutter and a 2px closing rule. */
export function Band({ tone = "bg", children, pad = "clamp(56px,7vw,88px) 40px clamp(56px,7vw,84px)", id }: {
tone?: "bg" | "surface" | "ink" | "accent"; children: React.ReactNode; pad?: string; id?: string;
}) {
const map = { bg: "var(--color-bg)", surface: "var(--color-surface)", ink: "var(--color-text)", accent: "var(--color-accent-600)" };
return (
<section id={id} style={{ background: map[tone], color: tone === "ink" || tone === "accent" ? "#fff" : "var(--color-text)", borderBottom: "2px solid var(--color-text)" }}>
<div style={{ ...wrap, padding: pad }}>{children}</div>
</section>
);
}
export async function CtaBand({ title = "Set your linen room up this week.", lede }: { title?: string; lede?: string }) {
const live = await plansLive();
lede ??= live
? `Creating your facility takes about a minute, and a room under ${PRICES.freeStaff} staff records is free. Your catalogue and staff register come in from CSV, so you can be issuing the same day.`
: "Its free, and creating your facility takes about a minute. Your catalogue and staff register come in from CSV, so you can be issuing the same day.";
return (
<Band tone="accent" pad="clamp(44px,5vw,64px) 40px clamp(48px,5vw,68px)">
<h2 style={{ ...h2, fontSize: "clamp(30px,4vw,58px)", textTransform: "uppercase", maxWidth: "20ch", color: "#fff" }}>{title}</h2>
<p style={{ fontSize: 16.5, lineHeight: 1.65, margin: "20px 0 0", maxWidth: "48ch", color: "#fff" }}>{lede}</p>
<div style={{ display: "flex", gap: 12, marginTop: 28, flexWrap: "wrap" }}>
<Link href="/auth?mode=signup" className="btn" data-umami-event="signup-cta" data-umami-event-placement="footer-band" style={{ background: "#fff", color: "var(--color-accent-700)", border: "2px solid #fff", fontWeight: 700 }}>Set up your facility</Link>
<Link href="/demo" className="btn" data-umami-event="open-demo" data-umami-event-placement="footer-band" style={{ background: "transparent", color: "#fff", border: "2px solid #fff", fontWeight: 700 }}>Open the demo</Link>
</div>
</Band>
);
}
export async function SiteFooter() {
const live = await plansLive();
return (
<footer style={{ borderTop: "2px solid var(--color-text)" }}>
<div style={{ ...wrap, padding: "56px 40px 40px" }}>
{/* Sign-up sits above the link columns and well away from the contact form, which promises
no mailing list. Two separate, deliberate acts; two promises that both stay true. */}
<div style={{ paddingBottom: 40, marginBottom: 40, borderBottom: "1px solid var(--color-divider)" }}>
<Subscribe />
</div>
<div className="tcm-foot" style={{ display: "grid", gridTemplateColumns: "minmax(240px,1.6fr) repeat(3, minmax(140px,1fr))", gap: 40 }}>
<div>
<Wordmark size={17} />
<p style={{ fontSize: 14, lineHeight: 1.65, color: "var(--color-neutral-800)", margin: "14px 0 0", maxWidth: "34ch" }}>Uniform management for hospital linen rooms, written by someone who runs one.</p>
<div style={{ display: "inline-block", marginTop: 18, border: "2px solid var(--color-text)", padding: "7px 12px", fontSize: 12, fontWeight: 800, letterSpacing: "0.1em", textTransform: "uppercase" }}>{live ? "Free to run" : "Free to use"}</div>
</div>
{FOOT.map((c) => (
<div key={c.h}>
<div style={{ fontSize: 11.5, letterSpacing: "0.12em", textTransform: "uppercase", fontWeight: 800, borderBottom: "2px solid var(--color-text)", paddingBottom: 9 }}>{c.h}</div>
<div style={{ display: "flex", flexDirection: "column", gap: 10, marginTop: 14 }}>
{c.links.map(([t, href]) => <Link key={t} href={href} style={{ fontSize: 14, fontWeight: 600, textDecoration: "none", color: "var(--color-text)" }}>{t}</Link>)}
</div>
</div>
))}
</div>
<div style={{ display: "flex", justifyContent: "space-between", gap: 16, marginTop: 40, paddingTop: 18, borderTop: "1px solid var(--color-divider)", fontSize: 12, color: "var(--color-neutral-700)", flexWrap: "wrap" }}>
{/* Every public page is prerendered, so a year read from the build machines clock is both
wrong for the first ten hours of an Australian new year and then frozen at whatever the
last deploy baked in. The zone is pinned here; the (site) layout revalidates daily so a
site that goes a year without a deploy still turns over. */}
<div>© {facilityToday("Australia/Brisbane").slice(0, 4)} ThreadCount. All records belong to your facility.</div>
<div>threadcount.tech</div>
</div>
</div>
</footer>
);
}