feat: Changes to self hosted purchase
This commit is contained in:
@@ -1,74 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ApiError, NotConnected, api } from "@/lib/api";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Field } from "@/components/Field";
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export function LinkForm({
|
||||
onLinked,
|
||||
claimId,
|
||||
}: {
|
||||
onLinked: (instanceId: string) => void;
|
||||
// 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 [error, setError] = useState<string | undefined>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const value = id.trim();
|
||||
|
||||
// Checked here so a typo costs nothing and the message is instant.
|
||||
if (!UUID_RE.test(value)) {
|
||||
setError(
|
||||
"That does not look like an instance ID. It should look like the example below.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
const inst = await api.claimLink(claimId, value);
|
||||
onLinked(inst.instance_id);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof NotConnected
|
||||
? "The licensing service is not reachable from this page."
|
||||
: err instanceof ApiError
|
||||
? err.message
|
||||
: "Could not link that instance. Try again.",
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="grid gap-4" noValidate>
|
||||
<Field
|
||||
label="Instance ID"
|
||||
value={id}
|
||||
onChange={(e) => setId(e.target.value)}
|
||||
error={error}
|
||||
hint={
|
||||
<>
|
||||
Find this on your install’s <code>Settings → Licence</code> page, or on
|
||||
the setup screen just after you first sign in. It looks like{" "}
|
||||
<code>6a0fe3f0-49d2-4aa1-967c-a3094b200b5d</code>.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Button type="submit" disabled={busy} className="justify-self-start">
|
||||
{busy ? "Linking…" : "Link and issue licence"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { LinkForm } from "./LinkForm";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
|
||||
export default function LinkPage() {
|
||||
const router = useRouter();
|
||||
const qc = useQueryClient();
|
||||
// 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
|
||||
back={{ href: "/", label: "Overview" }}
|
||||
title="Link an install"
|
||||
subtitle="Every licence is tied to one install, so we need its ID before we can issue yours. Paste it below and your licence is ready on the next screen."
|
||||
/>
|
||||
<LinkForm
|
||||
claimId={claimId}
|
||||
onLinked={(instanceId) => {
|
||||
qc.invalidateQueries({ queryKey: ["account"] });
|
||||
// Straight to the download, not back to a list: the licence is
|
||||
// the thing they came for.
|
||||
router.push(`/instances/${instanceId}`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -53,10 +53,10 @@ export default function OverviewPage() {
|
||||
return [
|
||||
{
|
||||
id: i.instance_id,
|
||||
text: `${name} is waiting for an install ID`,
|
||||
note: "You have paid for this. Paste the UUID from the install to get your licence.",
|
||||
href: i.status === "awaiting_link" ? `/instances/link?claim=${i.instance_id}` : "/purchase",
|
||||
action: i.status === "awaiting_link" ? "Link install" : "Get a licence",
|
||||
text: `${name} has no licence yet`,
|
||||
note: "Pick a plan and we will issue a licence for this install.",
|
||||
href: "/purchase",
|
||||
action: "Get a licence",
|
||||
tag: "",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -142,10 +142,13 @@ export function PurchaseForm() {
|
||||
onError: (e) => setError(e instanceof ApiError ? e.message : "Could not create the licence."),
|
||||
});
|
||||
|
||||
// Self-hosted checkout names the install's REAL UUID, so the instance is
|
||||
// linked (or an already-owned one reused) before Paddle opens. The webhook
|
||||
// then issues straight onto it — there is no placeholder to claim afterwards.
|
||||
const startCheckout = useMutation({
|
||||
mutationFn: async () => {
|
||||
const trimmed = name.trim();
|
||||
const r = dep === "cloud" ? await api.createCloudCheckout(trimmed) : await api.createSelfHosted(trimmed);
|
||||
const r = dep === "cloud" ? await api.createCloudCheckout(trimmed) : await api.createSelfHostedCheckout(uuid.trim(), trimmed);
|
||||
return r.instance_id;
|
||||
},
|
||||
onSuccess: async (instanceId) => {
|
||||
@@ -159,12 +162,6 @@ export function PurchaseForm() {
|
||||
onError: (e) => setError(e instanceof ApiError ? e.message : "Could not start checkout."),
|
||||
});
|
||||
|
||||
const claim = useMutation({
|
||||
mutationFn: () => api.claimLink(pending!.instanceId, uuid.trim()),
|
||||
onSuccess: () => router.push("/"),
|
||||
onError: (e) => setError(e instanceof ApiError ? e.message : "Could not link the install."),
|
||||
});
|
||||
|
||||
if (optionsQ.isLoading || account.isLoading) {
|
||||
return <p className="text-ink-3">Loading plans…</p>;
|
||||
}
|
||||
@@ -308,11 +305,13 @@ export function PurchaseForm() {
|
||||
</Block>
|
||||
)}
|
||||
|
||||
{selfHostedFree && (
|
||||
<Block n={3} label="Your install">
|
||||
{dep === "self_hosted" && (
|
||||
<Block n={paid ? 4 : 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.
|
||||
{paid
|
||||
? "Every licence binds to one install, so stand your control plane up first and paste the instance ID it reports. We attach it to your account now and the licence lands the moment payment clears. Already have an instance here? Paste its ID to upgrade it."
|
||||
: "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>
|
||||
@@ -380,7 +379,7 @@ export function PurchaseForm() {
|
||||
) : (
|
||||
<Cta
|
||||
label={startCheckout.isPending ? "Starting…" : "Continue to payment"}
|
||||
disabled={!name.trim() || items.length === 0 || !accountId || startCheckout.isPending}
|
||||
disabled={!name.trim() || items.length === 0 || !accountId || startCheckout.isPending || (dep === "self_hosted" && !UUID_RE.test(uuid.trim()))}
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
startCheckout.mutate();
|
||||
@@ -389,28 +388,13 @@ export function PurchaseForm() {
|
||||
))}
|
||||
|
||||
{/* Phase B: after the checkout has been opened. */}
|
||||
{pending?.deployment === "self_hosted" && (
|
||||
{pending && (
|
||||
<div className="grid gap-2 border-t border-rule-soft pt-3">
|
||||
<p className="text-[0.8rem] text-ink-2">Once payment clears, paste the instance ID your install reports (Settings → Licence) to receive your licence.</p>
|
||||
<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"
|
||||
/>
|
||||
<Cta
|
||||
label={claim.isPending ? "Linking…" : "Link and issue licence"}
|
||||
disabled={!uuid.trim() || claim.isPending}
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
claim.mutate();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{pending?.deployment === "cloud" && (
|
||||
<div className="grid gap-2 border-t border-rule-soft pt-3">
|
||||
<p className="text-[0.8rem] text-ink-2">Your instance is being set up. Its licence appears the moment payment clears no further steps.</p>
|
||||
<p className="text-[0.8rem] text-ink-2">
|
||||
{pending.deployment === "cloud"
|
||||
? "Your instance is being set up. Its licence appears the moment payment clears — no further steps."
|
||||
: "Your install is attached to this account. Its licence appears the moment payment clears — no further steps."}
|
||||
</p>
|
||||
<Link href={`/instances/${pending.instanceId}`} className="font-semibold text-accent underline">
|
||||
Go to your instance
|
||||
</Link>
|
||||
|
||||
Reference in New Issue
Block a user