it/web/app/vps/VPSDeployWizard.tsx
2025-10-26 17:52:17 +01:00

411 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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 customcontact 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, well email a secure link to connect your providerno 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">
Well 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>
);
}