feat(adminsite): create and renew a free instance

Adds the create form with a live slug preview, a renew action inside the
seven-day window, and a deletion countdown that renders only when the
backend has actually promised a date.

The progress bar denominator now follows the tier; a 30-day Free licence
was rendering as an 8% sliver against the hardcoded 365.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrhid6
2026-07-26 13:30:42 +01:00
co-authored by Claude Opus 5
parent a940390791
commit cb90ed12fb
5 changed files with 177 additions and 29 deletions
@@ -0,0 +1,65 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { ApiError, api } from "@/lib/api";
import { Button } from "@/components/Button";
import { Field } from "@/components/Field";
function slugify(value: string) {
return value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
export function CreateForm() {
const [name, setName] = useState("");
const [error, setError] = useState<string | null>(null);
const router = useRouter();
const qc = useQueryClient();
const create = useMutation({
mutationFn: () => api.createInstance(name.trim()),
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ["account"] });
router.push("/");
},
onError: (e) =>
setError(e instanceof ApiError ? e.message : "Something went wrong. Try again."),
});
const slug = slugify(name);
return (
<form
className="grid max-w-md gap-4"
onSubmit={(e) => {
e.preventDefault();
setError(null);
if (name.trim()) create.mutate();
}}
>
<Field
label="Instance name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Northgate Systems"
required
error={error ?? undefined}
hint={`${slug || "your-instance"}.vantage.hostxtra.co.uk`}
/>
<p className="text-[0.82rem] text-ink-2">
You sign in to it with this same email address and password. Changing one does not
change the other afterwards.
</p>
<Button type="submit" disabled={create.isPending || !name.trim()}>
{create.isPending ? "Creating…" : "Create instance"}
</Button>
</form>
);
}
@@ -0,0 +1,20 @@
import type { Metadata } from "next";
import { CreateForm } from "./CreateForm";
export const metadata: Metadata = { title: "New instance" };
export default function NewInstancePage() {
return (
<div className="grid gap-6">
<header className="grid gap-2">
<h1 className="text-3xl">Create a free instance</h1>
<p className="max-w-prose text-ink-2">
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.
</p>
</header>
<CreateForm />
</div>
);
}
+21 -3
View File
@@ -58,10 +58,16 @@ export default function OverviewPage() {
<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.
Create a free cloud instance and we host it, with your licence applied
automatically. Or buy a self-hosted licence, install Vantage on your own
server, and link it here to get your licence file.
</p>
<Link
href="/instances/new"
className="justify-self-start rounded bg-accent px-4 py-2 text-[0.9rem] font-semibold text-accent-ink"
>
Create a free instance
</Link>
</div>
) : (
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
@@ -74,6 +80,18 @@ export default function OverviewPage() {
))}
</section>
)}
{data.instances.length > 0 &&
!data.instances.some(
(i) => i.tier === "free" && i.status !== "cancelled" && i.status !== "deleted",
) && (
<Link
href="/instances/new"
className="justify-self-start text-[0.82rem] font-semibold text-accent underline"
>
Create a free instance
</Link>
)}
</div>
);
}
+67 -25
View File
@@ -1,6 +1,9 @@
"use client";
import Link from "next/link";
import clsx from "clsx";
import type { Instance, License } from "@/lib/api";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api, type Instance, type License } from "@/lib/api";
import { daysRemaining, formatDate, licenceState } from "@/lib/format";
import { StatePill } from "./StatePill";
@@ -11,10 +14,28 @@ const STRIPE = {
none: "before:bg-accent",
} as const;
export function InstanceCard({ instance, license }: { instance: Instance; license?: License }) {
export function InstanceCard({
instance,
license,
reapAfterDays,
}: {
instance: Instance;
license?: License;
reapAfterDays?: number;
}) {
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 qc = useQueryClient();
const renew = useMutation({
mutationFn: () => api.renewInstance(instance.instance_id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["account"] }),
});
const canRenew = instance.tier === "free" && license !== undefined && days <= 7;
return (
<article
@@ -42,6 +63,14 @@ export function InstanceCard({ instance, license }: { instance: Instance; licens
</p>
)}
{state === "expired" && deleteInDays !== null && (
<p className="text-[0.82rem] font-semibold text-expired">
{deleteInDays <= 0
? "Scheduled for deletion."
: `Deleted in ${deleteInDays} ${deleteInDays === 1 ? "day" : "days"} unless renewed.`}
</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
@@ -55,35 +84,48 @@ export function InstanceCard({ instance, license }: { instance: Instance; licens
<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))}%` }}
style={{ width: `${Math.max(2, Math.min(100, (days / termDays) * 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>
)}
<div className="flex flex-wrap items-center gap-3">
{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>
)}
{canRenew && (
<button
type="button"
onClick={() => renew.mutate()}
disabled={renew.isPending}
className="justify-self-start rounded bg-accent px-3 py-1.5 text-[0.82rem] font-semibold text-accent-ink"
>
{renew.isPending ? "Renewing…" : "Renew"}
</button>
)}
</div>
</article>
);
}
+4 -1
View File
@@ -57,7 +57,7 @@ const post = <T,>(path: string, payload?: unknown) =>
export type Deployment = "cloud" | "self_hosted";
export type Tier = "free" | "professional" | "self_hosted";
export type InstanceStatus = "awaiting_link" | "active" | "lapsed" | "cancelled";
export type InstanceStatus = "awaiting_link" | "active" | "lapsed" | "cancelled" | "deleted";
export interface Session {
kind: "staff" | "customer";
@@ -91,6 +91,7 @@ export interface Instance {
current_license?: string;
relink_count: number;
inject_failed_at?: string | null;
notices_sent?: string[];
created_at: string;
}
@@ -187,6 +188,8 @@ export const api = {
account: () => req<AccountResponse>("/api/account"),
link: (instance_id: string, name: string) =>
post<Instance>("/api/instances/link", { instance_id, name }),
createInstance: (name: string) => post<Instance>("/api/instances", { name }),
renewInstance: (id: string) => post<License>(`/api/instances/${id}/renew`, {}),
relink: (id: string, instance_id: string) =>
post<License>(`/api/instances/${id}/relink`, { instance_id }),
license: (id: string) => req<License & { blob?: string }>(`/api/instances/${id}/license`),