diff --git a/adminsite/app/(customer)/page.tsx b/adminsite/app/(customer)/page.tsx
new file mode 100644
index 0000000..aa7eac9
--- /dev/null
+++ b/adminsite/app/(customer)/page.tsx
@@ -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 ;
+ if (isLoading || !data) return
Loading your account…
;
+
+ const byInstance = new Map();
+ 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 (
+
+
+
+ {unlinked.length > 0 && (
+
+
Finish setting up your licence
+
+ {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"}.
+
+
+ Link an install
+
+
+ )}
+
+ {data.instances.length === 0 ? (
+
+
No instances yet
+
+ 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.
+
+
+ ) : (
+
+ {data.instances.map((i) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/adminsite/components/InstanceCard.test.tsx b/adminsite/components/InstanceCard.test.tsx
new file mode 100644
index 0000000..e4219de
--- /dev/null
+++ b/adminsite/components/InstanceCard.test.tsx
@@ -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( );
+ expect(screen.getByText("Valid")).toBeInTheDocument();
+ expect(screen.getByText(/367 days remaining/)).toBeInTheDocument();
+ });
+
+ it("warns inside fourteen days", () => {
+ render( );
+ expect(screen.getByText("Expiring")).toBeInTheDocument();
+ expect(screen.getByText(/9 days remaining/)).toBeInTheDocument();
+ });
+
+ it("names what still works when expired", () => {
+ render( );
+ 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(
+ ,
+ );
+ 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( );
+ expect(screen.getByRole("link", { name: /open/i })).toBeInTheDocument();
+ });
+});
diff --git a/adminsite/components/InstanceCard.tsx b/adminsite/components/InstanceCard.tsx
new file mode 100644
index 0000000..44221b4
--- /dev/null
+++ b/adminsite/components/InstanceCard.tsx
@@ -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 (
+
+
+
+
{instance.name || "Unnamed instance"}
+
+ {cloud ? "Cloud" : "Self-hosted"}
+ {instance.tier ? ` · ${instance.tier.replace("_", " ")}` : ""}
+
+
+
+
+
+ {state === "expired" && (
+
+ Servers and monitors are still running, and your agents keep their keys. Changes
+ are disabled until you renew.
+
+ )}
+
+ {state === "none" && (
+
+ You have paid for this but it is not attached to an install yet, so no licence
+ has been issued. Linking takes a minute.
+
+ )}
+
+ {license && state !== "expired" && (
+
+
{days} days remaining
+
+
Renews {formatDate(license.expires_at)}
+
+ )}
+
+ {state === "none" ? (
+
+ Link an install
+
+ ) : cloud && instance.slug ? (
+
+ Open {instance.slug}.vantage.hostxtra.co.uk
+
+ ) : (
+
+ {state === "expired" ? "Renew and download" : "Licence and download"}
+
+ )}
+
+ );
+}
diff --git a/adminsite/components/StatePill.tsx b/adminsite/components/StatePill.tsx
new file mode 100644
index 0000000..7448636
--- /dev/null
+++ b/adminsite/components/StatePill.tsx
@@ -0,0 +1,42 @@
+import clsx from "clsx";
+import type { LicenceState } from "@/lib/format";
+
+const LABEL: Record = {
+ 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 = {
+ 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 = {
+ 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 (
+
+
+ {LABEL[state]}
+
+ );
+}