feat(adminsite): self-hosted purchase, checkout overlay, billing portal, client line-item builder

Purchase flow: name -> placeholder -> configure via the shipped PlanConfigurator
-> Paddle overlay with custom_data -> paste install UUID to link and issue.
lineItemsFor mirrors the Go catalogue.LineItems/billable exactly (base included
in exactly one place). ManageBillingButton opens the hosted portal. Paddle token
and env are baked into the build, never fetched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 10:53:21 +01:00
co-authored by Claude Opus 5
parent c10f093cad
commit 8bbecd2035
10 changed files with 321 additions and 3 deletions
@@ -0,0 +1,131 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useMutation, useQuery } from "@tanstack/react-query";
import { ApiError, api, lineItemsFor } from "@/lib/api";
import { Button } from "@/components/Button";
import { Field } from "@/components/Field";
import { CheckoutButton } from "@/components/CheckoutButton";
import PlanConfigurator, { type PlanChoice } from "@/components/PlanConfigurator";
/*
* Three visible steps: name (creates a placeholder), configure + pay, then link
* the real install UUID. The placeholder exists before payment so the webhook
* has something to attach custom_data to; the licence is only issued once the
* install's real UUID is known.
*/
export function PurchaseForm() {
const router = useRouter();
const account = useQuery({ queryKey: ["account"], queryFn: api.account });
const options = useQuery({ queryKey: ["checkout-options"], queryFn: api.checkoutOptions });
const [name, setName] = useState("");
const [placeholderId, setPlaceholderId] = useState<string | null>(null);
const [uuid, setUuid] = useState("");
const [error, setError] = useState<string | null>(null);
const [choice, setChoice] = useState<PlanChoice>({
tier: "professional",
term: "annual", // self-hosted is annual only
servers: 3,
features: [],
});
const create = useMutation({
mutationFn: () => api.createSelfHosted(name.trim()),
onSuccess: (r) => setPlaceholderId(r.instance_id),
onError: (e) => setError(e instanceof ApiError ? e.message : "Could not start. Try again."),
});
const claim = useMutation({
mutationFn: () => api.claimLink(placeholderId!, uuid.trim()),
onSuccess: () => router.push("/"),
onError: (e) => setError(e instanceof ApiError ? e.message : "Could not link. Try again."),
});
const accountId = account.data?.account.account_id ?? "";
const items =
options.data && placeholderId
? lineItemsFor(options.data, choice, "self_hosted")
: [];
// Step 1 — name.
if (!placeholderId) {
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}
/>
<Button type="submit" disabled={create.isPending || !name.trim()}>
{create.isPending ? "Starting…" : "Continue"}
</Button>
</form>
);
}
// Step 2 + 3 — configure & pay, then link.
return (
<div className="grid max-w-2xl gap-6">
<section className="rounded-lg border border-rule bg-panel p-4">
<h2 className="mb-3 text-[0.95rem] font-medium text-ink">Configure</h2>
{options.data ? (
<PlanConfigurator
deployment="self_hosted"
value={choice}
plans={options.data.plans}
catalogue={options.data.catalogue}
onChange={setChoice}
/>
) : (
<p className="text-[0.85rem] text-ink-3">Loading plans</p>
)}
<div className="mt-4">
<CheckoutButton
items={items}
customData={{ account_id: accountId, instance_id: placeholderId }}
disabled={!accountId || items.length === 0}
/>
</div>
</section>
<section className="rounded-lg border border-rule bg-panel p-4">
<h2 className="mb-1 text-[0.95rem] font-medium text-ink">Link your install</h2>
<p className="mb-3 text-[0.82rem] text-ink-3">
After payment, paste the instance ID your Vantage install reports (Settings
Licence). Your licence is issued the moment it is linked.
</p>
<div className="grid gap-3">
<Field
label="Instance ID"
value={uuid}
onChange={(e) => setUuid(e.target.value)}
placeholder="00000000-0000-0000-0000-000000000000"
error={error ?? undefined}
/>
<Button
type="button"
disabled={claim.isPending || !uuid.trim()}
onClick={() => {
setError(null);
claim.mutate();
}}
>
{claim.isPending ? "Linking…" : "Link and issue licence"}
</Button>
</div>
</section>
</div>
);
}
@@ -0,0 +1,18 @@
import type { Metadata } from "next";
import { PurchaseForm } from "./PurchaseForm";
import { PageHeader } from "@/components/PageHeader";
export const metadata: Metadata = { title: "Buy a self-hosted plan" };
export default function PurchasePage() {
return (
<div className="grid gap-6">
<PageHeader
back={{ href: "/", label: "Overview" }}
title="Self-hosted plan"
subtitle="Name your instance, choose a plan, and pay. After payment, paste the ID your install reports to receive its licence. Self-hosted is billed annually."
/>
<PurchaseForm />
</div>
);
}