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>
132 lines
5.3 KiB
TypeScript
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>
|
|
);
|
|
}
|