From 242a587340874b71f385b0538834f9b2bee11201 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Sat, 25 Jul 2026 21:03:37 +0100 Subject: [PATCH] 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 --- adminsite/app/(customer)/page.tsx | 79 +++++++++++++++++++ adminsite/components/InstanceCard.test.tsx | 77 +++++++++++++++++++ adminsite/components/InstanceCard.tsx | 89 ++++++++++++++++++++++ adminsite/components/StatePill.tsx | 42 ++++++++++ 4 files changed, 287 insertions(+) create mode 100644 adminsite/app/(customer)/page.tsx create mode 100644 adminsite/components/InstanceCard.test.tsx create mode 100644 adminsite/components/InstanceCard.tsx create mode 100644 adminsite/components/StatePill.tsx 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 ( +
+
+

{data.account.name}

+

{data.account.billing_email}

+
+ + {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]} + + ); +}