This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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’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"}
|
||||
|
||||
Reference in New Issue
Block a user