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:45:19 +10:00
commit 1bc2de655a
505 changed files with 56223 additions and 0 deletions
+85
View File
@@ -0,0 +1,85 @@
import Link from "next/link";
import { Band, CtaBand, SiteNav, body, h2, h3, kicker, wrap } from "@/components/site";
import { plansLive } from "@/lib/plans-live";
export const metadata = {
title: "Who built it",
description: "ThreadCount was written by a hospital uniform coordinator for their own linen room, because the workbook stopped being enough.",
alternates: { canonical: "/about" },
};
const STORY = [
"A hospital linen room runs on memory. Who has how many sets, which sizes are short, what was ordered three weeks ago and never arrived, which ward should be paying for it. Most of that lives in a workbook, a notepad, and the coordinators head.",
"That works until it doesnt. A staff member insists they were never issued a jacket. Finance asks why one wards spend doubled. A size runs out on a Monday morning and nobody knew it was low on Friday.",
"ThreadCount was built to answer those questions from a record rather than from recollection. Every garment is scanned to a named person, every issue starts its own replacement order, and every dollar lands on the ward that wore it. The features exist because each one solved a real morning at a real counter.",
"Its free. No licence, no per-device charge, no sales process. If its useful to your room, use it.",
];
// The last paragraph and the last principle once plans are live: the software is still free, and
// the sentence now says what is and isn't. Everything above them is unchanged — see lib/plans-live.ts.
const STORY_LAST_LIVE = "The software is free — run it yourself and it costs nothing. Hosting it for you is what costs money, and a small room is hosted free. If its useful to your room, use it.";
const PRINCIPLE_LAST_LIVE = { t: "Free to run, and portable", b: "The code is published, there is no lock-in, and paying for hosting never locks anything else. Every report and register exports as CSV, and one backup file takes the whole facility. Leaving is as easy as arriving." };
const PRINCIPLES = [
{ t: "Built at the counter", b: "Nothing in the product exists because it demonstrates well. Every screen was written to survive a queue of nurses at eight in the morning." },
{ t: "No paperwork tax", b: "The finance outputs are assembled from data already being entered. Theres no second system to keep fed." },
{ t: "Free, and portable", b: "No licence and no lock-in. Every report and register exports as CSV, and one backup file takes the whole facility. Leaving is as easy as arriving." },
];
export default async function About() {
const live = await plansLive();
const story = live ? [...STORY.slice(0, -1), STORY_LAST_LIVE] : STORY;
const principles = live ? [...PRINCIPLES.slice(0, -1), PRINCIPLE_LAST_LIVE] : PRINCIPLES;
return (
<>
<SiteNav />
{/* Half-and-half hero: type left, photograph right behind an ink border. */}
<section style={{ borderBottom: "2px solid var(--color-text)" }}>
<div className="tcm-split" style={{ display: "grid", gridTemplateColumns: "1fr 1fr" }}>
<div style={{ padding: "clamp(56px,7vw,88px) 40px clamp(52px,6vw,76px)", maxWidth: 760, marginLeft: "auto", width: "100%" }}>
<div style={kicker}>Who built it</div>
<h1 style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: "clamp(34px,4.4vw,64px)", lineHeight: 1.0, letterSpacing: "-0.035em", margin: "18px 0 0" }}>One coordinator, one linen room, one spreadsheet too many.</h1>
<p style={{ ...body, fontSize: 17, maxWidth: "46ch", margin: "22px 0 0" }}>ThreadCount was written by the person doing the job, for the job. It exists because the workbook stopped being enough.</p>
</div>
<div style={{ borderLeft: "2px solid var(--color-text)", minHeight: "clamp(280px,32vw,460px)", overflow: "hidden" }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img className="grayscale" src="/photos/tc-photo-kitchen.jpg" alt="" aria-hidden="true" style={{ width: "100%", height: "100%", objectFit: "cover", objectPosition: "center 40%", display: "block" }} />
</div>
</div>
</section>
<Band>
<div className="tcm-rowgrid" style={{ display: "grid", gridTemplateColumns: "200px 1fr", gap: 40 }}>
<div style={kicker}>The story</div>
<div style={{ maxWidth: "64ch" }}>
{story.map((p, i) => <p key={i} style={{ ...body, margin: i ? "18px 0 0" : 0 }}>{p}</p>)}
</div>
</div>
</Band>
<Band tone="ink" pad="clamp(48px,6vw,76px) 40px">
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: "clamp(24px,3.2vw,44px)", lineHeight: 1.15, letterSpacing: "-0.03em", maxWidth: "26ch", color: "#fff" }}>Every feature here started as an annoyance, not a roadmap item.</div>
</Band>
<Band tone="surface">
<div className="tcm-3col" style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 0, border: "2px solid var(--color-text)", background: "var(--color-bg)" }}>
{principles.map((p, i) => (
<div key={p.t} style={{ padding: "26px 28px 32px", borderRight: i < 2 ? "1px solid var(--color-divider)" : undefined }}>
<h3 style={{ ...h3, fontSize: 20 }}>{p.t}</h3>
<p style={{ fontSize: 14.5, lineHeight: 1.65, color: "var(--color-neutral-800)", marginTop: 10 }}>{p.b}</p>
</div>
))}
</div>
</Band>
<Band>
<div className="tcm-split" style={{ display: "grid", gridTemplateColumns: "1fr auto", gap: 32, alignItems: "center" }}>
<h2 style={{ ...h2, fontSize: "clamp(22px,2.6vw,34px)" }}>If your linen room, aged-care home or clinic has the same problem, come and say so.</h2>
<Link href="/contact" className="btn btn-primary">Get in touch</Link>
</div>
</Band>
<CtaBand />
</>
);
}
+9
View File
@@ -0,0 +1,9 @@
import LegalDoc from "@/components/LegalDoc";
import { DOC_META } from "@/lib/legal";
const M = DOC_META["Acceptable Use"];
export const metadata = { title: M.title, description: M.desc, alternates: { canonical: M.path } };
export default function Page() {
return <LegalDoc name="Acceptable Use" />;
}
+53
View File
@@ -0,0 +1,53 @@
import Link from "next/link";
import ContactForm from "@/components/ContactForm";
import { CtaBand, PageHead, SPLIT_PAD, SiteNav, body, h3, kicker, small, wrap } from "@/components/site";
export const metadata = {
title: "Contact",
description: "Ask a question about ThreadCount, book a walkthrough, or raise a security review. One person answers.",
alternates: { canonical: "/contact" },
};
export default function Contact() {
return (
<>
<SiteNav />
<PageHead kicker="Contact" title="Ask a question, or watch it run." lede="One person reads these — the one who built it. Youll get a straight answer rather than a sales call." />
<section style={{ borderBottom: "2px solid var(--color-text)" }}>
<div style={{ ...wrap, padding: 0 }}>
<div className="tcm-split" style={{ display: "grid", gridTemplateColumns: "1.25fr 1fr" }}>
<div className={SPLIT_PAD} style={{ paddingTop: "clamp(36px,4vw,56px)", paddingRight: 40, paddingBottom: "clamp(44px,5vw,64px)" }}>
<ContactForm />
</div>
<div style={{ borderLeft: "2px solid var(--color-text)" }}>
<div style={{ background: "var(--color-accent-600)", color: "#fff", padding: "30px 32px 34px" }}>
<div style={{ ...kicker, color: "#fff" }}>Book a walkthrough</div>
<h2 style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: "clamp(22px,2.4vw,30px)", letterSpacing: "-0.02em", margin: "10px 0 0" }}>Twenty minutes, on real screens.</h2>
<p style={{ fontSize: 15, lineHeight: 1.65, margin: "12px 0 0" }}>Bring your own questions and your own room. No slides unless you want them.</p>
{/* No direction: below 900px this panel stacks under the form, so there is no left. */}
<p style={{ fontSize: 13.5, lineHeight: 1.6, margin: "16px 0 0" }}>Pick &ldquo;Book a walkthrough&rdquo; on the form and say when suits I&rsquo;ll work around your shift rather than the other way round.</p>
</div>
<div style={{ padding: "28px 32px 34px" }}>
<div style={kicker}>Who replies</div>
<h3 style={{ ...h3, fontSize: 20, marginTop: 10 }}>One person, the one who built it.</h3>
<p style={{ ...body, fontSize: 15, marginTop: 10 }}>Usually within a working day. It&rsquo;s maintained alongside a day job, so nights and weekends aren&rsquo;t covered <Link href="/support" style={{ fontWeight: 700 }}>Support</Link> sets out what that means honestly.</p>
<div style={{ marginTop: 22, borderTop: "1px solid var(--color-divider)", paddingTop: 18 }}>
<div style={{ ...small }}>Prefer email? <a href="mailto:hello@threadcount.tech" style={{ fontWeight: 700 }}>hello@threadcount.tech</a> for questions, <a href="mailto:privacy@threadcount.tech" style={{ fontWeight: 700 }}>privacy@</a> for privacy requests, <a href="mailto:security@threadcount.tech" style={{ fontWeight: 700 }}>security@</a> for security reports.</div>
</div>
<div style={{ display: "flex", gap: 12, marginTop: 22, flexWrap: "wrap" }}>
<Link href="/demo" data-umami-event="open-demo" data-umami-event-placement="contact" className="btn btn-secondary">Open the working demo</Link>
<Link href="/faq" className="btn btn-ghost">Read the FAQ first</Link>
</div>
</div>
</div>
</div>
</div>
</section>
<CtaBand />
</>
);
}
+9
View File
@@ -0,0 +1,9 @@
import LegalDoc from "@/components/LegalDoc";
import { DOC_META } from "@/lib/legal";
const M = DOC_META["Data Security"];
export const metadata = { title: M.title, description: M.desc, alternates: { canonical: M.path } };
export default function Page() {
return <LegalDoc name="Data Security" />;
}
+162
View File
@@ -0,0 +1,162 @@
import Link from "next/link";
import { Band, CtaBand, PageHead, SiteNav, body, h2, h3, kicker } from "@/components/site";
/* Google Play requires a publicly reachable page that explains how to delete an account and what
happens to the data — reachable without signing in, which is why it lives on the marketing site
rather than inside the app. */
export const metadata = {
title: "Delete your account",
description: "How to delete a ThreadCount account or just some of its data, what goes with it, and what is kept.",
alternates: { canonical: "/delete-account" },
};
const KEPT = [
["Nothing, once the facility goes", "Deleting the last account deletes the facility outright. There is no archive copy and no soft-delete flag — the rows are gone from the database."],
["Your name on past records, if colleagues remain", "If someone else still runs the facility, only your login is deleted. Issues, stocktakes and slips you recorded keep the name they were stamped with, because a stock record nobody signed isnt an audit trail."],
["Backups you downloaded", "A backup file you exported before deleting stays on your own computer. Delete it yourself if you dont want it."],
["Encrypted server backups, briefly", "Routine server backups roll off on their own schedule. Nothing is restored from them except to recover the whole server after a failure."],
];
const STEPS = [
["Sign in at threadcount.tech", "Use the account you want to delete. Deletion is done by the account holder — there is no form to fill in and nobody to email."],
["Open Settings, then the Account tab", "Its the last section on that tab, under your profile, password and users."],
["Read which of the two cases youre in", "The page tells you whether youre the last person who can sign in. If you are, download a backup first — the link is right there."],
["Confirm", "Enter your password. If deleting takes the facility with it, you also type the facility name exactly. Then its done immediately."],
];
const PARTIAL: [string, string][] = [
["Remove one person from the staff register", "Settings isnt needed — open the staff member on the Staff Register and delete them. Their name stays on garments already issued, because a stock record nobody signed isnt an audit trail."],
["Wipe the activity, keep the setup", "Deletes every issue, return, order, delivery, pickup, stocktake, approval and photo, and resets stock adjustments. The catalogue, staff register, suppliers and departments stay, so the room can carry on from a clean ledger."],
["Start fresh", "Deletes everything the facility has entered — catalogue, barcodes, staff, departments and all history — and leaves only the logins and the facilitys own settings. Its the reset for a room that wants to begin again without losing its accounts."],
["Export first, if you want a copy", "Settings Data offers a complete JSON backup before you delete anything. It downloads to your own device and we keep no copy of it."],
];
export default function DeleteAccount() {
return (
<>
<SiteNav />
<PageHead
kicker="Your data"
title="Delete your account"
lede="You can delete a ThreadCount account yourself, from inside the app, without asking anyone — or delete data without closing the account at all. This page explains both, and exactly what goes with each."
/>
<Band>
<div style={wrapNarrow}>
<h2 style={h2}>How to do it</h2>
<ol style={{ ...body, marginTop: 28, padding: 0, listStyle: "none", display: "grid", gap: 0, borderTop: "2px solid var(--color-text)" }}>
{STEPS.map(([t, b], i) => (
<li key={t} style={{ display: "grid", gridTemplateColumns: "44px 1fr", gap: 20, padding: "22px 0", borderBottom: "1px solid var(--color-divider)", alignItems: "start" }}>
<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 22, color: "var(--color-neutral-600)", fontVariantNumeric: "tabular-nums" }}>{String(i + 1).padStart(2, "0")}</span>
<span><b style={{ fontSize: 17 }}>{t}</b><span style={{ display: "block", marginTop: 4 }}>{b}</span></span>
</li>
))}
</ol>
<p style={{ ...body, marginTop: 28 }}>
Can&apos;t sign in? Use the <Link href="/contact">contact form</Link> from the email address on the account and say which facility it belongs to.
</p>
</div>
</Band>
<Band tone="surface">
<div style={wrapNarrow}>
<div style={kicker}>If you use the staff app</div>
<h2 style={{ ...h2, marginTop: 14 }}>Ask the linen room it takes effect immediately</h2>
<p style={{ ...body, marginTop: 18 }}>
The steps above are for a linen-room coordinator. If you are a staff member who signs in
to <b>ThreadCount Staff</b> to see your own uniform, your account works differently,
because the record it attaches to belongs to the linen room rather than to you.
</p>
<p style={{ ...body, marginTop: 18 }}>
Ask your uniform coordinator to remove your access. There is a button on your staff
record that does exactly that: it deletes your sign-in, and it ends every session you
have open straight away. You can be given a fresh code later if you change your mind.
</p>
<p style={{ ...body, marginTop: 18 }}>
Your register entry and issue history stay behind, because the facility needs them for
its own stock and financial records the same way your employer keeps a record of any
equipment issued to you. Deleting those is the facility&apos;s decision, not something
one sign-in controls.
</p>
<p style={{ ...body, marginTop: 18 }}>
If you would rather not ask your coordinator, or you want a copy of what is held about
you first, write to <b>privacy@threadcount.tech</b> and we will route it through your
facility&apos;s privacy process.
</p>
</div>
</Band>
<Band tone="surface">
<div style={wrapNarrow}>
<div style={kicker}>The two cases</div>
<h2 style={{ ...h2, marginTop: 14 }}>What gets deleted depends on whether anyone else is left</h2>
<div style={{ display: "grid", gridTemplateColumns: "1fr", gap: 0, marginTop: 32, border: "2px solid var(--color-text)" }}>
<div style={{ padding: "26px 28px", borderBottom: "1px solid var(--color-divider)" }}>
<h3 style={h3}>Other people can still sign in</h3>
<p style={{ ...body, marginTop: 10 }}>
Only your login is deleted. The facility, its catalogue, staff register, stock and history all stay,
because they belong to the linen room rather than to you. Your name stays on what you recorded.
</p>
</div>
<div style={{ padding: "26px 28px", background: "var(--color-bg)" }}>
<h3 style={{ ...h3, color: "var(--color-accent-700)" }}>You are the last one</h3>
<p style={{ ...body, marginTop: 10 }}>
Deleting your account deletes the whole facility: every user, the catalogue and barcodes, the staff
register, every issue, return, order, delivery, stocktake, photo and signature. It happens immediately,
it cannot be undone, and support cannot get it back. Download a backup first if any of it matters.
</p>
</div>
</div>
</div>
</Band>
<Band tone="surface">
<div style={wrapNarrow}>
<div style={kicker}>Without closing your account</div>
<h2 style={{ ...h2, marginTop: 14 }}>Deleting some data, but not all of it</h2>
<p style={{ ...body, marginTop: 18 }}>
You don&apos;t have to delete your account to remove data. An administrator can do any of these from
Settings &rsaquo; Data, and keep working afterwards:
</p>
<ol style={{ ...body, marginTop: 24, padding: 0, listStyle: "none", borderTop: "2px solid var(--color-text)" }}>
{PARTIAL.map(([t, b], i) => (
<li key={t} style={{ display: "grid", gridTemplateColumns: "44px 1fr", gap: 20, padding: "22px 0", borderBottom: "1px solid var(--color-divider)", alignItems: "start" }}>
<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 22, color: "var(--color-neutral-600)", fontVariantNumeric: "tabular-nums" }}>{String(i + 1).padStart(2, "0")}</span>
<span><b style={{ fontSize: 17 }}>{t}</b><span style={{ display: "block", marginTop: 4 }}>{b}</span></span>
</li>
))}
</ol>
<p style={{ ...body, marginTop: 24 }}>
All of these happen immediately and can&apos;t be undone. None of them touches your login, so you stay
signed in and the facility keeps running.
</p>
</div>
</Band>
<Band>
<div style={wrapNarrow}>
<h2 style={h2}>What is kept, and for how long</h2>
<div style={{ marginTop: 28, borderTop: "2px solid var(--color-text)" }}>
{KEPT.map(([t, b]) => (
<div key={t} style={{ padding: "22px 0", borderBottom: "1px solid var(--color-divider)" }}>
<b style={{ fontSize: 17 }}>{t}</b>
<p style={{ ...body, marginTop: 4 }}>{b}</p>
</div>
))}
</div>
<p style={{ ...body, marginTop: 28 }}>
More detail on how data is held is in the <Link href="/privacy">privacy policy</Link> and{" "}
<Link href="/data-security">data security</Link> pages.
</p>
</div>
</Band>
<CtaBand
title="Deleting because something isnt working?"
lede="If ThreadCount isnt doing what your linen room needs, thats worth hearing before you delete anything. The contact form goes straight to the person who wrote it."
/>
</>
);
}
const wrapNarrow: React.CSSProperties = { maxWidth: 860 };
+66
View File
@@ -0,0 +1,66 @@
import Link from "next/link";
import { DEMO_FACILITY, DEMO_RESET_MINUTES } from "@/lib/demo";
import { CtaBand, SiteNav } from "@/components/site";
import { switches } from "@/lib/switches";
/* Rendered per request, unlike the rest of the site: the demo switch lives in the database now
* and can be flipped from the console, and a page cached for a day would keep offering "Enter as
* Admin" long after the entry endpoint had started answering 404. */
export const dynamic = "force-dynamic";
export const metadata = {
title: "Try the working demo",
description: "Open a stocked, fictional hospital linen room and use ThreadCount as an Admin or an Issuer. The same screens run an aged-care homes store or a clinics cupboard. No sign-up.",
alternates: { canonical: "/demo" },
};
const CARDS = [
{ as: "admin", role: "Admin", who: "Alex Demo · Uniform Coordinator", body: "The coordinators view. Settings, the catalogue and prices, the staff register, suppliers, reorder levels, every report and the month-end pack." },
{ as: "issuer", role: "Issuer", who: "Sam Demo · Linen Room Assistant", body: "The counter view. Issue stock, record a managers approval, run a stocktake, receive a delivery and work through the pickup call list." },
];
export default async function DemoPage({ searchParams }: { searchParams: Promise<{ signedin?: string }> }) {
const sp = await searchParams;
/* The same switch the entry endpoint honours (app/api/auth/demo/route.ts), read here too.
*
* With the demo out of service that endpoint answers a bare JSON 404, so a page that still
* offers "Enter as Admin" walks somebody weighing the product onto a white screen of raw JSON
* with no nav and no way back. Sign-ups are handled the same way — /auth hides the create-account
* form when sign-ups are closed rather than letting the button fail — and the demo has to go
* quiet at the same moment its endpoint does. */
const { demoOpen } = await switches();
return (
<>
<SiteNav />
<div style={{ maxWidth: 980, margin: "0 auto", padding: "56px 32px 80px" }}>
<div style={{ fontSize: 12, letterSpacing: "0.14em", textTransform: "uppercase", color: "var(--color-accent-700)", fontWeight: 700 }}>Working demo</div>
<h1 style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: "clamp(32px, 4vw, 52px)", letterSpacing: "-0.025em", lineHeight: 1.02, margin: "14px 0 0", textWrap: "balance" as never }}>{demoOpen ? <>Have a look around {DEMO_FACILITY}&rsquo;s linen room.</> : <>The demo is closed for the moment.</>}</h1>
<p style={{ fontSize: 16, lineHeight: 1.6, color: "var(--color-neutral-800)", maxWidth: 620, margin: "18px 0 0" }}>{demoOpen
? "It is a made-up hospital, but everything in it is real: a stocked shelf, staff on the register, orders still open with the supplier, people waiting on a pickup, and three months of issues behind it. It could as easily be an aged-care home or a day surgery — the shelf, the counter and the screens are the same. Pick a role and have a look around. Nothing to sign up for."
: `${DEMO_FACILITY} is a made-up hospital anyone can walk into, and it has been taken out of service for a while. Nothing is wrong with ThreadCount itself, and a facility of your own is unaffected.`}</p>
{sp.signedin === "1" && <div style={{ border: "2px solid var(--color-accent)", padding: "10px 14px", marginTop: 24, fontSize: 13, fontWeight: 600, color: "var(--color-accent-700)" }}>You&rsquo;re signed in to your own facility. <Link href="/app">Go to your app</Link>, or sign out first to open the demo.</div>}
{demoOpen ? <>
<div className="tc-grid" style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 0, marginTop: 36, border: "2px solid var(--color-text)" }}>
{CARDS.map((c, i) => (
<div key={c.as} style={{ padding: "28px 28px 32px", borderRight: i === 0 ? "1px solid var(--color-divider)" : undefined, display: "flex", flexDirection: "column" }}>
<div style={{ fontSize: 11, letterSpacing: "0.1em", textTransform: "uppercase", color: "var(--color-neutral-700)", fontWeight: 700 }}>{c.who}</div>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 26, marginTop: 8, borderTop: "4px solid var(--color-accent)", paddingTop: 10, display: "inline-block", alignSelf: "flex-start" }}>Enter as {c.role}</div>
<p style={{ fontSize: 14, lineHeight: 1.65, color: "var(--color-neutral-800)", margin: "10px 0 0", flex: 1 }}>{c.body}</p>
<a href={`/api/auth/demo?as=${c.as}`} className={"btn " + (i === 0 ? "btn-primary" : "btn-secondary")} style={{ marginTop: 22, alignSelf: "flex-start" }}>Open the {c.role} view</a>
</div>
))}
</div>
<div style={{ marginTop: 28, border: "2px solid var(--color-text)", padding: "18px 22px", fontSize: 13.5, lineHeight: 1.65, color: "var(--color-neutral-800)" }}>
<b style={{ color: "var(--color-text)" }}>How the demo works.</b> Everyone shares the one demo facility, so you might spot someone else&rsquo;s changes while you are in there. It goes back to the same starting point every {DEMO_RESET_MINUTES} minutes, so feel free to break things. Demo accounts can&rsquo;t change passwords or users, or wipe the data. Everything else issuing, ordering, receiving, stocktakes, reports, printing behaves exactly as it would in your own facility. None of it is real hospital data.
</div>
</> : (
<div style={{ marginTop: 36, border: "2px solid var(--color-text)", padding: "18px 22px", fontSize: 13.5, lineHeight: 1.65, color: "var(--color-neutral-800)" }}>
<b style={{ color: "var(--color-text)" }}>There is nothing to open just now.</b> The shared demo facility is off, so the two role buttons that normally sit here would only lead to a closed door. Until it is back, <Link href="/how-it-works" style={{ fontWeight: 700 }}>how it works</Link> walks through the same day at the counter, and <Link href="/features" style={{ fontWeight: 700 }}>the feature list</Link> covers what is in it. If you would rather be shown around, <Link href="/contact" style={{ fontWeight: 700 }}>ask for a look</Link>.
</div>
)}
<div style={{ marginTop: 28, fontSize: 14 }}>Want one of your own? <Link href="/auth?mode=signup" data-umami-event="signup-cta" data-umami-event-placement="demo-page" style={{ fontWeight: 700 }}>Set up your facility</Link> it takes about a minute, and your catalogue and staff register come in from CSV.</div>
</div>
<CtaBand />
</>
);
}
+72
View File
@@ -0,0 +1,72 @@
import Link from "next/link";
import { CtaBand, PageHead, SiteNav, body, h3, kicker, small } from "@/components/site";
import JsonLd from "@/components/JsonLd";
import { faqPage, graph } from "@/lib/schema";
import { PRICES } from "@/lib/plan";
import { plansLive } from "@/lib/plans-live";
export const metadata = {
title: "Questions",
description: "Is it really free, is it only for hospitals, what does it run on, do we need scanners, how do nursing entitlements work, where is our data, and what if we stop using it.",
alternates: { canonical: "/faq" },
};
/* [number, question, answer, plainAnswer?]
The fourth slot exists only for the answer whose JSX cant be serialised into
structured data. Everything else marks up exactly the string on the page. */
const faqs = (live: boolean): [string, string, React.ReactNode, string?][] => [
live
? ["01", "Is it really free?", `The software is. Run it on your own server and it costs nothing, with every feature. Hosted on threadcount.tech it is free for a room under ${PRICES.freeStaff} staff records, and $${PRICES.hostedAnnual.toLocaleString("en-AU")} a year for a facility past that — which pays for the hosting, the backups and a person who answers. It was built by a hospital uniform coordinator for their own room, and theres no sales process attached to it.`]
: ["01", "Is it really free?", "Yes. No licence fee, no per-device charge, no per-user charge. It was built by a hospital uniform coordinator for their own room, and theres no sales process attached to it."],
["02", "Is it only for hospitals?", "No. It was built in a hospital linen room, and that is the setting it knows best, but the loop is the same anywhere uniforms go out from a shelf: an aged-care home, a day surgery, a dental or GP practice, allied health, pathology, community and home care. One thing to know up front — the screens say ward and linen room, because that is the vocabulary it was written in."],
["03", "What does it run on?", "Any browser, on the phones, tablets and computers the room already has, and nothing needs installing to start. Camera scanning is the one part that depends on which browser: Chrome and Edge read a barcode straight from the camera, on Android and on a computer. Safari on an iPhone or iPad, and Firefox anywhere, cannot — there you type the code or use a USB scanner."],
["04", "Do we need barcode scanners?", "No, though a USB scanner is the fastest thing at a counter. It works with the supplier barcodes already printed on the garment, read by that scanner or by the phone camera in a browser that supports it. If a code isnt recognised, tell it once what the garment is and it stays bound."],
["05", "Can more than one person use it at a time?", "Yes. Access is per person, not per device, so the counter and the ward, wing or clinic can all be working without anyone waiting for a licence to free up."],
["06", "How does it handle nursing entitlements?", "Through the FTE table on the signed order form. The hours someone works propose their starting kit, and their manager can sign for more than it proposes. Nobody holds more than six sets at a time — or whatever ceiling you set — nurses included — past that it takes a hand-in or a coordinators recorded override. You choose which staff groups use the table; the others start on a fixed kit, or have a manager approve each set."],
["07", "What happens when the box arrives short?", "Receive the lines that came, and the shortfall splits automatically to a back order against the same supplier reference. Nothing has to be re-keyed."],
["08", "Will finance accept the export?", "The journal comes out as one debit line per cost centre against your GL account, in CSV. Most finance teams upload it directly; if yours needs a different layout, say so."],
["09", "Where is our data held, and who can see it?", <>Only the people you invite can see your room&rsquo;s records, and nothing is pooled with another facility. Hosting, backup and retention should be agreed with your information security team before real staff data goes in there&rsquo;s more in <Link href="/security" style={{ fontWeight: 700 }}>Security &amp; data</Link>.</>,
"Only the people you invite can see your rooms records, and nothing is pooled with another facility. Hosting, backup and retention should be agreed with your information security team before real staff data goes in."],
["10", "What if we want to stop using it?", "Every report and register exports as CSV. An admin can also download the whole facility as one backup file, every issue, order and count included. Take the data and go: theres nothing to cancel and nothing held back."],
];
export default async function Faq() {
const FAQS = faqs(await plansLive());
return (
<>
<SiteNav />
<JsonLd
data={graph(
faqPage(
FAQS.filter(([, , a, plain]) => typeof a === "string" || plain)
.map(([, q, a, plain]) => ({ q, a: plain ?? (a as string) })),
),
)}
/>
<PageHead kicker="Questions" title="The ones people actually ask." />
{/* Deliberately narrower than the rest of the site. */}
<section style={{ borderBottom: "2px solid var(--color-text)" }}>
<div style={{ maxWidth: 1040, margin: "0 auto", padding: "clamp(28px,4vw,44px) 40px clamp(48px,6vw,72px)" }}>
{FAQS.map(([n, q, a]) => (
<div key={n} style={{ display: "grid", gridTemplateColumns: "44px 1fr", gap: 20, padding: "34px 0", borderBottom: "1px solid var(--color-divider)" }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 15, color: "var(--color-neutral-600)" }}>{n}</div>
<div>
<h2 style={{ ...h3, fontSize: "clamp(19px,2vw,25px)", margin: 0 }}>{q}</h2>
<p style={{ ...body, margin: "10px 0 0", maxWidth: "66ch" }}>{a}</p>
</div>
</div>
))}
<div style={{ marginTop: 40, border: "2px solid var(--color-text)", padding: "26px 30px 30px" }}>
<div style={kicker}>Something not covered here?</div>
<h2 style={{ ...h3, fontSize: "clamp(20px,2.2vw,27px)", marginTop: 10 }}>Ask it directly.</h2>
<div style={{ ...small, marginTop: 8, maxWidth: "56ch" }}>Replies come from the person who built the thing, so the answer will be straight.</div>
<Link href="/contact" className="btn btn-primary" style={{ marginTop: 20, display: "inline-block" }}>Ask a question</Link>
</div>
</div>
</section>
<CtaBand />
</>
);
}
+95
View File
@@ -0,0 +1,95 @@
import Link from "next/link";
import { Band, CtaBand, PageHead, SiteNav, body, h2, h3, kicker, small } from "@/components/site";
export const metadata = {
title: "Features",
description: "Nine things a linen room does every day, in a hospital, an aged-care home or a clinic: scanning, the pickup call list, supplier orders, stocktakes, allowances set per staff group, roles, staff records, catalogue and suppliers.",
alternates: { canonical: "/features" },
};
const FEATURES = [
{ n: "02", t: "The pickup call list", b: "Staff orders that have arrived queue by days waiting. Ring them, mark them contacted, print the collection slip, tick them off when they take it." },
{ n: "03", t: "Orders you can trace back", b: "Supplier order number, invoice, tracking, what actually turned up on each line, a purchase order you can print, and a back order raised for whatever the box was short." },
{ n: "04", t: "Stocktakes that stay filed", b: "Scan to count, with a blind mode when you would rather not see the system figure while you count. Variances in dollars as you go, and each count kept with the date, who counted, and every line that moved." },
{ n: "05", t: "Three routes, one ceiling", b: "Name your own staff groups and give each one a route. On the FTE table, the hours someone works propose their starting kit and a manager can sign for more. On a starting kit, they get a fixed number of sets on day one and more as they need them. On manager approval, a manager signs for each set. Every route stops at six sets held at any one time, unless you set a different figure. Past it takes a hand-in or a coordinators recorded override." },
{ n: "06", t: "Two roles, and only two", b: "Admins look after settings, staff, the catalogue, suppliers and prices. Issuers issue, count and receive. Nobody edits a price or a past issue by accident, because Issuers simply cant." },
{ n: "07", t: "Staff records with size history", b: "Every staff member carries their ward or clinic, cost centre, staff group and the sizes they were last issued, so the second visit takes seconds." },
{ n: "08", t: "Catalogue and suppliers together", b: "Garments, sizes, unit costs and supplier codes live in one place. Change a price once and every future issue values correctly — past issues keep the price they were recorded at." },
{ n: "09", t: "Nothing to install", b: "It runs in the browser on the devices the room already has. Chrome and Edge — on Android and on a computer — read garment barcodes straight from the camera with nothing extra installed. On an iPhone or iPad, and in Firefox, the code is typed or read with a USB scanner." },
];
export default function Features() {
return (
<>
<SiteNav current="features" />
<PageHead
kicker="Features"
tone="ink"
title="Built for a busy Monday morning."
right={<p style={{ fontSize: 16.5, lineHeight: 1.7, color: "var(--color-neutral-200)", maxWidth: "42ch", margin: 0 }}>Nine things the room actually does every day, and nothing it doesn&rsquo;t. No modules to buy, and no setting up before you can issue a scrub top.</p>}
/>
<Band pad="0 40px 0">
{/* Row 01 is the emphasised one — bigger numeral, its own kicker. */}
<div className="tcm-rowgrid" style={{ display: "grid", gridTemplateColumns: "110px 1fr 1.1fr", gap: 32, padding: "48px 0 44px", borderBottom: "1px solid var(--color-divider)" }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 56, lineHeight: 1, color: "var(--color-accent)" }}>01</div>
<div>
<div style={kicker}>The difference</div>
<h2 style={{ ...h3, fontSize: "clamp(22px,2.4vw,30px)", marginTop: 8 }}>Scan-first, at the counter and out on the floor</h2>
</div>
<p style={{ ...body, margin: 0, maxWidth: "52ch" }}>It reads the barcodes already printed on the garment labels, using a USB scanner at the counter, or your phone camera out on a ward, a wing or a clinic room in a browser that can read one Chrome and Edge can; Safari on an iPhone or iPad cannot. Scan something it doesn&rsquo;t know and you tell it once what it is; after that it just knows. Issuing, counting and receiving are all the same scan.</p>
</div>
{FEATURES.map((f) => (
<div key={f.n} className="tcm-rowgrid" style={{ display: "grid", gridTemplateColumns: "110px 1fr 1.1fr", gap: 32, padding: "34px 0", borderBottom: "1px solid var(--color-divider)" }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 40, lineHeight: 1, color: "var(--color-neutral-600)" }}>{f.n}</div>
<h2 style={{ ...h3, fontSize: "clamp(19px,2vw,25px)", margin: 0 }}>{f.t}</h2>
<p style={{ ...body, fontSize: 15.5, margin: 0, maxWidth: "52ch" }}>{f.b}</p>
</div>
))}
<div style={{ height: 24 }} />
</Band>
{/* Slips band with the recreated collection slip */}
<Band tone="surface">
<div className="tcm-split" style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 56, alignItems: "center" }}>
<div>
<h2 style={h2}>Slips come out filled in.</h2>
<p style={{ ...body, marginTop: 18, maxWidth: "46ch" }}>Collection slips, ward delivery notes and the credit slips against a manager&rsquo;s approval print with the staff member, ward, sets and order reference already on them. Whoever takes the round can sign the paper, or sign on screen when you hand it over; an on-screen signature is kept with that delivery and deleted with it.</p>
</div>
<div style={{ border: "2px solid var(--color-text)", background: "var(--color-bg)" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "10px 16px", borderBottom: "2px solid var(--color-text)" }}>
<b style={{ fontSize: 13 }}>Collection slip 04 Sep 2026</b><span className="tag tag-outline">Print</span>
</div>
<div style={{ padding: "14px 16px", fontSize: 13 }}>
{[["Staff", "M. Whitfield, RN"], ["Ward", "3A · cost centre RGH-3010"], ["Approved sets", "3 of 5 · managers approval on file"]].map(([k, v]) => (
<div key={k} style={{ display: "flex", justifyContent: "space-between", gap: 16, padding: "6px 0", borderBottom: "1px solid var(--color-divider)" }}><span style={{ color: "var(--color-neutral-700)" }}>{k}</span><b>{v}</b></div>
))}
<div style={{ marginTop: 12 }}>
{[["RN Active Scrub Top · M ×2", "From stock", false], ["Elastic Waist Scrub Pant · M ×2", "From stock", false], ["Softshell Jacket · M ×1", "Order in", true]].map(([n, s, red]) => (
<div key={String(n)} style={{ display: "flex", justifyContent: "space-between", gap: 16, padding: "6px 0", borderBottom: "1px solid var(--color-divider)" }}><span>{n}</span><b style={red ? { color: "var(--color-accent-700)" } : undefined}>{s}</b></div>
))}
</div>
<div style={{ marginTop: 18, color: "var(--color-neutral-700)" }}>Signature ______________________</div>
</div>
</div>
</div>
</Band>
<Band>
<div className="tcm-split" style={{ display: "grid", gridTemplateColumns: "1fr auto", gap: 32, alignItems: "center" }}>
<div>
<h2 style={{ ...h2, fontSize: "clamp(22px,2.6vw,34px)" }}>See it running with demonstration data.</h2>
<div style={{ ...small, marginTop: 10 }}>A stocked shelf, staff on the register, orders open and three months of history behind it.</div>
</div>
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
<Link href="/demo" data-umami-event="open-demo" data-umami-event-placement="features" className="btn btn-primary">Open the working demo</Link>
<Link href="/how-it-works" className="btn btn-secondary">How it works</Link>
</div>
</div>
</Band>
<CtaBand />
</>
);
}
+80
View File
@@ -0,0 +1,80 @@
import Link from "next/link";
import { Band, CtaBand, PageHead, SiteNav, body, h2, h3, kicker } from "@/components/site";
export const metadata = {
title: "Getting started",
description: "Five steps from opening the demo to issuing at the counter, with an honest time estimate against each one. The same five whether the shelf is a linen room or a store cupboard.",
alternates: { canonical: "/getting-started" },
};
const STEPS = [
{ n: "1", t: "Open the demo and have a look", b: "Everything works on demonstration data. Issue a set, receive a box, run a report. Nothing you do here touches a real record.", time: "20 minutes" },
{ n: "2", t: "Load the catalogue", b: "Garments, sizes, unit costs and supplier codes. This is the one piece worth doing carefully, because every report values from it.", time: "An hour" },
{ n: "3", t: "Load the staff list", b: "Name your staff groups in Settings and choose each ones route first, then load the list: name, department, cost centre and group. Sizes fill in as people are issued, so dont hold up the start waiting for them.", time: "An hour" },
{ n: "4", t: "Count whats on the shelf", b: "Run a stocktake as your opening balance. From that point on-hand is live and reorder flags start working.", time: "An afternoon" },
{ n: "5", t: "Start issuing", b: "Thats it. The first replenishment order builds itself from the first days movement.", time: "Same day" },
];
const READY = [
"The garment list with sizes and current unit costs",
"Supplier names, codes and order contacts",
"The staff list with departments and cost centres",
"Your GL account for uniform spend",
"Whoever signs off entitlements for each team",
];
export default function GettingStarted() {
return (
<>
<SiteNav />
<PageHead
kicker="Getting started"
title="Issuing by the end of the first afternoon."
lede="Theres no implementation project. Five steps, done in the order below, and the counter is live."
/>
<Band pad="0 40px 0">
{STEPS.map((s) => (
<div key={s.n} className="tcm-rowgrid" style={{ display: "grid", gridTemplateColumns: "120px 1fr 180px", gap: 32, padding: "34px 0", borderBottom: "1px solid var(--color-divider)", alignItems: "start" }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 56, lineHeight: 0.9, color: "var(--color-accent)" }}>{s.n}</div>
<div>
<h2 style={{ ...h3, fontSize: "clamp(19px,2vw,25px)", margin: 0 }}>{s.t}</h2>
<p style={{ ...body, fontSize: 15.5, margin: "10px 0 0", maxWidth: "56ch" }}>{s.b}</p>
</div>
<div style={{ textAlign: "right" }}>
<div style={{ ...kicker, color: "var(--color-neutral-700)", fontSize: 11 }}>About</div>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 18, marginTop: 4 }}>{s.time}</div>
</div>
</div>
))}
<div style={{ height: 22 }} />
</Band>
<Band tone="surface">
<h2 style={h2}>What to have ready.</h2>
<p style={{ ...body, marginTop: 14, maxWidth: "56ch" }}>None of it is hard to find. Most linen rooms and store cupboards already have all five in a workbook somewhere.</p>
<div style={{ marginTop: 26 }}>
{READY.map((r) => (
<div key={r} style={{ display: "flex", gap: 16, padding: "13px 0", borderBottom: "1px solid var(--color-divider)", fontSize: 16 }}>
<span style={{ width: 8, height: 8, background: "var(--color-accent)", flex: "none", marginTop: 8 }} />{r}
</div>
))}
</div>
</Band>
<Band>
<div style={{ border: "2px solid var(--color-text)", padding: "28px 32px 32px" }}>
<div style={kicker}>One thing first</div>
<h2 style={{ ...h2, fontSize: "clamp(22px,2.6vw,34px)", marginTop: 10 }}>Talk to information security before real staff data goes in.</h2>
<p style={{ ...body, marginTop: 14, maxWidth: "58ch" }}>Try the demo with demonstration data as long as you like. The moment you want your own staff list in it, hosting, backup and retention should be agreed with your facility.</p>
<div style={{ display: "flex", gap: 12, marginTop: 24, flexWrap: "wrap" }}>
<Link href="/demo" data-umami-event="open-demo" data-umami-event-placement="getting-started" className="btn btn-primary">Open the working demo</Link>
<Link href="/security" className="btn btn-secondary">Security &amp; data</Link>
</div>
</div>
</Band>
<CtaBand />
</>
);
}
@@ -0,0 +1,109 @@
import Link from "next/link";
import { Band, body, small } from "@/components/site";
import { GuideFoot, GuideHead, GuideMeta, Pitfalls, Steps, type Step } from "@/components/guide";
export const metadata = {
title: "Charging uniforms to the right cost centre",
description:
"How to attribute uniform spend to the ward, wing or clinic that incurred it: what to capture at the counter, how to value an issue, and what finance needs in the journal before they will accept it.",
alternates: { canonical: "/guides/cost-centre-reporting" },
};
const STEPS: Step[] = [
{
n: "01",
h: "Attribute at the moment of issue, not at month end",
p: <>The only person who reliably knows which ward, wing or clinic a garment is for is the person handing it over. Reconstructing that from invoices four weeks later is guesswork, and it is the reason most linen rooms end up charging everything to one central code. Capture the cost centre when the garment leaves the counter and the month-end job becomes an export rather than an investigation.</>,
},
{
n: "02",
h: "Hang the cost centre off the person, not the transaction",
p: <>Asking &ldquo;which cost centre?&rdquo; at every issue is a question that will be answered wrong under pressure. Put the ward and cost centre on the staff record, so the issue inherits it. The counter stays fast, and corrections happen once on the register rather than repeatedly at the counter.</>,
},
{
n: "03",
h: "Decide what an issue is worth",
p: <>Unit cost is the defensible answer: what you paid the supplier for that garment, per unit. Avoid apportioning freight and avoid an average across sizes if your sizes genuinely differ in price. The figure needs to be one you can explain in a sentence, because at some point someone will ask you to.</>,
},
{
n: "04",
h: "Decide when the charge lands",
p: <>There are two honest answers and you must pick one: at purchase, or at issue. Charging at purchase makes the linen room&rsquo;s budget lumpy and makes wards indifferent to what they take. Charging at issue is what most facilities want the ward or clinic feels the cost of its own consumption but it means your stock on hand is an asset carried by the linen room until it moves. Pick one, write it down, and don&rsquo;t quietly change it mid-year.</>,
},
{
n: "05",
h: "Produce one line per cost centre",
p: <>Finance does not want your transaction list. They want a journal: one debit line per cost centre, one credit to the GL account the stock was bought against, for a stated period, totalling to a number that matches. Give them exactly that as CSV and the upload takes minutes. Give them a spreadsheet of every issue and it will sit in an inbox.</>,
},
{
n: "06",
h: "Keep the detail behind the summary",
p: <>The summary is what gets uploaded; the detail is what settles the argument when a ward manager queries their figure. You want to be able to go from &ldquo;Ward 3A, $1,840&rdquo; to the individual issues behind it without rebuilding anything. If your summary can&rsquo;t be drilled into, expect to spend the following week defending it.</>,
},
];
const PITFALLS: [string, string][] = [
["One catch-all cost centre", "Everything charged centrally means no ward, wing or clinic ever sees the cost of its own uniform consumption, so nothing ever changes."],
["Cost centres that only exist in someones head", "If the mapping from ward or team to code lives in memory, it leaves when that person does. Put it on the record."],
["Charging at purchase and at issue", "Double-counting is the fastest way to lose finances trust, and it is easy to do accidentally when the method changes mid-year."],
["Retail price instead of unit cost", "There is no margin here. Anything other than what you paid invites a question you cannot answer."],
["No period stamped on the export", "A journal without an unambiguous date range cannot be reconciled and will be sent back."],
["Rounding per line", "Round the total, not each line, or the journal wont balance against the invoice and someone will spend an afternoon on eleven cents."],
];
export default function Page() {
return (
<>
<GuideMeta
slug="cost-centre-reporting"
title="Charging uniforms to the right cost centre"
description={metadata.description}
updated="2026-09-07"
/>
<GuideHead
kicker="Guide"
title="Charging uniforms to the right cost centre"
lede="Uniform spend that lands in one central code tells nobody anything. Attributing it properly is mostly a matter of capturing one field at the right moment."
/>
<Band>
<Steps steps={STEPS} />
</Band>
<Band tone="surface">
<div style={{ fontSize: 11.5, letterSpacing: "0.12em", textTransform: "uppercase", fontWeight: 800, borderBottom: "2px solid var(--color-text)", paddingBottom: 9 }}>
What finance sends back
</div>
<Pitfalls items={PITFALLS} />
</Band>
<Band>
<h2 style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: "clamp(24px,3vw,36px)", letterSpacing: "-0.02em", margin: 0 }}>
Doing this in ThreadCount
</h2>
<p style={{ ...body, margin: "16px 0 0", maxWidth: "62ch" }}>
Ward and cost centre sit on the staff record, so every issue values itself at unit cost
against the right code without anyone being asked at the counter. The journal exports as
one debit line per cost centre for the month you choose, and each line opens the issues
behind it who, what, which size, what it cost when someone queries their
number.
</p>
<p style={{ ...small, margin: "14px 0 0", maxWidth: "62ch" }}>
If your finance team needs a different layout, that is usually a small change say what
they need.
</p>
<div style={{ display: "flex", gap: 12, marginTop: 24, flexWrap: "wrap" }}>
<Link href="/reporting" className="btn btn-primary">See the reporting</Link>
<Link href="/contact" className="btn btn-secondary">Ask about a layout</Link>
</div>
</Band>
<GuideFoot
related={[
["Staff entitlements and manager approvals", "/guides/manager-approvals"],
["How to run a uniform stocktake", "/guides/uniform-stocktake"],
]}
/>
</>
);
}
@@ -0,0 +1,113 @@
import Link from "next/link";
import { Band, body, small } from "@/components/site";
import { GuideFoot, GuideHead, GuideMeta, Pitfalls, Steps, type Step } from "@/components/guide";
export const metadata = {
title: "Staff uniform entitlements and manager approvals",
description:
"How to run a uniform entitlement that holds, on a ward or in an aged-care home: what an approval needs to record, how to handle part-time and agency staff, what to do when someone asks for more.",
alternates: { canonical: "/guides/manager-approvals" },
};
const STEPS: Step[] = [
{
n: "01",
h: "Write the entitlement down before you need it",
p: <>An entitlement that exists only as custom is one you will renegotiate at the counter, individually, forever. Agree the number of sets per role, put it somewhere both the linen room and the wards, homes or clinics it serves can see, and date it. The specific number matters far less than everyone knowing what it is.</>,
},
{
n: "02",
h: "Prorate honestly, or dont prorate at all",
p: <>Part-time staff are where entitlement schemes get messy. A nurse at 0.4 FTE or a care worker on two shifts a week, or a dental assistant covering Fridays still needs enough sets to get through a week without doing laundry nightly, so a straight multiplication produces something unworkable and quietly ignored. Either set a floor below which nobody drops, or don&rsquo;t prorate and say so. What you cannot do is have a rule on paper that the counter overrides in practice, because then there is no rule.</>,
},
{
n: "03",
h: "Make the approval carry its own evidence",
p: <>An approval should record who approved it, what they approved, for whom, and when. The approver is the staff member&rsquo;s own manager the ward, clinic or team manager. &ldquo;The manager said it was fine&rdquo; is not an approval, and it is exactly what you will be holding when someone asks why a ward went over budget. Name, number of sets, FTE if it bears on the calculation, and a date.</>,
},
{
n: "04",
h: "Separate the entitlement from the issue",
p: <>Someone can be entitled to five sets and take three today. Track the entitlement and the issues against it as different things, so the balance is visible. Otherwise the only way to know what someone is still owed is to add up their history, and nobody does that at a counter with three people waiting.</>,
},
{
n: "05",
h: "Give them the balance in writing",
p: <>When a staff member takes less than their entitlement because the size isn&rsquo;t there, or they only wanted two hand over something that records the balance. A credit slip stops the same conversation happening again in a fortnight with a different person at the counter, and it stops the quiet inflation that happens when nobody can remember what was already given.</>,
},
{
n: "06",
h: "Decide what happens when someone asks for more",
p: <>They will, and often for good reason: a garment condemned after a spill, a size change, a genuine increase in hours. Have a route that isn&rsquo;t &ldquo;no&rdquo; and isn&rsquo;t &ldquo;yes, quietly&rdquo;. An over-entitlement issue with a recorded approval keeps the room helpful and the numbers honest at the same time.</>,
},
{
n: "07",
h: "Handle agency and students explicitly",
p: <>Short-term staff are the fastest route to unreturned stock, because there is often nobody to ask afterwards. Decide in advance whether they get issued at all, and if so whether it is a loan against a return date. Whatever you decide, decide it once rather than at the counter.</>,
},
];
const PITFALLS: [string, string][] = [
["Verbal approvals", "Nothing to show and nothing to reconcile. The first budget query will land on the linen room, not on the person who approved it."],
["Entitlement with no start date", "Without a date you cannot tell an annual renewal from a duplicate issue, and long-serving staff quietly accumulate."],
["Prorating below a workable minimum", "A rule that makes it impossible to get through a week will be broken at the counter, and then no rule is being followed at all."],
["No record of whats outstanding", "If the balance isnt visible, the safe answer at the counter is always to hand over another set."],
["Treating a size change as a new issue", "It doubles the persons apparent consumption and hides a real signal about how youre buying sizes."],
["No closing routine for leavers", "The entitlement was correct; nobody asked for the garments back. This is the single largest source of unreturned stock in most rooms."],
];
export default function Page() {
return (
<>
<GuideMeta
slug="manager-approvals"
title="Staff uniform entitlements and manager approvals"
description={metadata.description}
updated="2026-09-07"
/>
<GuideHead
kicker="Guide"
title="Entitlements and manager approvals"
lede="Most disputes at a uniform counter are not about uniforms. They are about a rule nobody wrote down, applied inconsistently by people trying to be helpful."
/>
<Band>
<Steps steps={STEPS} />
</Band>
<Band tone="surface">
<div style={{ fontSize: 11.5, letterSpacing: "0.12em", textTransform: "uppercase", fontWeight: 800, borderBottom: "2px solid var(--color-text)", paddingBottom: 9 }}>
Where entitlements come apart
</div>
<Pitfalls items={PITFALLS} />
</Band>
<Band>
<h2 style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: "clamp(24px,3vw,36px)", letterSpacing: "-0.02em", margin: 0 }}>
Doing this in ThreadCount
</h2>
<p style={{ ...body, margin: "16px 0 0", maxWidth: "62ch" }}>
Entitlement sits on the staff record and the balance is on screen at the counter, so the
person handing over garments can see what is still owed without adding anything up. An
approval records the approver, the sets approved and the FTE it was based on. Take less
than the entitlement and a credit slip prints for the balance.
</p>
<p style={{ ...small, margin: "14px 0 0", maxWidth: "62ch" }}>
Going over is allowed it just asks for the approval first, which is the only difference
between a helpful exception and an unexplained one.
</p>
<div style={{ display: "flex", gap: 12, marginTop: 24, flexWrap: "wrap" }}>
<Link href="/demo" data-umami-event="open-demo" data-umami-event-placement="guide" className="btn btn-primary">Open the demo</Link>
<Link href="/how-it-works" className="btn btn-secondary">How issuing works</Link>
</div>
</Band>
<GuideFoot
related={[
["Where hospital and aged-care uniforms actually go", "/guides/uniform-loss"],
["Charging uniforms to the right cost centre", "/guides/cost-centre-reporting"],
]}
/>
</>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { permanentRedirect } from "next/navigation";
// The guide moved to /guides/manager-approvals once the approval stopped being a nursing-only
// idea. This is a 308 rather than the 307 /product uses: that path was live only briefly before
// launch, whereas this one has been in the sitemap, in cold emails and in search results for
// months. The move is permanent, so say so — search engines hand the ranking to the new URL and
// browsers stop asking for the old one.
export default function NumApprovalsRedirect() {
permanentRedirect("/guides/manager-approvals");
}
+74
View File
@@ -0,0 +1,74 @@
import Link from "next/link";
import { Band, CtaBand, PageHead, SiteNav, body, small } from "@/components/site";
export const metadata = {
title: "Guides",
description:
"Notes on running a linen room — hospital, aged care or clinic: stocktakes that produce a usable number, where uniforms actually go, charging spend to the right ward, and entitlements that hold up.",
alternates: { canonical: "/guides" },
};
/* Written for the person doing the job, not for the person buying software. Each of these is
useful with a clipboard and no product at all — which is the only reason anyone would link to
them. */
export const GUIDES: [string, string, string][] = [
[
"/guides/uniform-stocktake",
"How to run a uniform stocktake",
"What to count, how to split a room into shelves, what to do about garments at the laundry, and how to record a variance so the number still means something a month later.",
],
[
"/guides/uniform-loss",
"Where the uniforms actually go",
"Loss is rarely theft. Leavers nobody closed off, sizes swapped at the shelf, laundry that never came back, damage never written off — and how to tell them apart.",
],
[
"/guides/cost-centre-reporting",
"Charging uniforms to the right cost centre",
"Capturing the cost centre at the counter, valuing an issue defensibly, and producing a journal finance will actually accept.",
],
[
"/guides/manager-approvals",
"Entitlements and manager approvals",
"Writing the entitlement down, prorating part-time hours honestly, recording an approval that carries its own evidence, and what to do when someone asks for more.",
],
];
export default function Guides() {
return (
<>
<SiteNav />
<PageHead
kicker="Guides"
title="How the job is actually done."
lede="Notes from running a hospital linen room. What follows works the same in an aged-care home, a day surgery or a clinic cupboard. No product required — take the method and use a clipboard if that is what you have."
/>
<Band>
{GUIDES.map(([href, title, blurb], i) => (
<Link
key={href}
href={href}
style={{ textDecoration: "none", color: "var(--color-text)", display: "grid", gridTemplateColumns: "44px 1fr", gap: 20, padding: "32px 0", borderBottom: "1px solid var(--color-divider)" }}
>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 15, color: "var(--color-neutral-600)" }}>
{String(i + 1).padStart(2, "0")}
</div>
<div>
<h2 style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: "clamp(20px,2.2vw,28px)", letterSpacing: "-0.02em", margin: 0 }}>
{title}
</h2>
<p style={{ ...body, margin: "10px 0 0", maxWidth: "64ch" }}>{blurb}</p>
</div>
</Link>
))}
<p style={{ ...small, margin: "26px 0 0", maxWidth: "58ch" }}>
Something missing that you had to work out the hard way?{" "}
<Link href="/contact" style={{ fontWeight: 700 }}>Tell us</Link> and it will get written up.
</p>
</Band>
<CtaBand />
</>
);
}
+104
View File
@@ -0,0 +1,104 @@
import Link from "next/link";
import { Band, body, small } from "@/components/site";
import { GuideFoot, GuideHead, GuideMeta, Pitfalls, Steps, type Step } from "@/components/guide";
export const metadata = {
title: "Where hospital and aged-care uniforms actually go",
description:
"Uniform loss is rarely theft. The honest causes — starters who never return a set, sizes swapped at the shelf, laundry that never comes back, leavers nobody closed off — and what to record so you can tell them apart.",
alternates: { canonical: "/guides/uniform-loss" },
};
const STEPS: Step[] = [
{
n: "01",
h: "Stop calling it shrinkage",
p: <>Borrowing the retail word imports the retail assumption, which is that stock is being stolen. In a linen room that is almost never the main cause, and starting there sours every conversation you need to have with the ward, wing or practice manager. What you have is unreturned stock, and most of it left through a door you can name.</>,
},
{
n: "02",
h: "Leavers who were never closed off",
p: <>The single largest cause in most rooms. Someone is issued three sets on their first day, works for two years, resigns, and nobody tells the linen room. The sets are gone and the record still shows them holding stock. This is a process gap, not a stock problem: what you need is a reliable signal from HR, the ward or the practice when someone leaves, and a routine for closing the record and asking for the garments back while there is still someone to ask.</>,
},
{
n: "03",
h: "Size changes that were never recorded",
p: <>A staff member takes a medium, finds it tight, and swaps it at the shelf for a large. Two garments have now moved and the record shows one. Do it a hundred times a year and your per-size figures drift far enough that ordering becomes guesswork. The fix is cheap: make the exchange a recorded action the old size back, the new size out, in one movement rather than something people do quietly because the proper route is slow.</>,
},
{
n: "04",
h: "Laundry that never came back",
p: <>Garments sent for washing are stock in transit, and transit is where things vanish without anyone noticing, because nobody owns the gap. If you send in bulk and receive in bulk, count both ends. A persistent difference between what went and what returned is a conversation with the laundry provider, and it is a conversation you can only have with numbers.</>,
},
{
n: "05",
h: "Damage that was never written off",
p: <>Torn, stained beyond use, or condemned on infection-control grounds. This is legitimate loss and should be recorded as such. If it isn&rsquo;t, it lands in the same bucket as unreturned stock and makes your genuine problem look worse than it is which costs you credibility exactly when you are asking for something.</>,
},
{
n: "06",
h: "Then, and only then, the small remainder",
p: <>When the four causes above are recorded properly, what is left is usually a modest number. That remainder is worth attention, and it is now attention you can direct, because you know it is not laundry, not leavers, not swaps and not damage.</>,
},
];
const PITFALLS: [string, string][] = [
["Issuing to a ward instead of a person", "Stock issued to “Ward 3A” can never be returned by anyone, because nobody holds it. Issue to a named person or accept that it is a write-off."],
["No opening entitlement", "If nobody agreed how many sets a role gets, there is no such thing as too many, and every request is reasonable."],
["Counting loss only in units", "A hundred lost XS gowns and a hundred lost theatre sets are the same number and very different money. Value it, or the case for change wont land."],
["Chasing individuals first", "Going after named staff before fixing the leaver process makes the room unpopular and recovers very little. Fix the doors before the people."],
["Annual review only", "A number produced once a year is a post-mortem. Monthly, it is a control, and you still remember what happened."],
["No agreed definition of loss", "If finance, the ward and the linen room each mean something different by it, the meeting is about definitions rather than uniforms."],
];
export default function Page() {
return (
<>
<GuideMeta
slug="uniform-loss"
title="Where hospital and aged-care uniforms actually go"
description={metadata.description}
updated="2026-09-07"
/>
<GuideHead
kicker="Guide"
title="Where the uniforms actually go"
lede="Almost every linen room — in a hospital, an aged-care home or a clinic — has a gap between what it bought and what it can find. Naming the causes honestly is what turns that gap from an accusation into a work plan."
/>
<Band>
<Steps steps={STEPS} />
</Band>
<Band tone="surface">
<div style={{ fontSize: 11.5, letterSpacing: "0.12em", textTransform: "uppercase", fontWeight: 800, borderBottom: "2px solid var(--color-text)", paddingBottom: 9 }}>
What makes it worse
</div>
<Pitfalls items={PITFALLS} />
</Band>
<Band>
<h2 style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: "clamp(24px,3vw,36px)", letterSpacing: "-0.02em", margin: 0 }}>
Doing this in ThreadCount
</h2>
<p style={{ ...body, margin: "16px 0 0", maxWidth: "62ch" }}>
Every issue is against a named person, so a leaver&rsquo;s outstanding sets are a list
rather than a mystery. A size change is one recorded exchange rather than two movements
nobody made. Returns carry a condition, so damage is written off as damage. What remains
unexplained stays visible instead of being averaged away.
</p>
<div style={{ display: "flex", gap: 12, marginTop: 24, flexWrap: "wrap" }}>
<Link href="/demo" data-umami-event="open-demo" data-umami-event-placement="guide" className="btn btn-primary">Open the demo</Link>
<Link href="/reporting" className="btn btn-secondary">What the reports show</Link>
</div>
</Band>
<GuideFoot
related={[
["How to run a uniform stocktake", "/guides/uniform-stocktake"],
["Staff entitlements and manager approvals", "/guides/manager-approvals"],
]}
/>
</>
);
}
@@ -0,0 +1,113 @@
import Link from "next/link";
import { Band, body, small } from "@/components/site";
import { GuideFoot, GuideHead, GuideMeta, Pitfalls, Steps, type Step } from "@/components/guide";
export const metadata = {
title: "How to run a uniform stocktake in a hospital, aged-care home or clinic",
description:
"A count that survives scrutiny in a hospital linen room or an aged-care store: what to count, how to split a room into shelves, what to do about garments at the laundry, and how to record a variance.",
alternates: { canonical: "/guides/uniform-stocktake" },
};
const STEPS: Step[] = [
{
n: "01",
h: "Decide what a count is for before you start",
p: <>A stocktake answers one of two questions, and they need different counts. Either you are correcting the on-hand figure so ordering stops going wrong, or you are producing a stock valuation someone in finance will sign. The first can be done shelf by shelf over a fortnight. The second has to be a single point in time with issuing stopped, or the number is meaningless. Most linen rooms need the first far more often than the second, and get into trouble by attempting the second casually.</>,
},
{
n: "02",
h: "Freeze movement, or record it",
p: <>The commonest way a count goes wrong is that garments move while it is happening. If you can, stop issuing for the duration. If you cannot and on a ward or in an aged-care home at shift change you usually cannot then write down every issue and every return made during the count, and apply them afterwards. An unrecorded issue mid-count looks exactly like a loss, and you will spend an afternoon chasing it.</>,
},
{
n: "03",
h: "Break the room into shelves, not into products",
p: <>Count by location, not by catalogue. A person standing at a shelf can count everything on it accurately; the same person asked to count &ldquo;all scrub tops&rdquo; has to walk the whole room, and will miss the bay by the door. Give every shelf and bay a name, count it as a unit, and record the count against that location. It also means two people can count different parts of the room at once without colliding.</>,
},
{
n: "04",
h: "Count by size, always",
p: <>&ldquo;Forty scrub tops&rdquo; is not a usable number. Forty tops that are all XXL is a shortage of every other size wearing a healthy total. Every count line should be item plus size, because that is the level at which you run out, and the level at which you reorder.</>,
},
{
n: "05",
h: "Account for what isnt on the shelf",
p: <>At any moment a large share of your stock is legitimately elsewhere: at the laundry, issued to staff, in a delivery not yet put away, or set aside for a starter pack. None of that is missing, but if your count only covers shelves then all of it reads as a loss. Decide in advance how each of those is treated and be consistent the usual approach is to count shelves only, and compare against expected shelf stock rather than against everything you have ever bought.</>,
},
{
n: "06",
h: "Write down why, not just what",
p: <>A variance with no reason beside it is worthless three weeks later. &ldquo;Twelve fewer size M tops than expected&rdquo; tells you nothing; &ldquo;twelve fewer, ward reported a bin sent to laundry unlogged&rdquo; tells you where to look next time. Make a reason mandatory past a threshold you choose five is a sensible starting point so small counting noise passes without ceremony and real gaps get explained while the explanation is still known.</>,
},
{
n: "07",
h: "Apply it, then look at the pattern",
p: <>Correcting the on-hand figure is the easy half. The value is in what repeats: the same size short every count, the same shelf always over, a variance that appears only after a particular roster. One count is an anecdote. Four counts with reasons attached is an argument you can take to a manager.</>,
},
];
const PITFALLS: [string, string][] = [
["Counting into a spreadsheet nobody reconciles", "A count that never gets applied to the on-hand figure changes nothing. If the spreadsheet is where it ends, ordering carries on from the same wrong number."],
["Recounting only the lines that look wrong", "Recounting a shortfall and accepting every surplus quietly biases the result. Recount by shelf, not by whether you liked the answer."],
["Treating the laundry as loss", "Garments in the wash are stock. Counted as missing, they justify orders you do not need, and the surplus arrives a fortnight later."],
["One person counting their own room", "Not because anyone is dishonest — because you see what you expect. Where the count feeds a valuation, have someone else count at least a sample."],
["No date on the count", "A number without the moment it was true cannot be reconciled against anything. Record when the count was taken, not when it was typed up."],
["Stopping at the total", "The total is the least useful figure in a stocktake. The per-size variance is the one that changes what you order on Monday."],
];
export default function Page() {
return (
<>
<GuideMeta
slug="uniform-stocktake"
title="How to run a uniform stocktake in a hospital, aged-care home or clinic"
description={metadata.description}
updated="2026-09-07"
/>
<GuideHead
kicker="Guide"
title="How to run a uniform stocktake"
lede="Counting a linen room is not hard, and neither is counting an aged-care store or a clinic cupboard. Producing a number that still means something a month later is, and that difference is almost entirely in the preparation."
/>
<Band>
<Steps steps={STEPS} />
</Band>
<Band tone="surface">
<div style={{ fontSize: 11.5, letterSpacing: "0.12em", textTransform: "uppercase", fontWeight: 800, borderBottom: "2px solid var(--color-text)", paddingBottom: 9 }}>
Where counts go wrong
</div>
<Pitfalls items={PITFALLS} />
</Band>
<Band>
<h2 style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: "clamp(24px,3vw,36px)", letterSpacing: "-0.02em", margin: 0 }}>
Doing this in ThreadCount
</h2>
<p style={{ ...body, margin: "16px 0 0", maxWidth: "62ch" }}>
Counts are taken by location, on a phone, scanning each garment. The expected figure stays
on screen as you go, the tally survives putting the phone down mid-shelf, and a gap past
your chosen threshold asks for a reason before it will commit. Nothing is applied to your
on-hand figures until you commit the count.
</p>
<p style={{ ...small, margin: "14px 0 0", maxWidth: "62ch" }}>
None of which you need in order to follow the steps above a clipboard and a consistent
method will do. It is just faster when the expected number is already in your hand.
</p>
<div style={{ display: "flex", gap: 12, marginTop: 24, flexWrap: "wrap" }}>
<Link href="/demo" data-umami-event="open-demo" data-umami-event-placement="guide" className="btn btn-primary">Open the demo</Link>
<Link href="/features" className="btn btn-secondary">What else it does</Link>
</div>
</Band>
<GuideFoot
related={[
["Where hospital and aged-care uniforms actually go", "/guides/uniform-loss"],
["Charging uniforms to the right cost centre", "/guides/cost-centre-reporting"],
]}
/>
</>
);
}
+87
View File
@@ -0,0 +1,87 @@
import Link from "next/link";
import { Band, CtaBand, PageHead, SiteNav, body, h2, h3, kicker, small } from "@/components/site";
export const metadata = {
title: "How it works",
description: "Issue, replenish, receive — the three steps a linen room runs on, in a hospital, an aged-care home or a clinic, and what a morning at the counter actually looks like.",
alternates: { canonical: "/how-it-works" },
};
const STEPS = [
{ n: "1", t: "Issue", b: "Scan the garment, pick the staff member, record it. Their ward or wing, cost centre, allowance route and last-issued sizes are already on the record, so a repeat visit takes seconds.", h: "What happens behind it", d: ["Nobody goes past six sets held without a recorded override", "A managers signed approval draws down as sets go over", "A credit slip prints for the rest of what was signed for", "The issue values at unit cost against the ward or clinic"] },
{ n: "2", t: "Replenish", b: "Every shelf issue feeds a draft supplier order, grouped by supplier. Review it, add the suppliers order number, send. Nothing you issue goes unreplaced.", h: "What you get", d: ["A draft built from real movement, not guesswork", "Reorder flags before a size runs out", "A printable purchase order with your codes", "Order number, invoice and tracking held together"] },
{ n: "3", t: "Receive", b: "Tick lines off the box as they arrive. Price-check against the order, then send each line to the shelf or to a staff pickup.", h: "And when the box is short", d: ["Shorts split to a back order automatically", "Staff lines join the pickup call list", "Shelf lines update on-hand immediately", "The variance against the invoice is recorded"] },
];
const DAY = [
["07:40", "Open the dashboard. Three orders open, one overdue at six days, seven sizes at or below reorder."],
["08:05", "Two nurses at the counter. Scan, pick, issue — both walk away with their sets and a printed slip."],
["09:30", "A box arrives. Receive it line by line, one item short; the back order writes itself."],
["11:00", "Work the pickup call list — two wards and the day-surgery unit. Mark contacted, print collection slips for the ones coming down."],
["14:15", "Review the draft replenishment order, add the suppliers reference, send."],
["16:20", "Month end approaching: run the cost centre report and the journal CSV, ready for finance."],
];
export default function HowItWorks() {
return (
<>
<SiteNav current="how" />
<PageHead
kicker="How it works"
title="Three steps, and the loop closes."
lede="Issue, replenish, receive. Nothing leaves the shelf without a replacement drafted, and nothing arrives without a line to tick it against."
/>
<Band pad="0 40px 0">
{STEPS.map((s) => (
<div key={s.n} className="tcm-rowgrid" style={{ display: "grid", gridTemplateColumns: "180px 1fr 1fr", gap: 40, padding: "48px 0", borderBottom: "1px solid var(--color-divider)" }}>
<div>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: "clamp(64px,8vw,120px)", lineHeight: 0.9, color: "var(--color-accent)" }}>{s.n}</div>
<div style={{ width: 60, height: 4, background: "var(--color-text)", marginTop: 14 }} />
</div>
<div>
<h2 style={{ ...h3, margin: 0 }}>{s.t}</h2>
<p style={{ ...body, margin: "12px 0 0", maxWidth: "44ch" }}>{s.b}</p>
</div>
<div style={{ borderLeft: "2px solid var(--color-text)", paddingLeft: 28 }}>
<div style={{ ...kicker, color: "var(--color-neutral-700)" }}>{s.h}</div>
<div style={{ marginTop: 12 }}>
{s.d.map((d) => (
<div key={d} style={{ display: "flex", gap: 12, padding: "7px 0", fontSize: 15, lineHeight: 1.6, color: "var(--color-neutral-800)" }}>
<span style={{ width: 7, height: 7, background: "var(--color-accent)", flex: "none", marginTop: 8 }} />{d}
</div>
))}
</div>
</div>
</div>
))}
<div style={{ height: 20 }} />
</Band>
{/* Full-bleed photo band */}
<section style={{ borderBottom: "2px solid var(--color-text)", height: "clamp(300px,34vw,460px)", overflow: "hidden" }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img className="grayscale" src="/photos/tc-photo-housekeeping.jpg" alt="" aria-hidden="true" style={{ width: "100%", height: "100%", objectFit: "cover", objectPosition: "center 40%", display: "block" }} />
</section>
<Band tone="surface">
<div style={kicker}>A morning at the counter</div>
<h2 style={{ ...h2, marginTop: 12 }}>What an ordinary Tuesday looks like.</h2>
<div style={{ marginTop: 30 }}>
{DAY.map(([t, w]) => (
<div key={t} className="tcm-rowgrid" style={{ display: "grid", gridTemplateColumns: "140px 1fr", gap: 24, padding: "16px 0", borderBottom: "1px solid var(--color-divider)" }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 19, color: "var(--color-accent-700)" }}>{t}</div>
<div style={{ fontSize: 15.5, lineHeight: 1.65, color: "var(--color-neutral-800)" }}>{w}</div>
</div>
))}
</div>
<div style={{ display: "flex", gap: 12, marginTop: 30, flexWrap: "wrap", alignItems: "center" }}>
<Link href="/demo" data-umami-event="open-demo" data-umami-event-placement="how-it-works" className="btn btn-primary">Open the working demo</Link>
<span style={small}>Try the same flow with demonstration data.</span>
</div>
</Band>
<CtaBand />
</>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { SiteFooter } from "@/components/site";
import Analytics from "@/components/Analytics";
import JsonLd from "@/components/JsonLd";
import { graph, organization, website } from "@/lib/schema";
// Route group: no URL segment. Wraps every public marketing page in the same shell so the nav and
// footer are defined once. The nav itself is rendered per page so it can mark the active link.
// These pages are prerendered. Two things in them move on their own: the footer's copyright year,
// and — once, on the day plans go live — the price on every page that states one (lib/plans-live.ts).
// A minute keeps the pages served from the prerender for every reader while letting the console's
// plans switch reach the site without a deploy.
export const revalidate = 60;
export default function SiteLayout({ children }: { children: React.ReactNode }) {
return (
<div style={{ fontFamily: "var(--font-body)", color: "var(--color-text)", background: "var(--color-bg)", minHeight: "100vh", display: "flex", flexDirection: "column" }}>
{/* The link has to be the first focusable thing on the page, so it lives here rather than in
SiteNav. Its target is the page heading — PageHead carries id="content" — because each
page renders its own nav inside `children` to mark the active link, so a target on this
wrapper would land above the nav and skip nothing. */}
<a className="skip-link" href="#content">Skip to content</a>
<main style={{ flex: 1 }}>{children}</main>
<SiteFooter />
<Analytics site="marketing" />
{/* Who this is and what site it is. Stated once, on the shell every public page uses. */}
<JsonLd data={graph(organization, website)} />
</div>
);
}
+9
View File
@@ -0,0 +1,9 @@
import LegalDoc from "@/components/LegalDoc";
import { DOC_META } from "@/lib/legal";
const M = DOC_META["About & Contact"];
export const metadata = { title: M.title, description: M.desc, alternates: { canonical: M.path } };
export default function Page() {
return <LegalDoc name="About & Contact" />;
}
+189
View File
@@ -0,0 +1,189 @@
import Link from "next/link";
import JsonLd from "@/components/JsonLd";
import { graph, softwareApplication } from "@/lib/schema";
import { Band, CtaBand, SiteNav, body, h2, kicker, small, wrap } from "@/components/site";
import { PRICES } from "@/lib/plan";
import { plansLive } from "@/lib/plans-live";
import { COMMUNITY } from "@/lib/edition";
import { redirect } from "next/navigation";
export const metadata = { alternates: { canonical: "/" } };
const CARDS = [
{ k: "Know", t: "Whats on the shelf", b: "On-hand by size, updated as you issue, and flagged before a size runs out on you." },
{ k: "Prove", t: "Where it went", b: "Every garment against the staff member who took it, dated, on a slip they signed." },
{ k: "Charge", t: "The right ward or clinic", b: "Issues carry the wearers cost centre at the price you paid, so the journal is already written." },
];
const LOOP = [
{ n: "1", t: "Issue", b: "Scan the garment, pick the staff member, done. Their ward or clinic, cost centre and last sizes are already there, so a repeat visit takes seconds.", pad: 0 },
{ n: "2", t: "Replenish", b: "What you take off the shelf lands on a draft order for that supplier. Check it, add their order number, send. Nothing you issue goes quietly unreplaced.", pad: 36 },
{ n: "3", t: "Receive", b: "Tick lines off against the invoice as you unpack, then send each one to the shelf or to whoever is waiting. A short delivery becomes a back order on its own.", pad: 72 },
];
const row: React.CSSProperties = { display: "flex", justifyContent: "space-between", padding: "8px 0", borderBottom: "1px solid var(--color-divider)", fontSize: 13 };
export default async function Home() {
// A Community instance is the product, not the website: its front door is the sign-in.
if (COMMUNITY) redirect("/auth");
const live = await plansLive();
return (
<>
{/* The product, with its price as it stands today, on the page that is actually about it. */}
<JsonLd data={graph(softwareApplication(live))} />
<SiteNav />
{/* Hero: full-bleed greyscale photograph with a solid ink panel flush left over it. */}
<section style={{ position: "relative", borderBottom: "2px solid var(--color-text)", minHeight: "clamp(440px,50vw,640px)", display: "flex", alignItems: "stretch" }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img className="grayscale" src="/photos/tc-photo-ward.jpg" alt="" aria-hidden="true" style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover", objectPosition: "center 30%" }} />
<div style={{ ...wrap, position: "relative", zIndex: 2, display: "flex", alignItems: "flex-start" }}>
{/* The panel sizes to its widest line rather than a fixed 760: the headline scales to 84px,
where "ACCOUNTED FOR." is 753px and was breaking out of the box onto the photograph
from 1440px up. Hugging the content means the ink always contains the words. */}
<div style={{ background: "var(--color-text)", color: "#fff", width: "fit-content", maxWidth: "min(880px, 100%)", padding: "clamp(32px,6vw,52px) clamp(20px,5vw,48px) clamp(56px,9vw,104px)" }}>
<div style={{ ...kicker, color: "var(--color-accent-300)", display: "flex", alignItems: "center", gap: 10 }}>
<span style={{ width: 28, height: 3, background: "var(--color-accent)" }} />{live ? "For hospitals, aged care and clinics" : "Free for hospitals, aged care and clinics"}
</div>
<h1 style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: "clamp(30px,7.2vw,84px)", lineHeight: 0.95, letterSpacing: "-0.035em", textTransform: "uppercase", margin: "24px 0 0" }}>Every garment out the door, accounted for.</h1>
<p style={{ fontSize: 18, lineHeight: 1.6, color: "var(--color-neutral-200)", maxWidth: "48ch", margin: "24px 0 0" }}>ThreadCount follows a garment from the shelf, to the person wearing it, to the order that puts another one back.</p>
<div style={{ display: "flex", gap: 12, marginTop: 30, flexWrap: "wrap" }}>
<Link href="/demo" data-umami-event="open-demo" data-umami-event-placement="hero" className="btn" style={{ background: "#fff", color: "var(--color-accent-700)", border: "2px solid #fff", fontWeight: 700 }}>Open the working demo</Link>
<Link href="/how-it-works" className="btn" style={{ background: "transparent", color: "#fff", border: "2px solid #fff", fontWeight: 700 }}>How it works</Link>
</div>
</div>
</div>
</section>
{/* Card row pulls up over the hero's bottom padding. */}
<section style={{ background: "var(--color-bg)" }}>
<div style={{ ...wrap, position: "relative", zIndex: 5 }}>
<div className="tcm-3col tcm-pullup" style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", border: "2px solid var(--color-text)", background: "var(--color-bg)", marginTop: -58 }}>
{CARDS.map((c, i) => (
<div key={c.k} style={{ padding: "26px 28px 30px", borderRight: i < 2 ? "1px solid var(--color-divider)" : undefined }}>
<div style={kicker}>{c.k}</div>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 24, letterSpacing: "-0.02em", marginTop: 10 }}>{c.t}</div>
<p style={{ fontSize: 14.5, lineHeight: 1.65, color: "var(--color-neutral-800)", margin: "8px 0 0" }}>{c.b}</p>
</div>
))}
</div>
</div>
</section>
{/* Proposition split */}
<Band pad="110px 40px clamp(64px,7vw,88px)">
<div className="tcm-split" style={{ display: "grid", gridTemplateColumns: "1.25fr 1fr", gap: 64 }}>
<h2 style={h2}>One coordinator can run the whole room from a phone.</h2>
<div>
<p style={{ ...body, margin: 0 }}>Scan the garment, pick the staff member, record it. The order to replace it starts itself, the cost lands on the right ward, wing or clinic, and the month-end pack is a button rather than a weekend.</p>
<Link href="/features" style={{ display: "inline-block", marginTop: 22, textDecoration: "none", fontSize: 14, fontWeight: 800, letterSpacing: "0.04em", textTransform: "uppercase", color: "var(--color-text)", borderBottom: "3px solid var(--color-accent)", paddingBottom: 3 }}>See everything it does</Link>
</div>
</div>
</Band>
{/* The loop, with the designed vertical stagger */}
<Band tone="surface">
<div style={kicker}>The loop</div>
<div className="tcm-3col tcm-stagger" style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 40, marginTop: 28 }}>
{LOOP.map((l) => (
<div key={l.n} style={{ paddingTop: l.pad }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 88, lineHeight: 1, color: "var(--color-neutral-300)" }}>{l.n}</div>
<div style={{ borderTop: "4px solid var(--color-accent)", paddingTop: 12, marginTop: 6, fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 22, display: "inline-block" }}>{l.t}</div>
<p style={{ fontSize: 15, lineHeight: 1.7, color: "var(--color-neutral-800)", margin: "10px 0 0", maxWidth: "38ch" }}>{l.b}</p>
</div>
))}
</div>
</Band>
{/* Pricing band */}
<Band>
<div className="tcm-split" style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 56, alignItems: "center" }}>
<div>
<div style={kicker}>Pricing</div>
{live
? <div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: "clamp(44px,7vw,104px)", lineHeight: 0.9, letterSpacing: "-0.04em", textTransform: "uppercase", marginTop: 10 }}>Free to run.<br />Paid to host.</div>
: <div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: "clamp(80px,13vw,190px)", lineHeight: 0.82, letterSpacing: "-0.04em", textTransform: "uppercase", marginTop: 10 }}>Free</div>}
</div>
<div>
<p style={{ fontSize: "clamp(17px,1.9vw,22px)", lineHeight: 1.55, color: "var(--color-neutral-800)", margin: 0 }}>
{live
? `Every feature in every edition. Run it yourself for nothing, or have it hosted from $${PRICES.hostedAnnual.toLocaleString("en-AU")} a year — free for a room under ${PRICES.freeStaff} staff records.`
: "No licence, no per-device charge, no per-user charge. Every feature, every report, everyone in the room."}
</p>
<Link href="/pricing" style={{ display: "inline-block", marginTop: 20, textDecoration: "none", fontSize: 14, fontWeight: 800, letterSpacing: "0.04em", textTransform: "uppercase", color: "var(--color-text)", borderBottom: "3px solid var(--color-accent)", paddingBottom: 3 }}>{live ? "The plans" : "What free covers"}</Link>
</div>
</div>
</Band>
{/* Recreated product UI */}
<Band tone="surface">
<div style={kicker}>On the screen</div>
<h2 style={{ ...h2, marginTop: 12 }}>The whole counter on one screen.</h2>
<div className="table-wrap" style={{ marginTop: 30 }}>
<div style={{ border: "2px solid var(--color-text)", background: "var(--color-bg)", minWidth: 620 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, padding: "10px 16px", borderBottom: "2px solid var(--color-text)" }}>
<span style={{ width: 9, height: 9, border: "2px solid var(--color-text)" }} />
<span style={{ width: 9, height: 9, border: "2px solid var(--color-text)" }} />
<span style={{ width: 9, height: 9, background: "var(--color-accent)" }} />
<span style={{ ...kicker, color: "var(--color-neutral-700)", fontSize: 11, marginLeft: 10 }}>ThreadCount Dashboard</span>
<span style={{ marginLeft: "auto", fontSize: 11, color: "var(--color-neutral-600)" }}>Signed in · Coordinator</span>
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3,1fr)", borderBottom: "2px solid var(--color-text)" }}>
{[["Orders", "3 open", "1 overdue — NL-48211, 6 days", true], ["Stock", "$18,240", "7 sizes at or below reorder", false], ["Staff", "212 active", "14 issues recorded this week", false]].map(([k, v, s, red], i) => (
<div key={String(k)} style={{ padding: "18px 22px", borderRight: i < 2 ? "1px solid var(--color-divider)" : undefined }}>
<div style={{ ...kicker, color: "var(--color-neutral-700)", fontSize: 10 }}>{k}</div>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 30, marginTop: 4 }}>{v}</div>
<div style={{ fontSize: 12, color: red ? "var(--color-accent-700)" : "var(--color-neutral-700)", fontWeight: red ? 600 : 400 }}>{s}</div>
</div>
))}
</div>
<div style={{ display: "grid", gridTemplateColumns: "3fr 2fr" }}>
<div style={{ padding: "16px 22px", borderRight: "1px solid var(--color-divider)" }}>
<div style={{ ...kicker, color: "var(--color-text)", fontSize: 11, borderBottom: "2px solid var(--color-text)", paddingBottom: 6 }}>Awaiting pickup call list</div>
{[["K. Osei · Softshell Jacket M", "16 days", "tag tag-accent", "Contacted"], ["T. Nguyen · Polo Shirt L ×2", "4 days", "tag tag-neutral", "Call"], ["R. Patel · Cargo Pant 88R", "1 day", "tag tag-neutral", "Call"]].map(([n, d, cls, act]) => (
<div key={String(n)} style={{ display: "flex", gap: 12, alignItems: "center", padding: "8px 0", borderBottom: "1px solid var(--color-divider)", fontSize: 13 }}>
<b style={{ flex: 1 }}>{n}</b><span className={String(cls)}>{d}</span><span className="tag tag-outline">{act}</span>
</div>
))}
</div>
<div style={{ padding: "16px 22px" }}>
<div style={{ ...kicker, color: "var(--color-text)", fontSize: 11, borderBottom: "2px solid var(--color-text)", paddingBottom: 6 }}>Reorder flags</div>
{[["RN Scrub Top · S", "2 left"], ["Chef Jacket · L", "0 left"], ["Security Polo · XL", "3 left"]].map(([n, v]) => (
<div key={n} style={row}><span>{n}</span><b style={{ color: "var(--color-accent-700)" }}>{v}</b></div>
))}
</div>
</div>
</div>
</div>
<div style={{ ...small, marginTop: 14 }}>Inventory, issuing, ordering, stocktakes and reports all work the same way, so there is only one thing to learn.</div>
</Band>
{/* The guides. Someone arriving from a search for their problem rather than for a product
should find the useful writing without hunting for it — and someone weighing this up
wants evidence the person behind it knows the job. These do both. */}
<Band>
<div style={kicker}>Guides</div>
<h2 style={{ ...h2, marginTop: 12 }}>Written from the counter, not the brochure.</h2>
<p style={{ ...body, marginTop: 16, maxWidth: "58ch" }}>
Notes on running a linen room &mdash; in a hospital, an aged-care home or a clinic &mdash; that are useful whether or not you ever use ThreadCount.
Take the method and use a clipboard if that&rsquo;s what you have.
</p>
<div className="tcm-2col" style={{ display: "grid", gridTemplateColumns: "1fr 1fr", columnGap: 56, rowGap: 0, marginTop: 24 }}>
{[
["How to run a uniform stocktake", "/guides/uniform-stocktake"],
["Where the uniforms actually go", "/guides/uniform-loss"],
["Charging uniforms to the right cost centre", "/guides/cost-centre-reporting"],
["Entitlements and manager approvals", "/guides/manager-approvals"],
].map(([label, href]) => (
<Link key={href} href={href} style={{ textDecoration: "none", color: "var(--color-text)", borderBottom: "1px solid var(--color-divider)", padding: "18px 0", display: "flex", alignItems: "baseline", gap: 14 }}>
<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 18 }}>{label}</span>
<span aria-hidden style={{ marginLeft: "auto", color: "var(--color-accent)", fontWeight: 800 }}>&rarr;</span>
</Link>
))}
</div>
</Band>
<CtaBand title="I built this for my own linen room." lede="I coordinate uniforms at a hospital, and I got tired of a spreadsheet that never matched the shelf. If your room has the same problem, its yours to use." />
</>
);
}
+195
View File
@@ -0,0 +1,195 @@
import Link from "next/link";
import type { Metadata } from "next";
import { Band, CtaBand, SiteNav, body, h2, h3, kicker, small, wrap } from "@/components/site";
import { PRICES } from "@/lib/plan";
import { plansLive } from "@/lib/plans-live";
/* Two pages in one file, and which one renders is decided by the console's plans switch, not by a
* deploy — see lib/plans-live.ts for why. The first is the page as it stood while ThreadCount was
* free, kept word for word: its promises are the reason the second page reads the way it does.
* The founder's sentence under "Why …" is the same on both; only the heading's object moves. */
export async function generateMetadata(): Promise<Metadata> {
const live = await plansLive();
return live
? { title: "Pricing", description: `ThreadCount is free to run yourself and free to host for rooms under ${PRICES.freeStaff} staff records. Hosting a facility is $${PRICES.hostedAnnual} a year. Every feature is in every edition.`, alternates: { canonical: "/pricing" } }
: { title: "Pricing", description: "ThreadCount is free. No licence, no per-device charge, no per-user charge, and no tier above this one holding the useful parts back.", alternates: { canonical: "/pricing" } };
}
const INCLUDED = [
{ t: "Every person, every device", b: "Access is per person and costs nothing, so the counter, the ward or wing, and the coordinators phone can all be signed in at once." },
{ t: "Every feature", b: "Issuing, ordering, receiving, stocktakes, all nine report tabs and the month-end pack. There is no locked tier." },
{ t: "Every record, exportable", b: "Every report and register exports as CSV. One backup file carries the whole facility out. Nothing is held back to make leaving hard." },
];
const COVERED = [
"Unlimited staff records", "Unlimited garments and sizes", "Unlimited issues and receipts",
"Scanning by USB scanner, or by phone camera where the browser reads one", "Draft replenishment orders", "Printable purchase orders and slips",
"Stocktakes with blind counting", "All nine report tabs", "Journal CSV for finance", "The one-click month-end pack",
];
const HONEST = [
{ q: "Is there a paid tier coming?", a: "Nothing is planned. If that ever changes, the rooms using it will hear before the website does." },
{ q: "Whats the catch?", a: "Its maintained by one person alongside a day job. Thats the honest limitation — not a cost, but a support surface worth discussing before you commit a whole site to it." },
{ q: "Do we need to buy hardware?", a: "No. It runs on the phones and computers the room already has. A USB scanner speeds up the counter but isnt required." },
{ q: "What about hosting costs?", a: "There is nothing for you to pay. If your facility needs it hosted inside their own environment, thats a conversation worth having early." },
];
/* ---------- the page once plans are live ---------- */
const P = PRICES;
const money = (n: number) => `$${n.toLocaleString("en-AU")}`;
const PLANS = [
{
name: "Community", price: "$0", per: "", who: "Run it on your own server. The source is published; every feature is in it.",
rows: ["Everything, no ceiling", "Your Postgres, your backups", "Your own single sign-on broker, if you want one", "A public issue tracker"],
cta: ["Read the install guide", "/getting-started"], lead: false,
},
{
name: "Hosted", price: money(P.hostedMonthly), per: "a facility, a month", who: `Or ${money(P.hostedAnnual)} a year. Free for a room under ${P.freeStaff} staff records.`,
rows: ["Everything", "Australian hosting, nightly backups kept 35 days", "Point-in-time restore", "Single sign-on set up for you", "Email support, next business day"],
cta: ["Start a 60-day trial", "/auth?mode=signup"], lead: true,
},
{
name: "Health Service", price: money(P.healthServiceAnnual), per: "a year", who: `Up to ${P.healthServiceFacilities} facilities, then ${money(P.healthServiceExtra)} each.`,
rows: ["Everything, across every site", "One sign-in, switch between facilities", "Roll-up reports and a shared catalogue", "Named support, same business day", "Invoice, purchase order, security questionnaire"],
cta: ["Talk about a pilot", "/contact"], lead: false,
},
];
const HONEST_LIVE = [
{ q: "Is there a locked tier?", a: "No. Community, Hosted and Health Service run the same code. The differences are who runs it, how long the backups are kept, and how many sites it covers." },
{ q: "We signed up when it was free. What changes?", a: "Nothing. Every facility created before this page changed stays on the free hosted plan, with everything, for as long as it exists. That was promised here, and it is written into the terms." },
{ q: `Why ${P.freeStaff} staff records?`, a: "It is roughly where a room stops being one person and a cupboard and starts being a job. A clinic, a dental practice or a small home sits under it and pays nothing; a hospital ward is over it on day one." },
{ q: "What happens if we stop paying?", a: "The facility goes read-only after a fortnights grace. Every report, export and the full backup keep working. Nothing is deleted, and writing starts again when the invoice is paid." },
{ q: "How do we pay?", a: "By invoice, annually, against your purchase order. Prices are in Australian dollars before GST." },
{ q: "Whats the catch?", a: "It is still maintained by one person alongside a day job. Hosted buys a response time; it does not buy a team." },
];
function FreePage() {
return (
<>
<section style={{ background: "var(--color-accent-600)", color: "#fff", borderBottom: "2px solid var(--color-text)" }}>
<div style={{ ...wrap, padding: "52px 40px 56px" }}>
<div style={{ ...kicker, color: "#fff" }}>Pricing</div>
<h1 style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: "clamp(52px,7vw,104px)", lineHeight: 0.9, letterSpacing: "-0.04em", textTransform: "uppercase", margin: "12px 0 0" }}>Free</h1>
<p style={{ fontSize: 17.5, lineHeight: 1.6, maxWidth: "46ch", marginTop: 20 }}>No licence. No per-device charge. No per-user charge. That is the whole page.</p>
</div>
</section>
<Band>
<h2 style={h2}>What free actually covers.</h2>
<p style={{ ...body, marginTop: 16, maxWidth: "56ch" }}>Everything. There is no tier above this one holding the useful parts back.</p>
<div className="tcm-3col" style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 0, marginTop: 34, border: "2px solid var(--color-text)" }}>
{INCLUDED.map((c, i) => (
<div key={c.t} style={{ padding: "26px 28px 30px", borderRight: i < 2 ? "1px solid var(--color-divider)" : undefined }}>
<h3 style={{ ...h3, fontSize: 20 }}>{c.t}</h3>
<p style={{ fontSize: 14.5, lineHeight: 1.65, color: "var(--color-neutral-800)", marginTop: 10 }}>{c.b}</p>
</div>
))}
</div>
</Band>
<Band tone="surface">
<div style={kicker}>In the box</div>
<div className="tcm-2col" style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0 56px", marginTop: 20 }}>
{COVERED.map((c) => (
<div key={c} style={{ display: "flex", gap: 14, alignItems: "baseline", padding: "12px 0", borderBottom: "1px solid var(--color-divider)", fontSize: 15.5 }}>
<span style={{ color: "var(--color-accent)", fontWeight: 800 }}></span>{c}
</div>
))}
</div>
</Band>
<Band>
<div className="tcm-split" style={{ display: "grid", gridTemplateColumns: "1fr 1.15fr", gap: 56 }}>
<div>
<h2 style={h2}>Why it costs nothing.</h2>
<p style={{ ...body, marginTop: 18, maxWidth: "40ch" }}>It was built by a hospital uniform coordinator to solve their own room&rsquo;s problem. It already exists, it already works, and charging for it was never the point.</p>
</div>
<div>
{HONEST.map((h) => (
<div key={h.q} style={{ padding: "18px 0", borderBottom: "1px solid var(--color-divider)" }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 17 }}>{h.q}</div>
<p style={{ fontSize: 15, lineHeight: 1.65, color: "var(--color-neutral-800)", margin: "7px 0 0", maxWidth: "56ch" }}>{h.a}</p>
</div>
))}
</div>
</div>
</Band>
</>
);
}
function LivePage() {
return (
<>
<section style={{ background: "var(--color-accent-600)", color: "#fff", borderBottom: "2px solid var(--color-text)" }}>
<div style={{ ...wrap, padding: "52px 40px 56px" }}>
<div style={{ ...kicker, color: "#fff" }}>Pricing</div>
<h1 style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: "clamp(40px,6vw,88px)", lineHeight: 0.92, letterSpacing: "-0.04em", textTransform: "uppercase", margin: "12px 0 0", maxWidth: "12ch" }}>Free to run. Paid to host.</h1>
<p style={{ fontSize: 17.5, lineHeight: 1.6, maxWidth: "50ch", marginTop: 20 }}>Every feature is in every edition. You pay for someone to keep it running, backed up and supported. A room under {P.freeStaff} staff records pays nothing either way.</p>
</div>
</section>
<Band pad="0 40px">
<div className="tcm-3col" style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 0, borderLeft: "2px solid var(--color-text)", borderRight: "2px solid var(--color-text)" }}>
{PLANS.map((p, i) => (
<div key={p.name} style={{ padding: "30px 26px 34px", borderRight: i < 2 ? "1px solid var(--color-divider)" : undefined, background: p.lead ? "var(--color-surface)" : undefined, display: "flex", flexDirection: "column" }}>
<h2 style={{ ...h3, fontSize: 22 }}>{p.name}</h2>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 44, letterSpacing: "-0.04em", lineHeight: 1, marginTop: 16 }}>{p.price}</div>
{p.per && <div style={{ ...small, marginTop: 6 }}>{p.per}</div>}
<p style={{ fontSize: 14, lineHeight: 1.6, color: "var(--color-neutral-800)", margin: "10px 0 0", minHeight: 44 }}>{p.who}</p>
<ul style={{ listStyle: "none", padding: 0, margin: "18px 0 0", flex: 1 }}>
{p.rows.map((r) => <li key={r} style={{ padding: "8px 0", borderBottom: "1px solid var(--color-divider)", fontSize: 14.5 }}>{r}</li>)}
</ul>
<Link href={p.cta[1]} className="btn" data-umami-event={p.lead ? "signup-cta" : undefined} data-umami-event-placement={p.lead ? "pricing-hosted" : undefined} style={{ marginTop: 22, textAlign: "center", fontWeight: 700, ...(p.lead ? { background: "var(--color-text)", color: "var(--color-bg)", border: "2px solid var(--color-text)" } : { border: "2px solid var(--color-text)", color: "var(--color-text)" }) }}>{p.cta[0]}</Link>
</div>
))}
</div>
</Band>
<Band tone="surface">
<div style={kicker}>In every edition</div>
<div className="tcm-2col" style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0 56px", marginTop: 20 }}>
{COVERED.map((c) => (
<div key={c} style={{ display: "flex", gap: 14, alignItems: "baseline", padding: "12px 0", borderBottom: "1px solid var(--color-divider)", fontSize: 15.5 }}>
<span style={{ color: "var(--color-accent)", fontWeight: 800 }}></span>{c}
</div>
))}
</div>
<p style={{ ...small, marginTop: 18, maxWidth: "60ch" }}>&ldquo;Unlimited staff records&rdquo; is true of Community, Hosted Facility and Health Service. The free hosted room holds {P.freeStaff}; past that it is a Hosted facility.</p>
</Band>
<Band>
<div className="tcm-split" style={{ display: "grid", gridTemplateColumns: "1fr 1.15fr", gap: 56 }}>
<div>
<h2 style={h2}>Why the software costs nothing.</h2>
<p style={{ ...body, marginTop: 18, maxWidth: "40ch" }}>It was built by a hospital uniform coordinator to solve their own room&rsquo;s problem. It already exists, it already works, and charging for it was never the point.</p>
<p style={{ ...body, marginTop: 14, maxWidth: "40ch" }}>What costs money is running it for other people, carefully: the servers, the backups, and being the person who answers.</p>
</div>
<div>
{HONEST_LIVE.map((h) => (
<div key={h.q} style={{ padding: "18px 0", borderBottom: "1px solid var(--color-divider)" }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 17 }}>{h.q}</div>
<p style={{ fontSize: 15, lineHeight: 1.65, color: "var(--color-neutral-800)", margin: "7px 0 0", maxWidth: "56ch" }}>{h.a}</p>
</div>
))}
</div>
</div>
</Band>
</>
);
}
export default async function Pricing() {
const live = await plansLive();
return (
<>
<SiteNav current="pricing" />
{live ? <LivePage /> : <FreePage />}
<Band tone="ink">
<h2 style={{ ...h2, color: "#fff" }}>Nothing to sign. Open it and look.</h2>
<p style={{ fontSize: 16.5, lineHeight: 1.7, color: "var(--color-neutral-200)", maxWidth: "50ch", marginTop: 18 }}>The demo runs on demonstration data. Nothing you do in it touches a real record.</p>
<div style={{ display: "flex", gap: 12, marginTop: 28, flexWrap: "wrap" }}>
<Link href="/demo" data-umami-event="open-demo" data-umami-event-placement="pricing" className="btn" style={{ background: "#fff", color: "var(--color-accent-700)", border: "2px solid #fff", fontWeight: 700 }}>Open the working demo</Link>
<Link href="/getting-started" className="btn" style={{ background: "transparent", color: "#fff", border: "2px solid #fff", fontWeight: 700 }}>Getting started</Link>
</div>
</Band>
<CtaBand />
</>
);
}
+9
View File
@@ -0,0 +1,9 @@
import LegalDoc from "@/components/LegalDoc";
import { DOC_META } from "@/lib/legal";
const M = DOC_META["Privacy Policy"];
export const metadata = { title: M.title, description: M.desc, alternates: { canonical: M.path } };
export default function Page() {
return <LegalDoc name="Privacy Policy" />;
}
+112
View File
@@ -0,0 +1,112 @@
import Link from "next/link";
import { Band, CtaBand, SPLIT_PAD, SiteNav, body, h2, h3, kicker, small, wrap } from "@/components/site";
export const metadata = {
title: "Reporting",
description: "Cost centre spend, a journal laid out for upload, valuation, shrinkage, exceptions and supplier spend — each prints or exports as CSV.",
alternates: { canonical: "/reporting" },
};
// One entry per tab on the Reports screen, in the order they appear there. The month-end pack is
// not one of them — it is a button that gathers six of these into one printed document — so it is
// named under the list rather than counted as a tab.
const REPORTS = [
["01", "Cost centre spend", "What each department wore, at unit cost, for any month."],
["02", "Journal export", "One debit per cost centre, formatted for upload."],
["03", "Top stock", "The fifteen most-issued garments of the month, each with its share and its year-to-date count."],
["04", "Stock valuation", "On-hand value by garment, at unit cost, dated."],
["05", "Shrinkage", "Every stocktake this financial year, and what each one cost or found."],
["06", "Exceptions", "Anyone over their operational limit, or over the monthly threshold you set."],
["07", "Supplier spend", "Orders placed, what they came to, and the invoice numbers against them."],
["08", "Approvals", "Manager approvals still outstanding: sets approved, collected and remaining."],
["09", "Pre-loved", "Free reissues and what they saved, hand-ins, and what is in the pool."],
];
const TABLE: [string, string, string, string][] = [
["Willow Ward · RGH-3010", "14", "$412.60", "+$96"],
["ICU · RGH-4010", "9", "$268.20", "$31"],
["Operational Support", "11", "$343.75", "+$12"],
];
export default function Reporting() {
return (
<>
<SiteNav current="reporting" />
{/* Split header: type on the ground, a fixed accent column at the right. */}
<section style={{ borderBottom: "2px solid var(--color-text)" }}>
<div style={{ ...wrap, padding: 0, display: "grid", gridTemplateColumns: "1fr 320px" }} className="tcm-split">
<div className={SPLIT_PAD} style={{ paddingTop: "clamp(56px,7vw,88px)", paddingRight: 40, paddingBottom: "clamp(52px,6vw,76px)" }}>
<div style={kicker}>Reporting</div>
<h1 style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: "clamp(38px,5.2vw,78px)", lineHeight: 0.98, letterSpacing: "-0.035em", margin: "18px 0 0" }}>When finance asks, it&rsquo;s already done.</h1>
<p style={{ ...body, fontSize: 17, maxWidth: "52ch", margin: "22px 0 0" }}>Every issue lands on the wearer&rsquo;s cost centre at the price you actually paid, so the month-end numbers are already written by the time anyone asks for them.</p>
</div>
<div style={{ background: "var(--color-accent-600)", color: "#fff", padding: "44px 32px", display: "flex", flexDirection: "column", justifyContent: "flex-end" }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 64, lineHeight: 1 }}>9</div>
<div style={{ ...kicker, color: "#fff", marginTop: 6 }}>Report tabs</div>
<div style={{ width: 60, height: 4, background: "#fff", marginTop: 16 }} />
<div style={{ fontSize: 13.5, lineHeight: 1.6, marginTop: 16 }}>Each one prints or exports as CSV, and six of them gather into the one-click month-end pack.</div>
</div>
</div>
</section>
<Band tone="surface">
<div className="tcm-split" style={{ display: "grid", gridTemplateColumns: "1.15fr 1fr", gap: 56, alignItems: "center" }}>
<div className="table-wrap">
<div style={{ border: "2px solid var(--color-text)", background: "var(--color-bg)", minWidth: 420 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "10px 16px", borderBottom: "2px solid var(--color-text)" }}>
<b style={{ fontSize: 13 }}>Cost centre report August</b>
<span style={{ display: "flex", gap: 6 }}><span className="tag tag-outline">Print</span><span className="tag tag-accent">CSV</span></span>
</div>
<div style={{ padding: "8px 16px 14px", fontSize: 13 }}>
<div style={{ display: "grid", gridTemplateColumns: "2fr 1fr 1fr 1fr", gap: 10, padding: "6px 0", borderBottom: "2px solid var(--color-text)", ...kicker, color: "var(--color-neutral-700)", fontSize: 10 }}>
<span>Cost centre</span><span style={{ textAlign: "right" }}>Items</span><span style={{ textAlign: "right" }}>Spend</span><span style={{ textAlign: "right" }}>Δ</span>
</div>
{TABLE.map(([cc, n, sp, d]) => (
<div key={cc} style={{ display: "grid", gridTemplateColumns: "2fr 1fr 1fr 1fr", gap: 10, padding: "7px 0", borderBottom: "1px solid var(--color-divider)" }}>
<span>{cc}</span><span style={{ textAlign: "right" }}>{n}</span><span style={{ textAlign: "right" }}><b>{sp}</b></span>
<span style={{ textAlign: "right", color: d.startsWith("+") ? "var(--color-accent-700)" : "var(--color-neutral-700)" }}>{d}</span>
</div>
))}
<div style={{ display: "grid", gridTemplateColumns: "2fr 1fr 1fr 1fr", gap: 10, padding: "8px 0 0", fontWeight: 800 }}>
<span>TOTAL</span><span style={{ textAlign: "right" }}>34</span><span style={{ textAlign: "right" }}>$1,024.55</span><span />
</div>
</div>
<div style={{ padding: "10px 16px", borderTop: "2px solid var(--color-text)", fontSize: 12, color: "var(--color-neutral-700)" }}>Journal: one debit per cost centre, GL 631020, ready for upload</div>
</div>
</div>
<div>
<h2 style={h2}>The journal comes out formatted.</h2>
<p style={{ ...body, marginTop: 18, maxWidth: "44ch" }}>One debit line per cost centre against your GL account, in the layout your finance system expects. No workbook, no pivot table, no retyping.</p>
</div>
</div>
</Band>
<Band>
<div style={kicker}>What you can pull</div>
<div className="tcm-2col" style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0 56px", marginTop: 24 }}>
{REPORTS.map(([n, t, b]) => (
<div key={n} style={{ display: "grid", gridTemplateColumns: "44px 1fr", gap: 16, padding: "18px 0", borderBottom: "1px solid var(--color-divider)" }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 15, color: "var(--color-neutral-600)" }}>{n}</div>
<div>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 17 }}>{t}</div>
<div style={{ fontSize: 14.5, lineHeight: 1.6, color: "var(--color-neutral-800)", marginTop: 4 }}>{b}</div>
</div>
</div>
))}
</div>
</Band>
<Band tone="ink">
<h2 style={{ ...h2, color: "#fff" }}>One click at month end.</h2>
<p style={{ fontSize: 16.5, lineHeight: 1.7, color: "var(--color-neutral-200)", maxWidth: "52ch", marginTop: 18 }}>The month-end pack prints one dated document: cost centre spend, the journal, top stock, shrinkage, and anything still outstanding over a summary carrying the month&rsquo;s supplier spend and the value of the stock on the shelf.</p>
<div style={{ display: "flex", gap: 12, marginTop: 28, flexWrap: "wrap" }}>
<Link href="/demo" data-umami-event="open-demo" data-umami-event-placement="reporting" className="btn" style={{ background: "#fff", color: "var(--color-accent-700)", border: "2px solid #fff", fontWeight: 700 }}>See the reports in the demo</Link>
</div>
<div style={{ ...small, color: "var(--color-neutral-300)", marginTop: 14 }}>Nothing is re-priced after the fact, so a report you ran in August still says in December what it said in August.</div>
</Band>
<CtaBand />
</>
);
}
+85
View File
@@ -0,0 +1,85 @@
import Link from "next/link";
import { Band, CtaBand, PageHead, SiteNav, h2, h3, kicker, small } from "@/components/site";
export const metadata = {
title: "Roadmap",
description: "Whats being built next for ThreadCount, and what has shipped since the list was written. No dates — the order is intent, and it came from linen rooms asking.",
alternates: { canonical: "/roadmap" },
};
// Still to come. No dates, because a date on a public page is a promise the room can't always keep.
const PLANNED = [
{ t: "Automatic reorder triggers at par level", b: "Set a par level per garment and size, and let the draft order raise itself when the shelf drops below it." },
{ t: "Offline mode for the linen room", b: "Keep issuing and counting when the wireless drops out, and sync when it returns." },
{ t: "Staff self-service issue kiosk", b: "A screen at the counter where staff identify themselves and collect what has been approved, without queueing for the coordinator." },
{ t: "Cost per staff member reporting", b: "Spend by individual as well as by ward, for the conversations about outliers." },
{ t: "Laundry contractor reconciliation", b: "Match what went out against what came back, and put a number on what the contract is losing." },
{ t: "Multi-site and group-wide rollout", b: "Several stores running independently — a hospitals linen rooms, an aged-care groups homes, a network of clinics — with combined reporting above them." },
];
/* Four things that were on this list have since been built, and leaving them tagged "Planned"
* made the page contradict /features and /faq — a reader could not tell which one to believe. They
* stay on the page rather than quietly disappearing from it, because a room that read the list
* three months ago should be able to see what happened to it, but they are out of the numbering
* of what is still to come. Size history was listed here as "size and fit history"; what shipped is
* the size half — the record knows the last size somebody was issued, not whether it fitted them —
* so the title says size only rather than promising a fit note nobody writes. The two apps are the
* one honest half-state: both are built, and the Play listings are in review, so the tag says
* exactly that until the listings are public. */
const DONE = [
{ t: "Barcode and QR scanning on phone", b: "Scanning for issuing, counting and receiving, using the supplier codes already on the garment. Chrome and Edge read a barcode from the camera; on an iPhone or iPad, and in Firefox, the code is typed or read with a USB scanner.", tag: "Shipped" },
{ t: "Size history per person", b: "Every staff member carries the size of the last one they were issued, garment by garment, so the counter opens on what they had rather than on a guess — on the coordinators desk and in the staff app both.", tag: "Shipped" },
{ t: "Bulk import from existing spreadsheets", b: "The staff register, catalogue, locations and opening stock come in from CSV, each with a template to fill.", tag: "Shipped" },
{ t: "Android app", b: "Two of them: the counter app for the linen room, and a staff app for the people who wear the uniform. Both are built and the Play listings are in review.", tag: "In review" },
];
function Row({ n, t, b, tag, accent }: { n: string; t: string; b: string; tag: string; accent?: boolean }) {
return (
<div className="tcm-rowgrid" style={{ display: "grid", gridTemplateColumns: "64px 1fr 150px", gap: 28, padding: "26px 0", borderBottom: "1px solid var(--color-divider)", alignItems: "start" }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 20, color: "var(--color-neutral-600)" }}>{n}</div>
<div>
<h2 style={{ ...h3, fontSize: "clamp(18px,1.9vw,23px)", margin: 0 }}>{t}</h2>
<p style={{ fontSize: 15, lineHeight: 1.65, color: "var(--color-neutral-800)", margin: "7px 0 0", maxWidth: "58ch" }}>{b}</p>
</div>
<div style={{ textAlign: "right" }}><span className={"tag " + (accent ? "tag-accent" : "tag-outline")}>{tag}</span></div>
</div>
);
}
export default function Roadmap() {
return (
<>
<SiteNav />
<PageHead
kicker="Roadmap"
title="Whats being built next."
tone="ink"
lede="No dates. The list below is in order of intent, and what has been built since it was written is at the foot of the page."
/>
<Band pad="0 40px 0">
{PLANNED.map((it, i) => <Row key={it.t} n={String(i + 1).padStart(2, "0")} t={it.t} b={it.b} tag="Planned" />)}
<div style={{ height: 22 }} />
</Band>
<Band>
<div style={kicker}>Since this list was written</div>
<div style={{ marginTop: 10 }}>
{DONE.map((it) => <Row key={it.t} n="—" t={it.t} b={it.b} tag={it.tag} accent={it.tag === "Shipped"} />)}
</div>
</Band>
<Band tone="surface">
<div className="tcm-split" style={{ display: "grid", gridTemplateColumns: "1fr auto", gap: 32, alignItems: "center" }}>
<div>
<h2 style={{ ...h2, fontSize: "clamp(22px,2.6vw,34px)" }}>Something you need that isn&rsquo;t on this list?</h2>
<div style={{ ...small, marginTop: 10 }}>The list came from real linen rooms asking. Yours can change the order of it.</div>
</div>
<Link href="/contact" className="btn btn-primary">Tell me what&rsquo;s missing</Link>
</div>
</Band>
<CtaBand />
</>
);
}
+75
View File
@@ -0,0 +1,75 @@
import Link from "next/link";
import { Band, CtaBand, PageHead, SiteNav, body, h2, h3, kicker } from "@/components/site";
export const metadata = {
title: "Security & data",
description: "ThreadCount records who was issued what, which makes it a staff record. Where it lives, who can see it, and what to agree with information security first.",
alternates: { canonical: "/security" },
};
const PILLARS = [
{ t: "Your facilitys records", b: "Every issue, count and order belongs to the facility that entered it. Nothing is pooled with another site." },
{ t: "Two roles, clear limits", b: "Admin manages settings, catalogue and pricing. Issuer issues, counts and receives. Prices and history cannot be edited by accident." },
{ t: "History is kept", b: "Issues, receipts and counts are written with the date and the person who recorded them, and stay that way." },
{ t: "Export whenever", b: "Every report and register exports as CSV, the ward request queue included. One backup file takes the whole facility out at once, so the data is never held hostage in the product." },
];
const PLAIN = [
"Only the people you invite can see your rooms records.",
"Staff names, the ward or clinic they work on, and cost centres are stored to run the loop, and for nothing else.",
"No garment data is sold, shared, or used to train anything.",
"Slips print for a signature on paper. A ward-round handover can be signed on screen instead, and that signature is stored with the delivery, shown only to your facility, and deleted with the record.",
"Deactivating a staff member removes them from issuing while keeping the historical record intact for finance.",
"You can export the whole facility to one file and walk away at any point.",
];
export default function Security() {
return (
<>
<SiteNav />
<PageHead
kicker="Security and data"
tone="accent"
title={<span style={{ maxWidth: "17ch", display: "block" }}>It holds staff names. So it holds them carefully.</span>}
lede="ThreadCount records who was issued what. That makes it a staff record, and it is built to be treated as one."
/>
<Band>
<div className="tcm-pillars tcm-2col" style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 0, border: "2px solid var(--color-text)" }}>
{PILLARS.map((p, i) => (
<div key={p.t} style={{ padding: "26px 28px 30px", borderRight: i % 2 === 0 ? "1px solid var(--color-divider)" : undefined, borderBottom: i < 2 ? "1px solid var(--color-divider)" : undefined }}>
<h3 style={{ ...h3, fontSize: 20 }}>{p.t}</h3>
<p style={{ fontSize: 14.5, lineHeight: 1.65, color: "var(--color-neutral-800)", marginTop: 10 }}>{p.b}</p>
</div>
))}
</div>
</Band>
<Band tone="surface">
<div style={kicker}>In plain English</div>
<h2 style={{ ...h2, marginTop: 12 }}>The commitments, without the certification language nobody reads.</h2>
<div style={{ marginTop: 28 }}>
{PLAIN.map((p) => (
<div key={p} style={{ display: "flex", gap: 16, padding: "14px 0", borderBottom: "1px solid var(--color-divider)", fontSize: 16, lineHeight: 1.6 }}>
<span style={{ width: 8, height: 8, background: "var(--color-accent)", flex: "none", marginTop: 8 }} />{p}
</div>
))}
</div>
</Band>
<Band>
<div style={{ border: "2px solid var(--color-text)", padding: "28px 32px 32px" }}>
<div style={kicker}>Before you deploy</div>
<h2 style={{ ...h2, fontSize: "clamp(22px,2.6vw,34px)", marginTop: 10 }}>Take it to your information security team early.</h2>
<p style={{ ...body, marginTop: 14, maxWidth: "58ch" }}>Hosting location, backup arrangement and retention period should be agreed with your facility before real staff data goes in with an information security team in a hospital, or with whoever signs off on systems in a smaller practice. Ask, and you&rsquo;ll get the answers in writing.</p>
<div style={{ display: "flex", gap: 12, marginTop: 24, flexWrap: "wrap" }}>
<Link href="/contact" className="btn btn-primary">Ask a security question</Link>
<Link href="/data-security" className="btn btn-secondary">Read the data security policy</Link>
</div>
</div>
</Band>
<CtaBand />
</>
);
}
+77
View File
@@ -0,0 +1,77 @@
import Link from "next/link";
import { Band, CtaBand, PageHead, SiteNav, body, h2, h3, kicker } from "@/components/site";
import { plansLive } from "@/lib/plans-live";
export const metadata = {
title: "Support",
description: "Support comes from the person who wrote the software — what that means in practice, and what it honestly doesnt cover.",
alternates: { canonical: "/support" },
};
// The handoff stated these as commitments ("Same day", "2 working days", "A week"). They are phrased
// here as what is aimed for, because one person alongside a day job can't guarantee a clock.
const TIERS = [
{ k: "Cant issue", time: "Usually same day", t: "The counter is stopped", b: "Anything stopping the room issuing, receiving or counting gets looked at first — normally the day its reported." },
{ k: "Somethings wrong", time: "Usually a couple of days", t: "It works, but not properly", b: "A number that looks off, a report that wont export, a slip printing the wrong field." },
{ k: "Can it do", time: "Usually within the week", t: "Questions and requests", b: "How-to questions and feature requests. Requests go on the roadmap in the order rooms ask for them." },
];
const HONEST = [
"Support is one person, alongside a day job. Nights and weekends arent covered.",
"There is no phone line. Everything goes through the contact form so its written down.",
"None of the times above is a contractual service level — theyre what usually happens, not a promise.",
"If your facility needs guaranteed response times in writing, have that conversation before a whole site depends on it.",
];
export default async function Support() {
const live = await plansLive();
return (
<>
<SiteNav />
<PageHead
kicker="Support"
title="One person answers. No ticket maze."
lede="Support comes from the person who wrote the software. That means straight answers, and it means being honest about how fast they arrive."
/>
<Band>
<div className="tcm-3col" style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 0, border: "2px solid var(--color-text)" }}>
{TIERS.map((t, i) => (
<div key={t.k} style={{ padding: "26px 28px 32px", borderRight: i < 2 ? "1px solid var(--color-divider)" : undefined }}>
<div style={kicker}>{t.k}</div>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: "clamp(22px,2.4vw,30px)", letterSpacing: "-0.02em", marginTop: 10 }}>{t.time}</div>
<div style={{ width: 44, height: 4, background: "var(--color-accent)", margin: "14px 0 14px" }} />
<h3 style={{ ...h3, fontSize: 18 }}>{t.t}</h3>
<p style={{ fontSize: 14.5, lineHeight: 1.65, color: "var(--color-neutral-800)", marginTop: 8 }}>{t.b}</p>
</div>
))}
</div>
</Band>
<Band tone="surface">
<div style={kicker}>Being straight about it</div>
<h2 style={{ ...h2, marginTop: 12 }}>{live ? "ThreadCount is maintained by one person." : "ThreadCount is free and maintained by one person."}</h2>
<p style={{ ...body, marginTop: 14, maxWidth: "56ch" }}>Here&rsquo;s what that honestly means, so nobody finds out the hard way.</p>
<div style={{ marginTop: 26 }}>
{HONEST.map((h) => (
<div key={h} style={{ display: "flex", gap: 16, padding: "14px 0", borderBottom: "1px solid var(--color-divider)", fontSize: 16, lineHeight: 1.6 }}>
<span style={{ width: 8, height: 8, background: "var(--color-accent)", flex: "none", marginTop: 8 }} />{h}
</div>
))}
</div>
</Band>
<Band>
<div className="tcm-split" style={{ display: "grid", gridTemplateColumns: "1fr auto", gap: 32, alignItems: "center" }}>
<h2 style={{ ...h2, fontSize: "clamp(22px,2.6vw,34px)" }}>Stuck on something? Say what it is.</h2>
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
<Link href="/contact" className="btn btn-primary">Get in touch</Link>
<Link href="/faq" className="btn btn-secondary">Check the FAQ</Link>
</div>
</div>
</Band>
<CtaBand />
</>
);
}
+9
View File
@@ -0,0 +1,9 @@
import LegalDoc from "@/components/LegalDoc";
import { DOC_META } from "@/lib/legal";
const M = DOC_META["Terms of Service"];
export const metadata = { title: M.title, description: M.desc, alternates: { canonical: M.path } };
export default function Page() {
return <LegalDoc name="Terms of Service" />;
}