Files
threadcount-community/components/PlanTab.tsx
T
ThreadCount 822c0b7c0b ThreadCount Community edition
Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from 8685140 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
2026-09-13 11:09:20 +10:00

139 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useState } from "react";
import { useSnap } from "@/lib/client";
import { Field, LiveRegion } from "@/components/ui";
import { PRICES } from "@/lib/plan";
/* Settings Plan. Admin only, and only once plans are live.
*
* Numbers and short labels, no selling: the plan's name, where it stands, the staff count against
* the ceiling when there is one, what the hosted backups keep, and the billing contact. Three
* things to do: ask for an invoice, which emails the owner and nothing more; pay by card, which
* leaves for a page Stripe hosts and appears only when the server has Stripe keys (`cardsOn`) and
* the room is not grandfathered; and download the backup, which is the same file Data offers and is
* here because a room deciding whether to pay should have its way out beside the button. */
const STATE_WORDS: Record<string, string> = {
grandfathered: "Free · everything included, for good",
free: "Free",
trial: "Trial",
active: "Paid",
grace: "Ended · still writable",
read_only: "Read-only",
};
export default function PlanTab() {
const { s, mutate } = useSnap();
const p = s.plan;
const cardsOn = (s.plan as { cardsOn?: boolean }).cardsOn === true;
const card = (s.plan as { card?: boolean }).card === true;
const [email, setEmail] = useState(p.billingEmail);
const [want, setWant] = useState<"hosted_facility" | "health_service">("hosted_facility");
const [msg, setMsg] = useState("");
const [busy, setBusy] = useState("");
const fmt = (iso: string | null) => iso ? new Date(iso).toLocaleDateString("en-AU", { day: "numeric", month: "short", year: "numeric" }) : "—";
// Back from Stripe: ?card=ok or ?card=cancelled, said once and taken off the address.
useEffect(() => {
const q = new URLSearchParams(window.location.search);
const back = q.get("card");
if (!back) return;
if (back === "ok") setMsg("Payment set up. The plan updates when Stripe confirms it — a minute at most.");
else if (back === "cancelled") setMsg("Card payment cancelled. Nothing was charged.");
q.delete("card");
const rest = q.toString();
window.history.replaceState(null, "", window.location.pathname + (rest ? `?${rest}` : ""));
}, []);
const row = (k: string, v: React.ReactNode) => (
<div style={{ display: "grid", gridTemplateColumns: "160px 1fr", borderBottom: "1px solid var(--color-divider)", fontSize: 13 }}>
<div style={{ padding: "10px 12px", fontSize: 11, fontWeight: 700, letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--color-neutral-600)", background: "var(--color-surface)" }}>{k}</div>
<div style={{ padding: "10px 12px", fontVariantNumeric: "tabular-nums" }}>{v}</div>
</div>
);
async function run(op: "plan.billing" | "plan.invoice", payload: Record<string, unknown>, done: string) {
setBusy(op); setMsg("");
const r = await mutate(op, payload);
setBusy("");
setMsg(r.ok ? done : r.error);
}
/** Checkout or the Billing Portal: both answer a URL to leave for. */
async function stripeGo(kind: "checkout" | "portal") {
setBusy(kind); setMsg("");
try {
const r = await fetch(`/api/billing/${kind}`, { method: "POST", headers: { "content-type": "application/json" }, body: "{}" });
const j = (await r.json().catch(() => ({}))) as { url?: string; error?: string };
if (r.ok && j.url) { window.location.assign(j.url); return; }
setMsg(j.error || "Could not reach the payment page.");
} catch {
setMsg("Could not reach the payment page.");
}
setBusy("");
}
const ends = p.state === "trial" ? "Trial ends" : p.state === "active" ? "Paid until" : p.state === "grace" ? "Writable until" : null;
const paidOrTrial = p.state === "active" || p.state === "trial";
// The card button: a room that could buy (free, trial, grace, read-only) and is not already
// paying by card. A card-backed room gets Manage card instead.
const showPay = cardsOn && !p.grandfathered && !card && p.state !== "active";
const showManage = cardsOn && !p.grandfathered && card;
return (
<>
<div className="sec" style={{ marginTop: "var(--space-6)" }}>Plan</div>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 24, letterSpacing: "-0.02em", marginTop: "var(--space-3)" }}>{p.label}</div>
<div style={{ border: "2px solid var(--color-text)", marginTop: "var(--space-3)", maxWidth: 640 }}>
{row("Status", <b style={{ color: p.readOnly ? "var(--color-accent-700)" : undefined }}>{STATE_WORDS[p.state] || p.state}{p.state === "trial" && p.daysLeft !== null ? ` · ${p.daysLeft} days left` : ""}{p.state === "grace" && p.graceDaysLeft !== null ? ` · ${p.graceDaysLeft} days` : ""}</b>)}
{row("Staff records", p.maxStaff === null ? String(p.staff) : `${p.staff} of ${p.maxStaff}`)}
{row("Backups", `Nightly · ${p.backupDays} days`)}
{ends && row(ends, fmt(p.state === "grace" ? p.graceEndsAt : p.endsAt))}
{card && row("Paid by", "Card · monthly")}
{p.org && row("Health service", p.org.name)}
{row("Billing contact", p.billingEmail || "—")}
{p.invoicer && row("Invoiced by", p.invoicer)}
</div>
{p.readOnly && (
<div style={{ fontSize: 13, marginTop: "var(--space-3)", maxWidth: 640, lineHeight: 1.6 }}>
Reports, exports and the backup still work. Writes start again when a payment is recorded.
</div>
)}
{!p.grandfathered && (
<>
<div className="sec" style={{ marginTop: "var(--space-6)" }}>{cardsOn ? "Invoice or card" : "Invoice"}</div>
<div className="tc-grid" style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "var(--space-3)", marginTop: "var(--space-3)", maxWidth: 640 }}>
<Field label="Billing contact" hint="Where the invoice goes.">{(c) => <input {...c} className="input" type="email" autoComplete="email" value={email} onChange={(e) => setEmail(e.target.value)} />}</Field>
<Field label="Plan">{(c) => (
<select {...c} className="input" value={want} onChange={(e) => setWant(e.target.value as typeof want)}>
<option value="hosted_facility">Hosted Facility · annual</option>
<option value="health_service">Health Service · annual</option>
</select>
)}</Field>
</div>
<div style={{ display: "flex", gap: "var(--space-2)", marginTop: "var(--space-3)", flexWrap: "wrap", alignItems: "center" }}>
<button className="btn btn-primary" disabled={!!busy || !email.trim()} onClick={() => run("plan.invoice", { email: email.trim(), plan: want }, "Requested. The invoice comes by email.")}>{busy === "plan.invoice" ? "Sending…" : "Request an invoice"}</button>
{showPay && (
<>
<button className="btn btn-secondary" disabled={!!busy} onClick={() => stripeGo("checkout")}>{busy === "checkout" ? "Opening…" : "Pay by card"}</button>
<span style={{ fontSize: 12, color: "var(--color-neutral-700)", fontVariantNumeric: "tabular-nums" }}>Hosted Facility · ${PRICES.hostedMonthly} a month, ex GST</span>
</>
)}
{showManage && <button className="btn btn-secondary" disabled={!!busy} onClick={() => stripeGo("portal")}>{busy === "portal" ? "Opening…" : "Manage card"}</button>}
<button className="btn btn-secondary" disabled={!!busy || email.trim() === p.billingEmail} onClick={() => run("plan.billing", { email: email.trim() }, "Saved.")}>Save contact</button>
<a className="btn btn-ghost" href="/api/backup" download>Download backup</a>
</div>
{paidOrTrial && !card && <div style={{ fontSize: 12, color: "var(--color-neutral-700)", marginTop: "var(--space-2)" }}>A renewal invoice is requested the same way.</div>}
</>
)}
{p.grandfathered && (
<div style={{ display: "flex", gap: "var(--space-2)", marginTop: "var(--space-3)", flexWrap: "wrap" }}>
<a className="btn btn-ghost" href="/api/backup" download>Download backup</a>
</div>
)}
<LiveRegion msg={msg} style={{ fontSize: 12, color: "var(--color-accent-700)", fontWeight: 600, marginTop: "var(--space-2)" }} />
</>
);
}