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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user