Files
vantage/adminsite/components/InstanceCard.tsx
T
mrhid6andClaude Opus 5 cb90ed12fb 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>
2026-07-26 13:30:42 +01:00

132 lines
5.3 KiB
TypeScript

"use client";
import Link from "next/link";
import clsx from "clsx";
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";
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,
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
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 === "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
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 / termDays) * 100))}%` }}
/>
</div>
<span>Renews {formatDate(license.expires_at)}</span>
</div>
)}
<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>
);
}