feat: HQ redesign

This commit is contained in:
2026-08-10 10:44:30 +01:00
parent e434beec7a
commit 1fe4ba5999
14 changed files with 686 additions and 200 deletions
+34 -10
View File
@@ -6,7 +6,8 @@ import { NotConnectedPanel } from "@/components/NotConnected";
import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame";
import { PageHeader } from "@/components/PageHeader";
import { ManageBillingButton } from "@/components/ManageBillingButton";
import { formatDate } from "@/lib/format";
import { TermSpark } from "@/components/TermBar";
import { formatDate, licenceState } from "@/lib/format";
export default function BillingPage() {
const subs = useQuery({ queryKey: ["subscriptions"], queryFn: api.subscriptions });
@@ -22,6 +23,21 @@ export default function BillingPage() {
// difference between "professional · annual" and knowing which install that is.
const nameFor = (instanceId?: string) => account.data?.instances.find((i) => i.instance_id === instanceId)?.name;
/*
* A subscription reports when the period ends but not when it began, so the
* start is derived from the term. Only the two terms we actually sell are
* handled — anything else returns null and the row falls back to the date
* alone, because a bar drawn from a guessed span is worse than no bar.
*/
const periodStart = (end: string, term: string): string | null => {
const months = /ann|year/i.test(term) ? 12 : /month/i.test(term) ? 1 : 0;
if (!months) return null;
const d = new Date(end);
if (Number.isNaN(d.getTime())) return null;
d.setMonth(d.getMonth() - months);
return d.toISOString();
};
return (
<div className="grid gap-6">
<PageHeader
@@ -71,15 +87,23 @@ export default function BillingPage() {
</tr>
</thead>
<tbody>
{rows.map((s) => (
<tr key={s.subscription_id} className="border-b border-rule-soft last:border-0">
<td className="px-4 py-3">{nameFor(s.instance_id) ?? <span className="text-ink-3">Not linked yet</span>}</td>
<td className="px-4 py-3">{s.tier.replace("_", " ")}</td>
<td className="px-4 py-3">{s.term}</td>
<td className="px-4 py-3">{s.status}</td>
<td className="px-4 py-3 font-mono tabular-nums">{formatDate(s.current_period_end)}</td>
</tr>
))}
{rows.map((s) => {
const start = periodStart(s.current_period_end, s.term);
return (
<tr key={s.subscription_id} className="border-b border-rule-soft last:border-0">
<td className="px-4 py-3">{nameFor(s.instance_id) ?? <span className="text-ink-3">Not linked yet</span>}</td>
<td className="px-4 py-3">{s.tier.replace("_", " ")}</td>
<td className="px-4 py-3">{s.term}</td>
<td className="px-4 py-3">{s.status}</td>
<td className="px-4 py-3">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
{start && <TermSpark issuedAt={start} expiresAt={s.current_period_end} state={licenceState(s.current_period_end, true)} />}
<span className="font-mono text-[0.78rem] tabular-nums text-ink-2">{formatDate(s.current_period_end)}</span>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
@@ -9,6 +9,7 @@ import { LicenceDelivery } from "@/components/LicenceDelivery";
import { MembersPanel } from "@/components/MembersPanel";
import { RelinkPanel } from "@/components/RelinkPanel";
import { StatePill } from "@/components/StatePill";
import { TermBar } from "@/components/TermBar";
import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame";
import { PageHeader } from "@/components/PageHeader";
import { formatDate, licenceState, limitLabel } from "@/lib/format";
@@ -136,6 +137,35 @@ export default function InstancePage() {
</>
}
>
{/*
* The term leads. This screen is about one licence, and the rail
* already carried its issue and expiry dates as two lines of
* text — which is the arithmetic this bar does for the reader.
*/}
{lic && (
<section className="grid gap-3 rounded border border-rule bg-panel p-5">
<div className="flex flex-wrap items-baseline justify-between gap-3">
<h2 className="text-[0.95rem] font-bold">Licence</h2>
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">
{lic.tier.replace("_", " ")} · {cloud ? "Cloud" : "Self-hosted"}
</span>
</div>
<TermBar issuedAt={lic.issued_at} expiresAt={lic.expires_at} state={state} />
{state === "warn" && (
<p className="rounded border border-rule border-l-[3px] border-l-warn bg-panel-2 px-3.5 py-2.5 text-[0.84rem] text-ink-2">
Renewing extends the term from the current expiry, not from today, so nothing is lost by renewing early.
</p>
)}
{state === "expired" && (
<p className="rounded border border-rule border-l-[3px] border-l-expired bg-panel-2 px-3.5 py-2.5 text-[0.84rem] text-ink-2">
Servers and monitors keep running and your agents keep their keys. Changes are disabled until this is renewed.
</p>
)}
</section>
)}
{cloud ? (
<MembersPanel instanceId={instance.instance_id} />
) : (
+104 -27
View File
@@ -34,21 +34,55 @@ export default function OverviewPage() {
const live = data.instances.filter((i) => i.status !== "deleted");
// Work the customer has to do, gathered across every instance. This is the
// only account-level view of it each record only knows about itself.
/*
* Work the customer has to do, gathered across every instance. This is the
* only account-level view of it — each record only knows about itself.
*
* Each item carries the way out of it. It used to be a list of sentences in
* the rail, which told someone their licence was expiring and then made
* them go and find the instance that owned it; the fix for every one of
* these is one click, so the click belongs on the row.
*/
const attention = live.flatMap((i) => {
const lic = byInstance.get(i.instance_id);
const state = licenceState(lic?.expires_at, Boolean(lic));
if (state === "none") return [{ id: i.instance_id, text: `${i.name || "An instance"} is not linked`, note: "" }];
if (state === "expired") return [{ id: i.instance_id, text: `${i.name} has expired`, note: "now" }];
if (state === "warn")
const name = i.name || "An instance";
if (state === "none")
return [
{
id: i.instance_id,
text: `${i.name} expires`,
note: `${daysRemaining(lic!.expires_at)}d`,
text: `${name} is waiting for an install ID`,
note: "You have paid for this. Paste the UUID from the install to get your licence.",
href: i.status === "awaiting_link" ? `/instances/link?claim=${i.instance_id}` : "/purchase",
action: i.status === "awaiting_link" ? "Link install" : "Get a licence",
tag: "",
},
];
if (state === "expired")
return [
{
id: i.instance_id,
text: `${name} has expired`,
note: "Servers keep running and agents keep their keys, but changes are disabled until you renew.",
href: `/instances/${i.instance_id}`,
action: "Renew",
tag: "now",
},
];
if (state === "warn") {
const d = daysRemaining(lic!.expires_at);
return [
{
id: i.instance_id,
text: `${name} expires in ${d} ${d === 1 ? "day" : "days"}`,
note: "Renewing extends the term from the current expiry, so nothing is lost by renewing early.",
href: `/instances/${i.instance_id}`,
action: "Renew",
tag: `${d}d`,
},
];
}
return [];
});
@@ -71,32 +105,43 @@ export default function OverviewPage() {
/>
{live.length === 0 ? (
<div className="grid max-w-xl gap-3 rounded border border-rule bg-panel p-5">
<h2 className="text-xl">No instances yet</h2>
<p className="text-ink-2">
Create a free cloud instance and we host it, with your licence applied automatically. Or run Vantage on your own server and get its licence free or paid from the purchase page.
</p>
<div className="flex flex-wrap gap-2.5">
<LinkButton href="/purchase">Buy a plan</LinkButton>
/*
* An empty screen is an invitation to act, and the two ways in
* are genuinely different products — we host it, or you do. One
* button and a paragraph explaining the other option made the
* self-hosted path read as an afterthought, which it is not.
*/
<div className="grid gap-4 rounded border border-rule bg-panel p-6">
<div className="grid gap-2">
<h2 className="text-xl">No instances yet</h2>
<p className="max-w-[52ch] text-ink-2">An instance is one Vantage control plane. Start a hosted one in about a minute, or license an install you run yourself.</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid content-start gap-2 rounded border border-rule p-4">
<h3 className="text-[1.05rem]">Cloud</h3>
<p className="text-[0.82rem] text-ink-2">We host it, on a subdomain of vantage.hostxtra.co.uk, with the licence applied for you.</p>
<div className="pt-1">
<LinkButton href="/purchase">Create a cloud instance</LinkButton>
</div>
</div>
<div className="grid content-start gap-2 rounded border border-rule p-4">
<h3 className="text-[1.05rem]">Self-hosted</h3>
<p className="text-[0.82rem] text-ink-2">You host it. Get the licence here, then paste your install&rsquo;s ID to bind it.</p>
<div className="pt-1">
<LinkButton variant="line" href="/purchase">
License my own install
</LinkButton>
</div>
</div>
</div>
<p className="text-[0.78rem] text-ink-3">The Free tier covers 5 servers and needs no card.</p>
</div>
) : (
<PageFrame
aside={
<>
{attention.length > 0 && (
<RailCard title="Needs you" count={attention.length}>
<ul className="grid gap-2">
{attention.map((a) => (
<li key={a.id} className="flex items-center justify-between gap-2.5 text-[0.82rem] text-ink-2">
<span>{a.text}</span>
{a.note && <span className="font-mono text-[0.64rem] uppercase tracking-[0.08em] text-warn">{a.note}</span>}
</li>
))}
</ul>
</RailCard>
)}
<RailCard title="Your team" count={people.data?.length}>
<ul className="grid gap-2">
{(people.data ?? []).slice(0, 5).map((p) => (
@@ -147,6 +192,38 @@ export default function OverviewPage() {
</>
}
>
{/*
* First in the main column, not in the rail. This is the
* reason the page is open; the rail is for things that are
* merely true. It disappears entirely when there is nothing
* in it rather than saying "all clear", which is a line
* nobody needs to read twice a week.
*/}
{attention.length > 0 && (
<section className="grid overflow-hidden rounded border border-rule bg-panel">
<div className="flex items-center justify-between gap-3 border-b border-rule-soft px-4 py-3">
<h2 className="text-[0.95rem] font-bold">Needs you</h2>
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">
{attention.length} {attention.length === 1 ? "item" : "items"}
</span>
</div>
<ul className="grid">
{attention.map((a) => (
<li key={a.id} className="flex flex-wrap items-center justify-between gap-3 border-b border-rule-soft px-4 py-3 last:border-b-0">
<div className="grid min-w-0 gap-0.5">
<span className="flex items-center gap-2 text-[0.9rem] font-semibold">
{a.text}
{a.tag && <span className="font-mono text-[0.62rem] uppercase tracking-[0.1em] text-warn">{a.tag}</span>}
</span>
<span className="text-[0.8rem] text-ink-3">{a.note}</span>
</div>
<LinkButton href={a.href}>{a.action}</LinkButton>
</li>
))}
</ul>
</section>
)}
{live.map((i, n) => {
const lic = byInstance.get(i.instance_id);
const state = licenceState(lic?.expires_at, Boolean(lic));
@@ -8,6 +8,8 @@ import clsx from "clsx";
import { api, type Deployment, type InjectionState } from "@/lib/api";
import { Ledger } from "@/components/Ledger";
import { PageHeader } from "@/components/PageHeader";
import { TermBar } from "@/components/TermBar";
import { licenceState } from "@/lib/format";
import PlanConfigurator, { type PlanChoice } from "@/components/PlanConfigurator";
import { IssuePanel } from "./IssuePanel";
@@ -32,6 +34,7 @@ export default function StaffInstancePage() {
if (isLoading || !data) return <p className="text-ink-3">Loading</p>;
const inj = data.injection.state ? INJECTION[data.injection.state] : undefined;
const current = data.licenses.find((l) => !l.superseded_by);
return (
<div className="grid gap-8">
@@ -59,6 +62,21 @@ export default function StaffInstancePage() {
{data.injection.applicable && inj && <p className={clsx("font-mono text-[0.72rem]", inj.tone)}>{inj.label}</p>}
</div>
{/*
* The live licence is the one nothing has superseded, which is the
* record's own statement of the fact — not its position in the
* array, which is the server's ordering and not a guarantee.
*/}
{current && (
<section className="grid gap-3 rounded border border-rule bg-panel p-5">
<div className="flex flex-wrap items-baseline justify-between gap-3">
<h2 className="text-xl">Current licence</h2>
<span className="font-mono text-[0.72rem] tabular-nums text-ink-3">{current.license_id}</span>
</div>
<TermBar issuedAt={current.issued_at} expiresAt={current.expires_at} state={licenceState(current.expires_at, true)} className="max-w-xl" />
</section>
)}
<section className="grid gap-3 rounded border border-rule bg-panel p-5">
<h2 className="text-xl">Licence history</h2>
<Ledger licenses={data.licenses} />
+14 -1
View File
@@ -4,8 +4,9 @@ import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { useState } from "react";
import { api, type Tier } from "@/lib/api";
import { formatDate } from "@/lib/format";
import { formatDate, licenceState } from "@/lib/format";
import { PageHeader } from "@/components/PageHeader";
import { TermSpark } from "@/components/TermBar";
export default function LicensesPage() {
const [tier, setTier] = useState<"" | Tier>("");
@@ -60,6 +61,7 @@ export default function LicensesPage() {
<th className="px-4 py-2.5">Instance</th>
<th className="px-4 py-2.5">Tier</th>
<th className="px-4 py-2.5">Reason</th>
<th className="px-4 py-2.5">Term</th>
<th className="px-4 py-2.5">Expires</th>
<th className="px-4 py-2.5">State</th>
</tr>
@@ -83,6 +85,17 @@ export default function LicensesPage() {
</td>
<td className="px-4 py-3">{l.tier.replace("_", " ")}</td>
<td className="px-4 py-3">{l.reason.replace("_", " ")}</td>
{/* A superseded row's term is not a countdown to
anything — it ended when its successor was
issued, so drawing a bar for it would invite
a comparison that means nothing. */}
<td className="px-4 py-3">
{l.superseded_by ? (
<span className="font-mono text-[0.72rem] text-ink-3">superseded</span>
) : (
<TermSpark issuedAt={l.issued_at} expiresAt={l.expires_at} state={licenceState(l.expires_at, true)} />
)}
</td>
<td className="px-4 py-3 font-mono tabular-nums">
{formatDate(l.expires_at)}
</td>
+48 -43
View File
@@ -1,12 +1,12 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { Suspense, useState } from "react";
import { ApiError, api } from "@/lib/api";
import { Button } from "@/components/Button";
import { Field } from "@/components/Field";
import { AuthMessage, AuthShell } from "@/components/AuthShell";
function AcceptForm() {
const token = useSearchParams().get("token") ?? "";
@@ -17,60 +17,65 @@ function AcceptForm() {
const accept = useMutation({
mutationFn: () => api.acceptInvite(token, password),
onSuccess: () => setDone(true),
onError: (e) =>
setError(e instanceof ApiError ? e.message : "Something went wrong. Try again."),
onError: (e) => setError(e instanceof ApiError ? e.message : "Something went wrong. Try again."),
});
if (!token) return <p className="text-ink-2">That link is missing its token.</p>;
if (!token)
return (
<AuthMessage
title="That link is incomplete"
body="It is missing its token. Use the link in the invitation exactly as sent — some mail clients cut long links in half."
action={{ href: "/login", label: "Go to sign in" }}
/>
);
if (done)
return (
<div className="grid gap-3">
<h1 className="text-3xl">You&apos;re in</h1>
<p className="text-ink-2">Sign in with your email address and new password.</p>
<Link href="/login" className="font-semibold text-accent underline">
Sign in
</Link>
</div>
<AuthMessage
title="You're in"
body="Sign in with your email address and the password you just set."
action={{ href: "/login", label: "Sign in" }}
/>
);
return (
<form
className="grid max-w-md gap-4"
onSubmit={(e) => {
e.preventDefault();
setError(null);
accept.mutate();
}}
<AuthShell
title="Choose a password"
lede="You have been invited to a Vantage HQ account."
footnote="Nobody who invited you can see this password, and it is never sent to them."
>
<h1 className="text-3xl">Choose a password</h1>
<p className="text-ink-2">
This password signs you into Vantage HQ and into every instance you are given
access to. Nobody who invited you can see it.
</p>
<Field
label="New password"
type="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={12}
hint="At least 12 characters."
error={error ?? undefined}
/>
<Button type="submit" disabled={accept.isPending || password.length < 12}>
{accept.isPending ? "Setting…" : "Set password"}
</Button>
</form>
<form
className="grid gap-4"
onSubmit={(e) => {
e.preventDefault();
setError(null);
accept.mutate();
}}
>
<p className="text-[0.86rem] text-ink-2">This password signs you into Vantage HQ and into every instance you are given access to.</p>
<Field
label="New password"
type="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={12}
hint="At least 12 characters."
error={error ?? undefined}
/>
<Button type="submit" disabled={accept.isPending || password.length < 12} className="w-full justify-center">
{accept.isPending ? "Setting…" : "Set password and continue"}
</Button>
</form>
</AuthShell>
);
}
export default function AcceptInvitePage() {
return (
<main className="mx-auto max-w-rail px-5 py-16">
<Suspense fallback={<p className="text-ink-3">Loading</p>}>
<AcceptForm />
</Suspense>
</main>
<Suspense fallback={<AuthShell title="Choose a password" lede="One moment." />}>
<AcceptForm />
</Suspense>
);
}
+46 -71
View File
@@ -6,6 +6,7 @@ import { API_BASE, ApiError, NotConnected, api } from "@/lib/api";
import { NotConnectedPanel } from "@/components/NotConnected";
import { Button } from "@/components/Button";
import { Field } from "@/components/Field";
import { AuthShell } from "@/components/AuthShell";
const SITE_URL = (process.env.NEXT_PUBLIC_SITE_URL ?? "").replace(/\/$/, "");
@@ -38,82 +39,56 @@ export default function LoginPage() {
if (offline)
return (
<Main>
<AuthShell title="Sign in">
<NotConnectedPanel url={API_BASE} />
</Main>
</AuthShell>
);
return (
<Main>
{/* The masthead's lockup, unlinked: there is nowhere to go yet. */}
<div className="mb-7 flex flex-col items-center gap-2 text-center">
<span className="flex items-baseline gap-2 text-[1.5rem] font-extrabold tracking-[-0.02em]">
Vantage
<span className="font-mono text-[0.78rem] font-normal uppercase tracking-[0.14em] text-ink-3">
HQ
</span>
</span>
<h1 className="text-[1.16rem]">Sign in</h1>
<p className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
Licences · instances · billing
</p>
</div>
<AuthShell
title="Sign in"
lede="Licences, instances and billing for your account."
/*
* HQ and the Vantage console are separate sign-ins on separate
* hosts, and the two get confused — someone lands here with their
* console password and reads the generic failure as a broken
* account. Saying which door this is costs one line.
*/
footnote="This is the portal for your licence and billing. Your servers are managed inside your Vantage instance, which signs in separately."
>
<form onSubmit={submit} className="grid gap-4">
<Field label="Email" type="email" autoComplete="username" required value={email} onChange={(e) => setEmail(e.target.value)} />
<Field
label="Password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
error={error ?? undefined}
/>
<label className="flex items-center gap-2 text-[0.82rem] text-ink-2">
<input type="checkbox" checked={staff} onChange={(e) => setStaff(e.target.checked)} className="accent-[var(--accent)]" />
I work at Vantage
</label>
<Button type="submit" disabled={busy} className="w-full justify-center">
{busy ? "Signing in…" : "Sign in"}
</Button>
</form>
<div className="rounded border border-rule bg-panel p-6 shadow-[var(--shadow)]">
<form onSubmit={submit} className="grid gap-4">
<Field
label="Email"
type="email"
autoComplete="username"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<Field
label="Password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
error={error ?? undefined}
/>
<label className="flex items-center gap-2 text-[0.82rem] text-ink-2">
<input
type="checkbox"
checked={staff}
onChange={(e) => setStaff(e.target.checked)}
className="accent-[var(--accent)]"
/>
I work at Vantage
</label>
<Button type="submit" disabled={busy} className="w-full justify-center">
{busy ? "Signing in…" : "Sign in"}
</Button>
</form>
{SITE_URL && (
<>
<div className="h-px bg-rule-soft" />
{SITE_URL && (
<>
<div className="my-5 h-px bg-rule-soft" />
{/* Signup lives on the marketing site's /start, not here. */}
<p className="text-center text-[0.82rem] text-ink-3">
No account?{" "}
<a href={`${SITE_URL}/start`} className="text-accent underline">
Create one
</a>
</p>
</>
)}
</div>
</Main>
);
}
function Main({ children }: { children: React.ReactNode }) {
return (
<main className="mx-auto flex min-h-screen w-full max-w-[26rem] flex-col justify-center px-5 py-12">
{children}
</main>
{/* Signup lives on the marketing site's /start, not here. */}
<p className="text-center text-[0.82rem] text-ink-3">
No account?{" "}
<a href={`${SITE_URL}/start`} className="text-accent underline">
Create one
</a>
</p>
</>
)}
</AuthShell>
);
}
+38 -27
View File
@@ -2,9 +2,11 @@
import { useQuery } from "@tanstack/react-query";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { Suspense, useEffect } from "react";
import { api } from "@/lib/api";
import { AuthMessage, AuthShell } from "@/components/AuthShell";
const SITE_URL = (process.env.NEXT_PUBLIC_SITE_URL ?? "").replace(/\/$/, "");
function Verify() {
const router = useRouter();
@@ -25,50 +27,59 @@ function Verify() {
router.replace(`/accept-invite?token=${encodeURIComponent(token)}`);
}
}, [needsPassword, token, router]);
if (needsPassword) return <Message title="One moment…" body="Taking you to set a password." />;
if (needsPassword) return <AuthShell title="One moment…" lede="Taking you to set a password." />;
if (!token)
return (
<Message
<AuthMessage
title="That link is incomplete"
body="It is missing its token. Use the link in the email exactly as sent."
body="It is missing its token. Use the link in the email exactly as sent — some mail clients cut long links in half."
action={{ href: "/login", label: "Go to sign in" }}
/>
);
if (isLoading) return <Message title="Verifying…" body="One moment." />;
if (isLoading) return <AuthShell title="Verifying…" lede="One moment." />;
if (error || !data?.verified)
return (
<Message
<AuthMessage
title="That link is invalid or has expired"
body="Links last 24 hours and can only be used once. Sign up again to get a fresh one."
body="Links last 24 hours and can only be used once. Signing in will send you a fresh one."
action={{ href: "/login", label: "Go to sign in" }}
/>
);
return (
<div className="grid max-w-xl gap-3">
<h1 className="text-3xl">Email verified</h1>
<p className="text-ink-2">Your account is ready.</p>
<Link href="/login" className="justify-self-start text-accent underline">
<AuthShell
title="Email verified"
lede="Your account is ready."
footnote={
SITE_URL ? (
<>
New to Vantage? The{" "}
<a href={`${SITE_URL}/docs`} className="text-accent underline">
getting started guide
</a>{" "}
walks through your first instance.
</>
) : undefined
}
>
<p className="text-[0.9rem] text-ink-2">Sign in to create your first instance. The Free tier covers 5 servers and needs no card.</p>
<a
href="/login"
className="inline-flex items-center justify-center gap-2 rounded border border-accent bg-accent px-3.5 py-2 text-[0.86rem] font-semibold text-accent-ink no-underline"
>
Sign in
</Link>
</div>
);
}
function Message({ title, body }: { title: string; body: string }) {
return (
<div className="grid max-w-xl gap-3">
<h1 className="text-3xl">{title}</h1>
<p className="text-ink-2">{body}</p>
</div>
</a>
</AuthShell>
);
}
export default function VerifyPage() {
return (
<main className="mx-auto max-w-rail px-5 py-12">
<Suspense fallback={null}>
<Verify />
</Suspense>
</main>
<Suspense fallback={<AuthShell title="Verifying…" lede="One moment." />}>
<Verify />
</Suspense>
);
}
+67
View File
@@ -0,0 +1,67 @@
import Link from "next/link";
/*
* The frame for every screen you can reach without a session: sign in, email
* verification, and accepting an invitation.
*
* These three had drifted into three different layouts. Sign in was a centred
* 26rem card with the lockup above it; verify and accept-invite were bare
* left-aligned text on the full 1200px rail, with no masthead, no panel and no
* brand anywhere on the page. Those two are the first screens a new customer
* ever sees — arriving from an email, on a domain they have not visited before
* — and they were the two that did not say whose product this is.
*
* There is no AppBar here on purpose: it carries navigation and an account
* menu, and none of it works without a session.
*/
export function AuthShell({
title,
lede,
children,
footnote,
}: {
title: string;
lede?: React.ReactNode;
children?: React.ReactNode;
/** Sits outside the panel: orientation, not part of the task. */
footnote?: React.ReactNode;
}) {
return (
<main className="mx-auto flex min-h-screen w-full max-w-[26rem] flex-col justify-center px-5 py-12">
{/* The masthead's lockup, unlinked: there is nowhere to go yet. */}
<div className="mb-7 flex flex-col items-center gap-2 text-center">
<span className="flex items-baseline gap-2 text-[1.5rem] font-extrabold tracking-[-0.02em]">
Vantage
<span className="font-mono text-[0.78rem] font-normal uppercase tracking-[0.14em] text-ink-3">HQ</span>
</span>
<h1 className="text-[1.16rem]">{title}</h1>
{lede && <p className="text-[0.86rem] text-ink-2">{lede}</p>}
</div>
{children && <div className="grid gap-4 rounded border border-rule bg-panel p-6 shadow-[var(--shadow)]">{children}</div>}
{footnote && <div className="mt-5 text-center text-[0.8rem] text-ink-3">{footnote}</div>}
</main>
);
}
/*
* A terminal state — verified, expired, already used, invalid. Always says what
* happened and what to do next: a dead end that only reports the failure leaves
* someone holding an email they cannot act on.
*/
export function AuthMessage({ title, body, action }: { title: string; body: React.ReactNode; action?: { href: string; label: string } }) {
return (
<AuthShell title={title}>
<p className="text-[0.9rem] text-ink-2">{body}</p>
{action && (
<Link
href={action.href}
className="inline-flex items-center justify-center gap-2 rounded border border-accent bg-accent px-3.5 py-2 text-[0.86rem] font-semibold text-accent-ink no-underline"
>
{action.label}
</Link>
)}
</AuthShell>
);
}
+5 -17
View File
@@ -7,6 +7,7 @@ import { useEffect, useState } from "react";
import { api, type Instance, type License } from "@/lib/api";
import { daysRemaining, formatDate, licenceState, limitLabel } from "@/lib/format";
import { StatePill } from "./StatePill";
import { TermBar } from "./TermBar";
import { Button, LinkButton } from "./Button";
const STRIPE = {
@@ -35,7 +36,6 @@ export function InstanceRecord({ instance, license, reapAfterDays, defaultOpen =
const state = licenceState(license?.expires_at, Boolean(license));
const days = license ? daysRemaining(license.expires_at) : 0;
const cloud = instance.deployment === "cloud";
const termDays = instance.tier === "free" ? 30 : 365;
const deleteInDays = license && reapAfterDays ? daysRemaining(license.expires_at) + reapAfterDays : null;
const [open, setOpen] = useState(defaultOpen);
@@ -102,22 +102,10 @@ export function InstanceRecord({ instance, license, reapAfterDays, defaultOpen =
</div>
</div>
{license && state !== "expired" && (
<div className="grid max-w-md gap-1.5">
<div className="flex justify-between font-mono text-[0.78rem] tabular-nums text-ink-2">
<span>{days} days remaining</span>
<span>Renews {formatDate(license.expires_at)}</span>
</div>
<div className="h-1 overflow-hidden rounded-sm bg-rule-soft">
<div
className={clsx("h-full", state === "warn" ? "bg-warn" : "bg-valid")}
style={{
width: `${Math.max(2, Math.min(100, (days / termDays) * 100))}%`,
}}
/>
</div>
</div>
)}
{/* The term is drawn for an expired licence too. The old bar hid
itself once it lapsed, which removed the measurement at exactly
the moment it started mattering. */}
{license && <TermBar issuedAt={license.issued_at} expiresAt={license.expires_at} state={state} className="max-w-md" />}
{state === "expired" && (
<div className="grid gap-1">
+91
View File
@@ -0,0 +1,91 @@
import clsx from "clsx";
/*
* The surface every screen is built from.
*
* Before this there were four panel treatments in the app: `rounded border
* border-rule bg-panel p-5` with an `<h2 className="text-xl">`, the same thing
* with `text-[0.95rem] font-medium`, a bare `<section className="space-y-2">`
* with no border at all, and a table wrapper that was a panel in everything but
* name. They were all trying to be the same object.
*
* The header is title-left, meta-right. Meta is the keyed idiom — mono, small,
* tracked, dimmed — because it is always a count, a scope or an identifier,
* never prose.
*/
export function Panel({
title,
meta,
actions,
tone,
children,
bodyless,
className,
}: {
title?: string;
meta?: React.ReactNode;
actions?: React.ReactNode;
/** Draws the panel's own border in a state colour. For a panel that IS the warning. */
tone?: "warn" | "expired";
children: React.ReactNode;
/** Skip the padded body — for a panel whose content is a full-bleed table. */
bodyless?: boolean;
className?: string;
}) {
const head = title || meta || actions;
return (
<section
className={clsx(
"grid overflow-hidden rounded border bg-panel",
tone === "warn" ? "border-warn" : tone === "expired" ? "border-expired" : "border-rule",
className,
)}
>
{head && (
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-rule-soft px-4 py-3">
{title && <h2 className="text-[0.95rem] font-bold tracking-[-0.01em]">{title}</h2>}
<div className="flex items-center gap-3">
{meta && <span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">{meta}</span>}
{actions}
</div>
</header>
)}
{bodyless ? children : <div className="grid gap-3.5 p-4">{children}</div>}
</section>
);
}
/*
* An aside that is part of the argument rather than beside it: the consequence
* of the action on screen, or the constraint the reader is about to hit. The
* left rule carries the tone, so the note reads as annotation and never as a
* second panel competing with the one it sits in.
*/
export function Note({ tone = "accent", children }: { tone?: "accent" | "warn" | "expired"; children: React.ReactNode }) {
return (
<p
className={clsx(
"rounded border border-rule border-l-[3px] bg-panel-2 px-3.5 py-2.5 text-[0.84rem] text-ink-2",
tone === "warn" ? "border-l-warn" : tone === "expired" ? "border-l-expired" : "border-l-accent",
)}
>
{children}
</p>
);
}
/*
* An empty screen is an invitation to act. Every one of these says what the
* thing is before offering to make one — "No licences match those filters" on
* its own tells someone the filter worked, not what to do about it.
*/
export function EmptyState({ title, body, action }: { title: string; body?: React.ReactNode; action?: React.ReactNode }) {
return (
<div className="grid justify-items-center gap-2 px-5 py-12 text-center">
<p className="text-[1rem] font-bold">{title}</p>
{body && <p className="max-w-[46ch] text-[0.86rem] text-ink-2">{body}</p>}
{action && <div className="mt-2">{action}</div>}
</div>
);
}
+9 -4
View File
@@ -1,5 +1,6 @@
import Link from "next/link";
import clsx from "clsx";
import type { ReactNode } from "react";
const TONE = {
expired: "border-l-expired text-expired",
@@ -16,7 +17,11 @@ export function Queue({
title: string;
count: number;
tone: keyof typeof TONE;
items: { label: string; href: string; meta: string }[];
/* `meta` is a node rather than a string so a queue about time can carry the
* term measurement itself. A tier name told the reader what the instance
* was; the queue is sorted by how soon it lapses, and that was the one
* figure the row did not show. */
items: { label: string; href: string; meta: ReactNode }[];
}) {
return (
<section
@@ -38,12 +43,12 @@ export function Queue({
{items.map((i) => (
<li
key={i.href}
className="flex justify-between gap-2 font-mono text-[0.72rem] text-ink-2"
className="flex items-center justify-between gap-2 font-mono text-[0.72rem] text-ink-2"
>
<Link href={i.href} className="text-accent underline">
<Link href={i.href} className="truncate text-accent underline">
{i.label}
</Link>
<span className="tabular-nums">{i.meta}</span>
<span className="shrink-0 tabular-nums">{i.meta}</span>
</li>
))}
</ul>
+83
View File
@@ -0,0 +1,83 @@
import clsx from "clsx";
import type { HTMLAttributes, TdHTMLAttributes, ThHTMLAttributes } from "react";
/*
* One table treatment for the whole console.
*
* There were four: billing, licences, accounts and catalogue each wrote their
* own thead, and they disagreed about the head's type size, its tracking,
* whether it sat on --panel-2, and whether numbers were tabular. Catalogue's
* heads were sentence-case body text. A registry whose columns are set four
* ways does not read as one product.
*
* The head is the keyed idiom — mono, small, uppercase, widely tracked — which
* is what a column head is: a key above a value, exactly as the record line is
* a key beside one.
*/
export function Table({ className, children, ...props }: HTMLAttributes<HTMLTableElement>) {
return (
<div className="overflow-x-auto">
<table className={clsx("w-full border-collapse text-left text-[0.86rem]", className)} {...props}>
{children}
</table>
</div>
);
}
export function THead({ className, children, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
return (
<thead className={clsx("border-b border-rule", className)} {...props}>
{children}
</thead>
);
}
export function TBody({ className, children, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
return (
<tbody className={className} {...props}>
{children}
</tbody>
);
}
export function TR({ className, children, ...props }: HTMLAttributes<HTMLTableRowElement>) {
return (
<tr className={clsx("border-b border-rule-soft last:border-0 hover:bg-panel-2", className)} {...props}>
{children}
</tr>
);
}
interface CellProps {
/** Right-aligns the cell. For quantities and money, which read down the column. */
numeric?: boolean;
}
export function TH({ className, numeric, children, ...props }: ThHTMLAttributes<HTMLTableCellElement> & CellProps) {
return (
<th
className={clsx(
"whitespace-nowrap px-4 py-2.5 font-mono text-[0.62rem] font-normal uppercase tracking-[0.13em] text-ink-3",
numeric && "text-right",
className,
)}
{...props}
>
{children}
</th>
);
}
export function TD({ className, numeric, children, ...props }: TdHTMLAttributes<HTMLTableCellElement> & CellProps) {
return (
<td className={clsx("px-4 py-3 align-middle", numeric && "text-right tabular-nums", className)} {...props}>
{children}
</td>
);
}
/** The secondary line under a cell's main value — an ID, a deployment, a date. */
export function Sub({ children }: { children: React.ReactNode }) {
return <div className="text-[0.78rem] text-ink-3">{children}</div>;
}
+99
View File
@@ -0,0 +1,99 @@
import clsx from "clsx";
import { daysRemaining, formatDate, type LicenceState } from "@/lib/format";
/*
* A licence's life as a measured line: issued at the left, expiry at the right,
* today as a notch, the part you have not got yet hatched.
*
* This replaces a 1px progress rule and a "Renews 19 Aug 2026" caption. The
* date is still there, but a date alone makes the reader do the arithmetic that
* is the only question this product is ever asked — when does this stop
* working. The bar answers it before they read a word.
*
* The fill takes the state's colour, so the same vocabulary the pill uses
* carries through. State is never colour alone here either: the remaining span
* is hatched rather than tinted, the notch is a hard edge, and the days-left
* figure is written out.
*/
const TONE: Record<LicenceState, string> = {
valid: "text-valid",
warn: "text-warn",
expired: "text-expired",
none: "text-accent",
};
function span(issuedAt: string, expiresAt: string) {
const start = new Date(issuedAt).getTime();
const end = new Date(expiresAt).getTime();
const total = end - start;
// A licence issued and expiring at the same instant is not a real record,
// but it must not divide by zero on the way to being rendered.
if (!Number.isFinite(total) || total <= 0) return 100;
const elapsed = Date.now() - start;
return Math.max(0, Math.min(100, (elapsed / total) * 100));
}
export function TermBar({
issuedAt,
expiresAt,
state,
className,
}: {
issuedAt: string;
expiresAt: string;
state: LicenceState;
className?: string;
}) {
const pct = span(issuedAt, expiresAt);
const days = daysRemaining(expiresAt);
const expired = days <= 0;
const remaining = expired
? `Expired ${Math.abs(days)} ${Math.abs(days) === 1 ? "day" : "days"} ago`
: `${days} ${days === 1 ? "day" : "days"} left`;
return (
<div className={clsx("grid gap-2", TONE[state], className)}>
<div className="relative h-[26px] overflow-hidden rounded-sm border border-rule bg-panel-2">
<span className="absolute inset-y-0 left-0 bg-current opacity-[0.16]" style={{ width: `${pct}%` }} />
{/* The span still to come, drawn as absence rather than as a
second colour: it is the thing being bought. */}
<span
className="absolute inset-y-0 right-0 bg-[repeating-linear-gradient(45deg,transparent_0_5px,var(--rule-soft)_5px_6px)]"
style={{ width: `${100 - pct}%` }}
/>
<span className="absolute -inset-y-px w-0.5 bg-current" style={{ left: `${pct}%` }} />
</div>
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
<span className="font-mono text-[0.64rem] uppercase tracking-[0.12em] text-ink-3">Issued {formatDate(issuedAt)}</span>
<span className="font-mono text-[0.74rem] font-bold tabular-nums">{remaining}</span>
<span className="font-mono text-[0.64rem] uppercase tracking-[0.12em] text-ink-3">Expires {formatDate(expiresAt)}</span>
</div>
</div>
);
}
/*
* The same measurement at 56px, for a row in a ledger. Licences, Billing and
* the staff expiry queue are all lists of terms, and a list of dates cannot be
* scanned for "which of these is nearly out" — a list of bars can.
*
* It carries a text alternative rather than a title: the row it sits in is
* being read, not hovered.
*/
export function TermSpark({ issuedAt, expiresAt, state }: { issuedAt: string; expiresAt: string; state: LicenceState }) {
const pct = span(issuedAt, expiresAt);
const days = daysRemaining(expiresAt);
return (
<span className={clsx("inline-flex items-center gap-2", TONE[state])}>
<span aria-hidden className="relative inline-block h-[9px] w-14 overflow-hidden rounded-sm border border-rule bg-panel-2 align-middle">
<span className="absolute inset-y-0 left-0 bg-current opacity-[0.45]" style={{ width: `${pct}%` }} />
<span className="absolute inset-y-0 w-px bg-current" style={{ left: `${pct}%` }} />
</span>
<span className="font-mono text-[0.72rem] tabular-nums">{days <= 0 ? `${Math.abs(days)}d` : `${days}d`}</span>
</span>
);
}