feat: Updated self hosted free plans

This commit is contained in:
mrhid6 committed 2026-07-27 16:26:20 +01:00
1 parent 66140aaf58
commit 8fd5eacec6
8 files changed
+77 -144

No files matched your search

@@ -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"}
+10 -11
View File
@@ -221,17 +221,16 @@ export function InstanceRecord({
<div className="flex flex-wrap items-center gap-2.5">
{state === "none" ? (
// A paid placeholder (awaiting_link) must CLAIM its install, not
// create a second Free instance beside it — so pass its id.
<LinkButton
href={
instance.status === "awaiting_link"
? `/instances/link?claim=${instance.instance_id}`
: "/instances/link"
}
>
Link an install
</LinkButton>
// A paid placeholder (awaiting_link) claims its real install
// UUID in place. Anything else without a licence gets one from
// the purchase page (self-hosted Free is created there).
instance.status === "awaiting_link" ? (
<LinkButton href={`/instances/link?claim=${instance.instance_id}`}>
Link an install
</LinkButton>
) : (
<LinkButton href="/purchase">Get a licence</LinkButton>
)
) : cloud && instance.slug ? (
<>
<LinkButton
+2
View File
@@ -308,6 +308,8 @@ export const api = {
post<Instance>("/api/instances/link", { instance_id, name }),
createInstance: (name: string) => post<Instance>("/api/instances", { name }),
renewInstance: (id: string) => post<License>(`/api/instances/${id}/renew`, {}),
// Self-hosted Free: issue the licence on an already-linked instance.
claimFree: (id: string) => post<License>(`/api/instances/${id}/claim-free`, {}),
relink: (id: string, instance_id: string) =>
post<License>(`/api/instances/${id}/relink`, { instance_id }),
license: (id: string) => req<License & { blob?: string }>(`/api/instances/${id}/license`),