feat: Updated self hosted free plans
Server Deploy / deploy (push) Successful in 40s

This commit is contained in:
2026-07-27 16:26:20 +01:00
parent 66140aaf58
commit 8fd5eacec6
8 changed files with 77 additions and 144 deletions
@@ -12,13 +12,12 @@ export function LinkForm({
claimId,
}: {
onLinked: (instanceId: string) => void;
// When set, this is a PAID placeholder awaiting its real install UUID: claim
// it in place rather than creating a fresh (Free) instance. The name was
// chosen at checkout, so it is not asked for again.
claimId?: string;
// The PAID placeholder awaiting its real install UUID: claim it in place. The
// name was chosen at checkout, so it is not asked for again. Self-hosted Free
// is created on the purchase page instead, not here.
claimId: string;
}) {
const [id, setId] = useState("");
const [name, setName] = useState("");
const [error, setError] = useState<string | undefined>();
const [busy, setBusy] = useState(false);
@@ -37,9 +36,7 @@ export function LinkForm({
setBusy(true);
setError(undefined);
try {
const inst = claimId
? await api.claimLink(claimId, value)
: await api.link(value, name.trim());
const inst = await api.claimLink(claimId, value);
onLinked(inst.instance_id);
} catch (err) {
setError(
@@ -69,14 +66,6 @@ export function LinkForm({
</>
}
/>
{!claimId && (
<Field
label="Name it (optional)"
value={name}
onChange={(e) => setName(e.target.value)}
hint="So you can tell it apart from your other installs."
/>
)}
<Button type="submit" disabled={busy} className="justify-self-start">
{busy ? "Linking…" : "Link and issue licence"}
</Button>
@@ -1,5 +1,6 @@
"use client";
import { useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { LinkForm } from "./LinkForm";
@@ -8,10 +9,17 @@ import { PageHeader } from "@/components/PageHeader";
export default function LinkPage() {
const router = useRouter();
const qc = useQueryClient();
// A paid placeholder passes its id here so its install is claimed in place
// rather than a second, Free instance being created alongside it.
// This page only claims a PAID placeholder's real install UUID. Self-hosted
// Free is created on the purchase page, so with no placeholder to claim there
// is nothing to do here — send them there.
const claimId = useSearchParams().get("claim") ?? undefined;
useEffect(() => {
if (!claimId) router.replace("/purchase");
}, [claimId, router]);
if (!claimId) return null;
return (
<div className="grid max-w-2xl gap-6">
<PageHeader
@@ -1,65 +0,0 @@
"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 your Vantage
HQ password changes it here too.
</p>
<Button type="submit" disabled={create.isPending || !name.trim()}>
{create.isPending ? "Creating…" : "Create instance"}
</Button>
</form>
);
}
@@ -1,18 +0,0 @@
import type { Metadata } from "next";
import { CreateForm } from "./CreateForm";
import { PageHeader } from "@/components/PageHeader";
export const metadata: Metadata = { title: "New instance" };
export default function NewInstancePage() {
return (
<div className="grid gap-6">
<PageHeader
back={{ href: "/", label: "Overview" }}
title="Create a free instance"
subtitle="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."
/>
<CreateForm />
</div>
);
}
+7 -21
View File
@@ -55,7 +55,6 @@ export default function OverviewPage() {
});
const pending = (people.data ?? []).filter((p) => !p.verified_at).length;
const hasFree = live.some((i) => i.tier === "free" && i.status !== "cancelled");
const subtitle =
live.length === 0
@@ -71,14 +70,7 @@ export default function OverviewPage() {
subtitle={subtitle}
actions={
live.length > 0 ? (
<>
{!hasFree && (
<LinkButton variant="line" href="/instances/new">
Create a free instance
</LinkButton>
)}
<LinkButton href="/purchase">Buy a plan</LinkButton>
</>
<LinkButton href="/purchase">Buy a plan</LinkButton>
) : undefined
}
record={[
@@ -97,17 +89,11 @@ export default function OverviewPage() {
<h2 className="text-xl">No instances yet</h2>
<p className="text-ink-2">
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.
automatically. Or run Vantage on your own server and get its licence free
or paid from the purchase page.
</p>
<div className="flex flex-wrap gap-2.5">
<LinkButton href="/purchase">Buy a plan</LinkButton>
<LinkButton variant="line" href="/instances/new">
Create a free instance
</LinkButton>
<LinkButton variant="line" href="/instances/link">
Link an install
</LinkButton>
</div>
</div>
) : (
@@ -189,14 +175,14 @@ export default function OverviewPage() {
<RailCard title="Running Vantage yourself?">
<p className="text-[0.82rem] text-ink-2">
Link your own install to get its licence file. It keeps its own
users.
Get a licence for your own install free or paid from the
purchase page. It keeps its own users.
</p>
<Link
href="/instances/link"
href="/purchase"
className="text-[0.82rem] font-semibold text-accent underline"
>
Link an install
Get a licence
</Link>
</RailCard>
</>
@@ -10,6 +10,8 @@ import { initPaddle, previewPrices, type PricePreview } from "@/lib/paddle";
/* Tiers in the order a customer reads them, cheapest first. */
const TIER_ORDER: Tier[] = ["free", "professional", "enterprise"];
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/* Human labels for feature keys. The catalogue names them by key; this is the
* one place the customer-facing wording lives. */
const FEATURE_LABEL: Record<string, string> = {
@@ -137,6 +139,18 @@ export function PurchaseForm() {
onError: (e) => setError(e instanceof ApiError ? e.message : "Could not create the instance."),
});
// Self-hosted Free binds to the install's own UUID: register the instance,
// then issue its Free licence in one action.
const createSelfHostedFree = useMutation({
mutationFn: async () => {
const inst = await api.link(uuid.trim(), name.trim());
await api.claimFree(inst.instance_id);
return inst.instance_id;
},
onSuccess: (id) => router.push(`/instances/${id}`),
onError: (e) => setError(e instanceof ApiError ? e.message : "Could not create the licence."),
});
const startCheckout = useMutation({
mutationFn: async () => {
const trimmed = name.trim();
@@ -304,15 +318,25 @@ export function PurchaseForm() {
)}
{selfHostedFree && (
<Block n={3} label="Free self-hosted">
<div className="rounded border border-accent/25 bg-accent-wash p-4 text-[0.86rem] text-ink-2">
<strong className="text-ink">Free self-hosted starts with your install.</strong> Install Vantage on your own server, link the instance ID it reports, then claim your free
licence there is nothing to pay for here.
<div className="mt-3">
<Link href="/instances/link" className="font-semibold text-accent underline">
Link an install
</Link>
</div>
<Block n={3} label="Your install">
<div className="grid gap-3 rounded border border-rule bg-panel p-4">
<p className="text-[0.86rem] text-ink-2">
Install Vantage on your own server first, then paste the instance ID it
reports. We register it and issue your Free licence nothing to pay.
</p>
<label className="grid gap-1">
<span className="text-[0.72rem] font-semibold uppercase tracking-[0.08em] text-ink-3">Instance ID</span>
<input
value={uuid}
onChange={(e) => setUuid(e.target.value)}
placeholder="00000000-0000-0000-0000-000000000000"
className="rounded border border-rule bg-panel px-2.5 py-2 font-mono text-[0.82rem] text-ink placeholder:text-ink-3"
/>
<span className="text-[0.72rem] text-ink-3">
Find this on your install&rsquo;s Settings Licence page, or the setup
screen just after first sign-in.
</span>
</label>
</div>
</Block>
)}
@@ -330,7 +354,7 @@ export function PurchaseForm() {
{/* name + CTA */}
<div className="grid gap-3 border-t border-rule px-4 py-4">
{!pending && !selfHostedFree && (
{!pending && (
<label className="grid gap-1">
<span className="text-[0.72rem] font-semibold uppercase tracking-[0.08em] text-ink-3">Instance name</span>
<input
@@ -347,7 +371,15 @@ export function PurchaseForm() {
{/* Phase A: choose an action for the configuration. */}
{!pending &&
(selfHostedFree ? (
<p className="text-[0.8rem] text-ink-3">Free self-hosted is claimed after linking your install.</p>
<Cta
label={createSelfHostedFree.isPending ? "Creating…" : "Create licence"}
variant="line"
disabled={!UUID_RE.test(uuid.trim()) || createSelfHostedFree.isPending}
onClick={() => {
setError(null);
createSelfHostedFree.mutate();
}}
/>
) : cloudFree ? (
<Cta
label={createFree.isPending ? "Creating…" : "Create free instance"}