411 lines
16 KiB
TypeScript
411 lines
16 KiB
TypeScript
"use client";
|
||
|
||
import { useRouter } from "next/navigation";
|
||
import { useState } from "react";
|
||
import { AnimatePresence, motion } from "framer-motion";
|
||
import { toast } from "sonner";
|
||
|
||
type PlanId = "solo" | "team" | "dedicated";
|
||
type Region = "eu-nl" | "eu-de" | "us-east" | "us-west";
|
||
type OSImage = "ubuntu-24-04" | "ubuntu-22-04" | "debian-12";
|
||
type OwnershipMode = "managed" | "owned";
|
||
|
||
type Plan = {
|
||
id: PlanId;
|
||
name: string;
|
||
price: string;
|
||
specs: string;
|
||
bestFor: string;
|
||
features: readonly string[];
|
||
};
|
||
|
||
type RegionOpt = { id: Region; label: string };
|
||
type OSOpt = { id: OSImage; label: string };
|
||
|
||
export default function VPSDeployWizard({
|
||
plans,
|
||
regions,
|
||
osImages,
|
||
siteHostname,
|
||
}: {
|
||
plans: readonly Plan[];
|
||
regions: readonly RegionOpt[];
|
||
osImages: readonly OSOpt[];
|
||
siteHostname: string;
|
||
}) {
|
||
const router = useRouter();
|
||
const [step, setStep] = useState<number>(1);
|
||
const [loading, setLoading] = useState<boolean>(false);
|
||
|
||
const [form, setForm] = useState<{
|
||
plan: PlanId | "";
|
||
region: Region | "";
|
||
osImage: OSImage | "";
|
||
hostname: string;
|
||
fqdn: string;
|
||
sshPublicKey: string;
|
||
ownership: OwnershipMode;
|
||
provider: "" | "hetzner" | "digitalocean" | "vultr";
|
||
email: string;
|
||
notes: string;
|
||
acceptTerms: boolean;
|
||
enableBackups: boolean;
|
||
}>({
|
||
plan: "",
|
||
region: "",
|
||
osImage: "ubuntu-24-04",
|
||
hostname: "",
|
||
fqdn: "",
|
||
sshPublicKey: "",
|
||
ownership: "managed",
|
||
provider: "",
|
||
email: "",
|
||
notes: "",
|
||
acceptTerms: false,
|
||
enableBackups: true,
|
||
});
|
||
|
||
const set = <K extends keyof typeof form>(key: K, value: (typeof form)[K]) =>
|
||
setForm((f) => ({ ...f, [key]: value }));
|
||
|
||
// Preselect plan from hash/query if present
|
||
if (typeof window !== "undefined" && !form.plan) {
|
||
const hash = window.location.hash;
|
||
const url = new URL(window.location.href);
|
||
const qp = url.searchParams.get("plan") as PlanId | null;
|
||
const fromHash = /plan=(solo|team|dedicated)/.exec(hash || "")?.[1] as PlanId | undefined;
|
||
const initial = qp || fromHash;
|
||
if (initial) set("plan", initial);
|
||
}
|
||
|
||
const canContinue1 = !!form.plan;
|
||
const canContinue2 = !!form.region && !!form.osImage;
|
||
const canSubmit =
|
||
canContinue2 &&
|
||
!!form.hostname &&
|
||
!!form.email &&
|
||
form.acceptTerms &&
|
||
(form.ownership === "managed" || (form.ownership === "owned" && !!form.provider));
|
||
|
||
const handleSubmit = async () => {
|
||
if (!canSubmit) return;
|
||
setLoading(true);
|
||
|
||
try {
|
||
const res = await fetch("/api/vps/create", {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
"x-secret-token": process.env.NEXT_PUBLIC_FORM_SECRET || "",
|
||
},
|
||
body: JSON.stringify(form),
|
||
});
|
||
|
||
if (!res.ok) throw new Error("Provisioning failed");
|
||
|
||
toast.success(
|
||
form.ownership === "owned"
|
||
? "Owned VPS request received! Check your email to securely connect your provider."
|
||
: "VPS provisioning started! Check your email for access details."
|
||
);
|
||
router.push("/vps/success");
|
||
} catch (err) {
|
||
console.error(err);
|
||
toast.error("Something went wrong. Please try again.");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="mt-8 rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 shadow-sm p-6">
|
||
<AnimatePresence mode="wait">
|
||
{step === 1 && (
|
||
<motion.div
|
||
key="vps-step1"
|
||
initial={{ opacity: 0, y: 20 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
exit={{ opacity: 0, y: -20 }}
|
||
transition={{ duration: 0.25 }}
|
||
>
|
||
<h3 className="text-xl font-semibold text-neutral-900 dark:text-white">1) Choose a Plan</h3>
|
||
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||
You can change plans later. Dedicated is custom—contact us for sizing.
|
||
</p>
|
||
|
||
<div className="mt-5 grid gap-4 sm:grid-cols-3">
|
||
{plans.map((p) => (
|
||
<button
|
||
key={p.id}
|
||
onClick={() => set("plan", p.id)}
|
||
className={`text-left rounded-xl border p-4 transition ${
|
||
form.plan === p.id
|
||
? "border-blue-600 bg-blue-50/50 dark:bg-blue-950/30"
|
||
: "border-neutral-200 dark:border-neutral-800 bg-white/60 dark:bg-neutral-900/60"
|
||
}`}
|
||
>
|
||
<div className="flex items-baseline justify-between">
|
||
<div className="font-semibold">{p.name}</div>
|
||
<div className="text-sm font-bold">{p.price}</div>
|
||
</div>
|
||
<div className="mt-1 text-xs text-neutral-600 dark:text-neutral-400">{p.specs}</div>
|
||
<div className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">{p.bestFor}</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="mt-6 flex justify-end">
|
||
<button
|
||
disabled={!canContinue1}
|
||
onClick={() => setStep(2)}
|
||
className="rounded-lg bg-gradient-to-r from-blue-600 via-purple-600 to-blue-600 text-white font-medium px-5 py-2 disabled:opacity-50"
|
||
>
|
||
Continue →
|
||
</button>
|
||
</div>
|
||
</motion.div>
|
||
)}
|
||
|
||
{step === 2 && (
|
||
<motion.div
|
||
key="vps-step2"
|
||
initial={{ opacity: 0, y: 20 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
exit={{ opacity: 0, y: -20 }}
|
||
transition={{ duration: 0.25 }}
|
||
>
|
||
<h3 className="text-xl font-semibold text-neutral-900 dark:text-white">2) Configure</h3>
|
||
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
||
{/* Region */}
|
||
<div>
|
||
<label className="block text-sm font-medium mb-1">Region</label>
|
||
<select
|
||
value={form.region}
|
||
onChange={(e) => set("region", e.target.value as Region)}
|
||
className="w-full rounded-lg border border-neutral-300 dark:border-neutral-700 bg-white/80 dark:bg-neutral-900/80 px-3 py-2"
|
||
>
|
||
<option value="">Select region</option>
|
||
{regions.map((r) => (
|
||
<option key={r.id} value={r.id}>
|
||
{r.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
{/* OS */}
|
||
<div>
|
||
<label className="block text-sm font-medium mb-1">Operating System</label>
|
||
<select
|
||
value={form.osImage}
|
||
onChange={(e) => set("osImage", e.target.value as OSImage)}
|
||
className="w-full rounded-lg border border-neutral-300 dark:border-neutral-700 bg-white/80 dark:bg-neutral-900/80 px-3 py-2"
|
||
>
|
||
{osImages.map((o) => (
|
||
<option key={o.id} value={o.id}>
|
||
{o.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
{/* Hostname */}
|
||
<div>
|
||
<label className="block text-sm font-medium mb-1">Hostname</label>
|
||
<input
|
||
type="text"
|
||
value={form.hostname}
|
||
onChange={(e) => set("hostname", e.target.value.replace(/[^a-z0-9-.]/gi, "").toLowerCase())}
|
||
placeholder="app-1"
|
||
className="w-full rounded-lg border border-neutral-300 dark:border-neutral-700 bg-white/80 dark:bg-neutral-900/80 px-3 py-2"
|
||
/>
|
||
</div>
|
||
|
||
{/* FQDN */}
|
||
<div>
|
||
<label className="block text-sm font-medium mb-1">FQDN (optional)</label>
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
type="text"
|
||
value={form.fqdn}
|
||
onChange={(e) => set("fqdn", e.target.value.toLowerCase())}
|
||
placeholder={`app.${siteHostname}`}
|
||
className="w-full rounded-lg border border-neutral-300 dark:border-neutral-700 bg-white/80 dark:bg-neutral-900/80 px-3 py-2"
|
||
/>
|
||
</div>
|
||
<p className="mt-1 text-xs text-neutral-500">
|
||
We can map a subdomain like <code>app</code>.{siteHostname}.
|
||
</p>
|
||
</div>
|
||
|
||
{/* SSH Public Key */}
|
||
<div className="sm:col-span-2">
|
||
<label className="block text-sm font-medium mb-1">SSH Public Key (recommended)</label>
|
||
<textarea
|
||
value={form.sshPublicKey}
|
||
onChange={(e) => set("sshPublicKey", e.target.value)}
|
||
rows={3}
|
||
placeholder="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5.... your_email@example.com"
|
||
className="w-full rounded-lg border border-neutral-300 dark:border-neutral-700 bg-white/80 dark:bg-neutral-900/80 px-3 py-2"
|
||
/>
|
||
<label className="mt-2 inline-flex items-center gap-2 text-sm">
|
||
<input
|
||
type="checkbox"
|
||
checked={form.enableBackups}
|
||
onChange={(e) => set("enableBackups", e.target.checked)}
|
||
className="h-4 w-4"
|
||
/>
|
||
Enable backups (recommended)
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-6 flex justify-between">
|
||
<button
|
||
onClick={() => setStep(1)}
|
||
className="text-sm text-neutral-600 dark:text-neutral-400 hover:underline"
|
||
>
|
||
← Back
|
||
</button>
|
||
<button
|
||
disabled={!canContinue2}
|
||
onClick={() => setStep(3)}
|
||
className="rounded-lg bg-gradient-to-r from-blue-600 via-purple-600 to-blue-600 text-white font-medium px-5 py-2 disabled:opacity-50"
|
||
>
|
||
Continue →
|
||
</button>
|
||
</div>
|
||
</motion.div>
|
||
)}
|
||
|
||
{step === 3 && (
|
||
<motion.div
|
||
key="vps-step3"
|
||
initial={{ opacity: 0, y: 20 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
exit={{ opacity: 0, y: -20 }}
|
||
transition={{ duration: 0.25 }}
|
||
>
|
||
<h3 className="text-xl font-semibold text-neutral-900 dark:text-white">3) Ownership & Provision</h3>
|
||
|
||
<div className="mt-4 grid gap-4">
|
||
{/* Ownership Mode */}
|
||
<div>
|
||
<label className="block text-sm font-medium mb-1">Management Mode</label>
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||
<button
|
||
onClick={() => set("ownership", "managed")}
|
||
type="button"
|
||
className={`rounded-lg border p-3 text-left ${
|
||
form.ownership === "managed"
|
||
? "border-blue-600 bg-blue-50/50 dark:bg-blue-950/30"
|
||
: "border-neutral-300 dark:border-neutral-700"
|
||
}`}
|
||
>
|
||
<div className="font-semibold">Managed (default)</div>
|
||
<div className="text-xs text-neutral-600 dark:text-neutral-400">
|
||
We host & bill the VPS; you still get access. Backups & monitoring included.
|
||
</div>
|
||
</button>
|
||
|
||
<button
|
||
onClick={() => set("ownership", "owned")}
|
||
type="button"
|
||
className={`rounded-lg border p-3 text-left ${
|
||
form.ownership === "owned"
|
||
? "border-blue-600 bg-blue-50/50 dark:bg-blue-950/30"
|
||
: "border-neutral-300 dark:border-neutral-700"
|
||
}`}
|
||
>
|
||
<div className="font-semibold">Owned (your account)</div>
|
||
<div className="text-xs text-neutral-600 dark:text-neutral-400">
|
||
We provision into your cloud account via a secure connect. You keep full ownership.
|
||
</div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Provider (only for Owned) */}
|
||
{form.ownership === "owned" && (
|
||
<div>
|
||
<label className="block text-sm font-medium mb-1">Cloud Provider</label>
|
||
<select
|
||
value={form.provider}
|
||
onChange={(e) =>
|
||
set("provider", e.target.value as "hetzner" | "digitalocean" | "vultr" | "")
|
||
}
|
||
className="w-full rounded-lg border border-neutral-300 dark:border-neutral-700 bg-white/80 dark:bg-neutral-900/80 px-3 py-2"
|
||
>
|
||
<option value="">Select provider</option>
|
||
<option value="hetzner">Hetzner</option>
|
||
<option value="digitalocean">DigitalOcean</option>
|
||
<option value="vultr">Vultr</option>
|
||
</select>
|
||
<p className="mt-1 text-xs text-neutral-500">
|
||
After submission, we’ll email a secure link to connect your provider—no API keys here.
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Contact + Notes */}
|
||
<div className="grid gap-4 sm:grid-cols-2">
|
||
<div>
|
||
<label className="block text-sm font-medium mb-1">Contact Email</label>
|
||
<input
|
||
type="email"
|
||
value={form.email}
|
||
onChange={(e) => set("email", e.target.value)}
|
||
placeholder="you@example.com"
|
||
className="w-full rounded-lg border border-neutral-300 dark:border-neutral-700 bg-white/80 dark:bg-neutral-900/80 px-3 py-2"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium mb-1">Notes (optional)</label>
|
||
<input
|
||
type="text"
|
||
value={form.notes}
|
||
onChange={(e) => set("notes", e.target.value)}
|
||
placeholder="Apps to deploy, access prefs, etc."
|
||
className="w-full rounded-lg border border-neutral-300 dark:border-neutral-700 bg-white/80 dark:bg-neutral-900/80 px-3 py-2"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<label className="mt-2 inline-flex items-center gap-2 text-sm">
|
||
<input
|
||
type="checkbox"
|
||
checked={form.acceptTerms}
|
||
onChange={(e) => set("acceptTerms", e.target.checked)}
|
||
className="h-4 w-4"
|
||
/>
|
||
I accept the service terms and understand provisioning involves infrastructure costs.
|
||
</label>
|
||
</div>
|
||
|
||
<div className="mt-6 flex justify-between items-center">
|
||
<button
|
||
onClick={() => setStep(2)}
|
||
className="text-sm text-neutral-600 dark:text-neutral-400 hover:underline"
|
||
>
|
||
← Back
|
||
</button>
|
||
<button
|
||
onClick={handleSubmit}
|
||
disabled={!canSubmit || loading}
|
||
className="rounded-lg bg-gradient-to-r from-blue-600 via-purple-600 to-blue-600 text-white font-medium px-6 py-2 disabled:opacity-50"
|
||
>
|
||
{loading ? "Provisioning..." : "Provision VPS"}
|
||
</button>
|
||
</div>
|
||
|
||
<p className="mt-3 text-xs text-neutral-600 dark:text-neutral-400">
|
||
We’ll send access details by email. Managed mode includes monitoring and backups. Owned mode will
|
||
prompt a secure provider connect after submission.
|
||
</p>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
</div>
|
||
);
|
||
}
|