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

386 lines
14 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 = "starter" | "pro" | "network";
type Edition = "java" | "bedrock" | "modded";
type ModLoader = "paper" | "spigot" | "velocity" | "forge" | "fabric" | "";
type Region = "eu-nl" | "eu-de" | "us-east" | "us-west";
// ✅ Allow readonly arrays for safe assignment from const literals
type Plan = {
id: PlanId;
name: string;
price: string;
specs: string;
typical: string;
features: readonly string[];
};
type RegionOpt = { id: Region; label: string };
export default function DeployWizard({
plans,
regions,
siteHostname,
}: {
plans: readonly Plan[];
regions: readonly RegionOpt[];
siteHostname: string;
}) {
const router = useRouter();
const [step, setStep] = useState<number>(1);
const [loading, setLoading] = useState<boolean>(false);
const [form, setForm] = useState<{
plan: PlanId | "";
edition: Edition | "";
modLoader: ModLoader;
version: string;
region: Region | "";
serverName: string;
subdomain: string;
email: string;
acceptEula: boolean;
notes: string;
}>({
plan: "",
edition: "",
modLoader: "",
version: "latest",
region: "",
serverName: "",
subdomain: "",
email: "",
acceptEula: false,
notes: "",
});
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=(starter|pro|network)/.exec(hash || "")?.[1] as PlanId | undefined;
const initial = qp || fromHash;
if (initial) set("plan", initial);
}
const canContinue1 = !!form.plan;
const canContinue2 =
!!form.edition &&
!!form.region &&
!!form.version &&
(form.edition !== "modded" || !!form.modLoader);
const canSubmit =
canContinue2 &&
!!form.serverName &&
!!form.email &&
!!form.subdomain &&
form.acceptEula;
const handleSubmit = async () => {
if (!canSubmit) return;
setLoading(true);
try {
const res = await fetch("/api/minecraft/create-server", {
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("Server provisioning started! Check your email for details.");
router.push("/minecraft/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="mc-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. Network 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.typical}
</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="mc-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">
{/* Edition */}
<div>
<label className="block text-sm font-medium mb-1">Edition</label>
<select
value={form.edition}
onChange={(e) => set("edition", e.target.value as Edition)}
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 one</option>
<option value="java">Java (Paper/Spigot)</option>
<option value="bedrock">Bedrock (Geyser supported)</option>
<option value="modded">Modded (Forge/Fabric)</option>
</select>
</div>
{/* Mod loader */}
<div>
<label className="block text-sm font-medium mb-1">Mod Loader</label>
<select
value={form.modLoader}
onChange={(e) => set("modLoader", e.target.value as ModLoader)}
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"
disabled={form.edition === "bedrock"}
>
<option value="">None / Default</option>
<option value="paper">Paper</option>
<option value="spigot">Spigot</option>
<option value="velocity">Velocity (proxy)</option>
<option value="forge">Forge</option>
<option value="fabric">Fabric</option>
</select>
<p className="mt-1 text-xs text-neutral-500">
Velocity is recommended for multi-server networks.
</p>
</div>
{/* Version */}
<div>
<label className="block text-sm font-medium mb-1">Version</label>
<input
type="text"
value={form.version}
onChange={(e) => set("version", e.target.value)}
placeholder="latest or e.g. 1.20.6"
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>
{/* 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>
</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="mc-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) Details & Provision
</h3>
<div className="mt-4 grid gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium mb-1">Server Name</label>
<input
type="text"
value={form.serverName}
onChange={(e) => set("serverName", e.target.value)}
placeholder="My Community Server"
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">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>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium mb-1">Subdomain</label>
<div className="flex items-center gap-2">
<input
type="text"
value={form.subdomain}
onChange={(e) =>
set(
"subdomain",
e.target.value.replace(/[^a-z0-9-]/gi, "").toLowerCase()
)
}
placeholder="myserver"
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"
/>
<span className="text-sm text-neutral-600 dark:text-neutral-400">
.{siteHostname}
</span>
</div>
<p className="mt-1 text-xs text-neutral-500">
Well map this to your server on provision.
</p>
</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="Plugins to preinstall, SFTP user, 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.acceptEula}
onChange={(e) => set("acceptEula", e.target.checked)}
className="h-4 w-4"
/>
I accept the{" "}
<a
href="https://www.minecraft.net/en-us/eula"
target="_blank"
rel="noreferrer"
className="underline"
>
Minecraft EULA
</a>
.
</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 Server"}
</button>
</div>
<p className="mt-3 text-xs text-neutral-600 dark:text-neutral-400">
Well send panel access and SFTP credentials to your email. All plans include
backups and monitoring. Modpacks & networks depend on resources and may
require custom sizing.
</p>
</motion.div>
)}
</AnimatePresence>
</div>
);
}