ThreadCount Community edition
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
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 bar’s 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.`
|
||||
: "It’s 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 machine’s 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user