diff --git a/adminsite/app/(customer)/billing/page.tsx b/adminsite/app/(customer)/billing/page.tsx
index b8c0194..d482620 100644
--- a/adminsite/app/(customer)/billing/page.tsx
+++ b/adminsite/app/(customer)/billing/page.tsx
@@ -3,60 +3,109 @@
import { useQuery } from "@tanstack/react-query";
import { API_BASE, NotConnected, api } from "@/lib/api";
import { NotConnectedPanel } from "@/components/NotConnected";
+import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame";
+import { PageHeader } from "@/components/PageHeader";
import { formatDate } from "@/lib/format";
export default function BillingPage() {
- const { data, error, isLoading } = useQuery({
- queryKey: ["subscriptions"],
- queryFn: api.subscriptions,
- });
+ const subs = useQuery({ queryKey: ["subscriptions"], queryFn: api.subscriptions });
+ const account = useQuery({ queryKey: ["account"], queryFn: api.account });
- if (error instanceof NotConnected) return ;
- if (isLoading) return
Loading…
;
+ if (subs.error instanceof NotConnected) return ;
+ if (subs.isLoading) return
Loading…
;
+
+ const rows = subs.data ?? [];
+
+ // Each subscription names the instance it pays for, because tier and term
+ // are per-licence rather than per-account. Resolving the name here is the
+ // difference between "professional · annual" and knowing which install that is.
+ const nameFor = (instanceId?: string) =>
+ account.data?.instances.find((i) => i.instance_id === instanceId)?.name;
return (
-
Billing
+
- {!data || data.length === 0 ? (
-
- You have no subscriptions. Cloud instances and self-hosted licences are both
- bought from the pricing page.
-
- ) : (
-
-
-
-
-
Plan
-
Term
-
Status
-
Renews
-
-
-
- {data.map((s) => (
-
-
{s.tier.replace("_", " ")}
-
{s.term}
-
{s.status}
-
- {formatDate(s.current_period_end)}
-
+
+
+
+
+
+
+ To change a card, download an invoice or cancel, email support and
+ we will send you a billing link. Self-service billing arrives with
+ card payments.
+
+ You have no subscriptions. Cloud instances and self-hosted licences are
+ both bought from the pricing page.
+
+ ) : (
+
+
+
+
+
Instance
+
Plan
+
Term
+
Status
+
Renews
- ))}
-
-
-
- )}
-
-
- To change a card, download an invoice or cancel, email support and we will send you
- a billing link. Self-service billing arrives with card payments.
-
+ No licence has been issued for this instance yet.
+
+ )}
+
);
}
diff --git a/adminsite/app/(customer)/instances/link/page.tsx b/adminsite/app/(customer)/instances/link/page.tsx
index e213a86..561fed3 100644
--- a/adminsite/app/(customer)/instances/link/page.tsx
+++ b/adminsite/app/(customer)/instances/link/page.tsx
@@ -3,6 +3,7 @@
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { LinkForm } from "./LinkForm";
+import { PageHeader } from "@/components/PageHeader";
export default function LinkPage() {
const router = useRouter();
@@ -10,13 +11,11 @@ export default function LinkPage() {
return (
-
-
Link an install
-
- Every licence is tied to one install, so we need its ID before we can issue
- yours. Paste it below and your licence is ready on the next screen.
-
-
+ {
qc.invalidateQueries({ queryKey: ["account"] });
diff --git a/adminsite/app/(customer)/instances/new/page.tsx b/adminsite/app/(customer)/instances/new/page.tsx
index 32da202..f1e05d2 100644
--- a/adminsite/app/(customer)/instances/new/page.tsx
+++ b/adminsite/app/(customer)/instances/new/page.tsx
@@ -1,19 +1,17 @@
import type { Metadata } from "next";
import { CreateForm } from "./CreateForm";
+import { PageHeader } from "@/components/PageHeader";
export const metadata: Metadata = { title: "New instance" };
export default function NewInstancePage() {
return (
-
-
Create a free instance
-
- An instance owns its servers, keys, workflows, monitors and secrets. Nothing
- inside it is visible to any other instance. Free covers three servers, and the
- licence runs for a month at a time — we email you before it needs renewing.
-
-
+
);
diff --git a/adminsite/app/(customer)/layout.tsx b/adminsite/app/(customer)/layout.tsx
index e7391b4..2bed622 100644
--- a/adminsite/app/(customer)/layout.tsx
+++ b/adminsite/app/(customer)/layout.tsx
@@ -1,31 +1,39 @@
"use client";
-import Link from "next/link";
+import { useQuery } from "@tanstack/react-query";
+import { api } from "@/lib/api";
import { RequireKind } from "@/lib/session";
+import { AppBar, type NavLink } from "@/components/AppBar";
+
+/*
+ * Three destinations, not five. Settings moved into the account menu — it is
+ * your password, not a place — and Instances went with it, because Overview
+ * already lists them and a second door to the same room is just a second thing
+ * to keep in sync. Linking an install is an action, so it is a button on
+ * Overview rather than a permanent nav entry.
+ */
+const LINKS: NavLink[] = [
+ { href: "/", label: "Overview" },
+ { href: "/users", label: "People" },
+ { href: "/billing", label: "Billing" },
+];
+
+function AccountName() {
+ // Shares the ["account"] key with Overview, so this costs no extra request.
+ const { data } = useQuery({ queryKey: ["account"], queryFn: api.account });
+ if (!data) return null;
+ return (
+
+ {data.account.name}
+
+ );
+}
export default function CustomerLayout({ children }: { children: React.ReactNode }) {
return (
-
- {children}
+ } />
+ {children}
);
}
diff --git a/adminsite/app/(customer)/page.tsx b/adminsite/app/(customer)/page.tsx
index 6fcc4b5..4f3e2dd 100644
--- a/adminsite/app/(customer)/page.tsx
+++ b/adminsite/app/(customer)/page.tsx
@@ -4,10 +4,16 @@ 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";
+import { InstanceRecord } from "@/components/InstanceRecord";
+import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame";
+import { PageHeader } from "@/components/PageHeader";
+import { LinkButton } from "@/components/Button";
+import { StatePill } from "@/components/StatePill";
+import { daysRemaining, formatDate, licenceState } from "@/lib/format";
export default function OverviewPage() {
const { data, error, isLoading } = useQuery({ queryKey: ["account"], queryFn: api.account });
+ const people = useQuery({ queryKey: ["account-users"], queryFn: api.accountUsers });
const licences = useQueries({
queries: (data?.instances ?? [])
@@ -26,35 +32,62 @@ export default function OverviewPage() {
if (q.data) byInstance.set(q.data.instance_id, q.data);
});
- const unlinked = data.instances.filter((i) => i.status === "awaiting_link");
+ 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.
+ 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")
+ return [
+ {
+ id: i.instance_id,
+ text: `${i.name} expires`,
+ note: `${daysRemaining(lic!.expires_at)}d`,
+ },
+ ];
+ return [];
+ });
+
+ const pending = (people.data ?? []).filter((p) => !p.verified_at).length;
+ const hasFree = live.some((i) => i.tier === "free" && i.status !== "cancelled");
+
+ const subtitle =
+ live.length === 0
+ ? "Nothing here yet."
+ : `${live.length} ${live.length === 1 ? "instance" : "instances"}${
+ attention.length ? ` · ${attention.length} needing attention` : " · all licensed"
+ }`;
return (
-
@@ -62,36 +95,127 @@ export default function OverviewPage() {
automatically. Or buy a self-hosted licence, install Vantage on your own
server, and link it here to get your licence file.
+ )}
+
+ Manage people
+
+
+
+ {/*
+ * Account-level facts only. Tier, limits and renewal date
+ * belong to a licence, and a licence belongs to one
+ * instance — an account holding a Free cloud instance and
+ * a Professional self-hosted one has no single plan.
+ */}
+
+
+
+ Billing history
+
+
+
+
+
+ Link your own install to get its licence file. It keeps its own
+ users.
+