386 lines
14 KiB
TypeScript
386 lines
14 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 = "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 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.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">
|
||
We’ll 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">
|
||
We’ll 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>
|
||
);
|
||
}
|