feat(adminsite): instance cards and the customer overview

State reads three ways on every card -- a stripe, a shaped-and-labelled
pill, and the copy -- so it survives a colourblind reader and a glance at
arm's length. Colour alone would fail on the one screen where getting it
wrong costs money.

The expired card leads with what still works, because that is the first
thing a worried customer wants to know and the backend really does keep
servers, monitors and agent keys running. The awaiting-link card is
deliberately loud: a customer who has paid and not linked has paid for
nothing yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrhid6
2026-07-25 21:03:37 +01:00
co-authored by Claude Opus 5
parent 7a8e683d99
commit 242a587340
4 changed files with 287 additions and 0 deletions
+79
View File
@@ -0,0 +1,79 @@
"use client";
import { useQueries, useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { API_BASE, NotConnected, api, type License } from "@/lib/api";
import { NotConnectedPanel } from "@/components/NotConnected";
import { InstanceCard } from "@/components/InstanceCard";
export default function OverviewPage() {
const { data, error, isLoading } = useQuery({ queryKey: ["account"], queryFn: api.account });
const licences = useQueries({
queries: (data?.instances ?? [])
.filter((i) => i.current_license)
.map((i) => ({
queryKey: ["license", i.instance_id],
queryFn: () => api.license(i.instance_id),
})),
});
if (error instanceof NotConnected) return <NotConnectedPanel url={API_BASE} />;
if (isLoading || !data) return <p className="text-ink-3">Loading your account</p>;
const byInstance = new Map<string, License>();
licences.forEach((q) => {
if (q.data) byInstance.set(q.data.instance_id, q.data);
});
const unlinked = data.instances.filter((i) => i.status === "awaiting_link");
return (
<div className="grid gap-8">
<header className="grid gap-2">
<h1 className="text-3xl">{data.account.name}</h1>
<p className="text-ink-2">{data.account.billing_email}</p>
</header>
{unlinked.length > 0 && (
<div className="rounded border border-accent bg-accent-wash p-4">
<h2 className="text-xl">Finish setting up your licence</h2>
<p className="mt-1 text-[0.82rem] text-ink-2">
{unlinked.length === 1
? "One purchase is"
: `${unlinked.length} purchases are`}{" "}
not attached to an install yet, so no licence has been issued for{" "}
{unlinked.length === 1 ? "it" : "them"}.
</p>
<Link
href="/instances/link"
className="mt-2 inline-block text-[0.82rem] font-semibold text-accent underline"
>
Link an install
</Link>
</div>
)}
{data.instances.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">
There are two ways to run Vantage. Buy a cloud instance and we host it, and
your licence is applied automatically. Or buy a self-hosted licence, install
Vantage on your own server, and link it here to get your licence file.
</p>
</div>
) : (
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{data.instances.map((i) => (
<InstanceCard
key={i.instance_id}
instance={i}
license={byInstance.get(i.instance_id)}
/>
))}
</section>
)}
</div>
);
}
@@ -0,0 +1,77 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { InstanceCard } from "./InstanceCard";
import type { Instance, License } from "@/lib/api";
const base: Instance = {
instance_id: "6a0fe3f0-49d2-4aa1-967c-a3094b200b5d",
account_id: "a1",
name: "Acme Production",
slug: "acme",
deployment: "cloud",
tier: "professional",
status: "active",
current_license: "l1",
relink_count: 0,
created_at: "2026-01-01T00:00:00Z",
};
function licence(daysFromNow: number): License {
return {
license_id: "l1",
instance_id: base.instance_id,
account_id: "a1",
tier: "professional",
deployment: "cloud",
limits: { max_servers: -1, max_secret_groups: -1, max_channels: -1 },
features: ["console", "oidc"],
issued_at: "2026-01-01T00:00:00Z",
expires_at: new Date(Date.now() + daysFromNow * 86_400_000).toISOString(),
issued_by: "staff@example.com",
reason: "new",
};
}
describe("InstanceCard", () => {
it("shows a valid licence with days remaining", () => {
render(<InstanceCard instance={base} license={licence(367)} />);
expect(screen.getByText("Valid")).toBeInTheDocument();
expect(screen.getByText(/367 days remaining/)).toBeInTheDocument();
});
it("warns inside fourteen days", () => {
render(<InstanceCard instance={base} license={licence(9)} />);
expect(screen.getByText("Expiring")).toBeInTheDocument();
expect(screen.getByText(/9 days remaining/)).toBeInTheDocument();
});
it("names what still works when expired", () => {
render(<InstanceCard instance={base} license={licence(-3)} />);
expect(screen.getByText("Expired")).toBeInTheDocument();
// The reassurance is the point: this is the first thing a worried
// customer needs, and the backend really does keep these running.
expect(screen.getByText(/servers and monitors are still running/i)).toBeInTheDocument();
expect(screen.getByText(/changes are disabled/i)).toBeInTheDocument();
});
it("prompts to link when paid but never linked", () => {
render(
<InstanceCard
instance={{
...base,
status: "awaiting_link",
deployment: "self_hosted",
current_license: undefined,
}}
/>,
);
expect(screen.getByText("Awaiting link")).toBeInTheDocument();
expect(screen.getByText(/not attached to an install yet/i)).toBeInTheDocument();
expect(screen.getByRole("link", { name: /link an install/i })).toBeInTheDocument();
});
it("links to the instance's own subdomain for cloud", () => {
render(<InstanceCard instance={base} license={licence(30)} />);
expect(screen.getByRole("link", { name: /open/i })).toBeInTheDocument();
});
});
+89
View File
@@ -0,0 +1,89 @@
import Link from "next/link";
import clsx from "clsx";
import type { Instance, License } from "@/lib/api";
import { daysRemaining, formatDate, licenceState } from "@/lib/format";
import { StatePill } from "./StatePill";
const STRIPE = {
valid: "before:bg-valid",
warn: "before:bg-warn",
expired: "before:bg-expired",
none: "before:bg-accent",
} as const;
export function InstanceCard({ instance, license }: { instance: Instance; license?: License }) {
const state = licenceState(license?.expires_at, Boolean(license));
const days = license ? daysRemaining(license.expires_at) : 0;
const cloud = instance.deployment === "cloud";
return (
<article
className={clsx(
"relative grid gap-3 rounded border border-rule bg-panel p-4 pl-5",
"before:absolute before:inset-y-0 before:left-0 before:w-1 before:content-['']",
STRIPE[state],
)}
>
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-lg">{instance.name || "Unnamed instance"}</h3>
<p className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
{cloud ? "Cloud" : "Self-hosted"}
{instance.tier ? ` · ${instance.tier.replace("_", " ")}` : ""}
</p>
</div>
<StatePill state={state} />
</div>
{state === "expired" && (
<p className="text-[0.82rem] text-ink-2">
Servers and monitors are still running, and your agents keep their keys. Changes
are disabled until you renew.
</p>
)}
{state === "none" && (
<p className="text-[0.82rem] text-ink-2">
You have paid for this but it is not attached to an install yet, so no licence
has been issued. Linking takes a minute.
</p>
)}
{license && state !== "expired" && (
<div className="grid gap-1 font-mono text-[0.82rem] tabular-nums text-ink-2">
<span>{days} days remaining</span>
<div className="h-[3px] 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 / 365) * 100))}%` }}
/>
</div>
<span>Renews {formatDate(license.expires_at)}</span>
</div>
)}
{state === "none" ? (
<Link
href="/instances/link"
className="justify-self-start text-[0.82rem] font-semibold text-accent underline"
>
Link an install
</Link>
) : cloud && instance.slug ? (
<a
href={`https://${instance.slug}.vantage.hostxtra.co.uk`}
className="justify-self-start text-[0.82rem] font-semibold text-accent underline"
>
Open {instance.slug}.vantage.hostxtra.co.uk
</a>
) : (
<Link
href={`/instances/${instance.instance_id}`}
className="justify-self-start text-[0.82rem] font-semibold text-accent underline"
>
{state === "expired" ? "Renew and download" : "Licence and download"}
</Link>
)}
</article>
);
}
+42
View File
@@ -0,0 +1,42 @@
import clsx from "clsx";
import type { LicenceState } from "@/lib/format";
const LABEL: Record<LicenceState, string> = {
valid: "Valid",
warn: "Expiring",
expired: "Expired",
none: "Awaiting link",
};
/*
* State reads three ways: this pill's colour, the pill's SHAPE, and the label.
* Colour alone would fail a colourblind reader on the one screen where getting
* it wrong costs money.
*/
const SHAPE: Record<LicenceState, string> = {
valid: "rounded-full",
warn: "[clip-path:polygon(50%_0,100%_100%,0_100%)]",
expired: "[clip-path:polygon(20%_0,80%_0,100%_20%,100%_80%,80%_100%,20%_100%,0_80%,0_20%)]",
none: "rounded-none",
};
const TONE: Record<LicenceState, string> = {
valid: "border-valid text-valid",
warn: "border-warn text-warn",
expired: "border-expired text-expired",
none: "border-accent text-accent",
};
export function StatePill({ state }: { state: LicenceState }) {
return (
<span
className={clsx(
"inline-flex shrink-0 items-center gap-1.5 rounded-sm border bg-panel px-2 py-0.5 font-mono text-[0.72rem] uppercase tracking-[0.08em]",
TONE[state],
)}
>
<i className={clsx("h-1.5 w-1.5 shrink-0 bg-current", SHAPE[state])} />
{LABEL[state]}
</span>
);
}