359 lines
12 KiB
TypeScript
359 lines
12 KiB
TypeScript
"use client";
|
||
|
||
import { useMemo, useState } from "react";
|
||
import Link from "next/link";
|
||
|
||
// ---------- Types ----------
|
||
export type CarePlan = {
|
||
id: string;
|
||
name: string;
|
||
bestFor: string;
|
||
monthlyPrice: number;
|
||
yearlyDiscount: number; // 0.1 = 10%
|
||
outcomes: readonly string[];
|
||
inclusions: readonly string[];
|
||
sla: string;
|
||
cta: { label: string; href: string };
|
||
popular?: boolean;
|
||
contactOnly?: boolean;
|
||
};
|
||
|
||
export type VPSPlan = {
|
||
id: "solo" | "team" | "dedicated";
|
||
name: string;
|
||
price: number | null; // null = custom
|
||
specs: string;
|
||
bestFor: string;
|
||
};
|
||
|
||
export type MCPlan = {
|
||
id: "starter" | "pro" | "network";
|
||
name: string;
|
||
price: number | null;
|
||
specs: string;
|
||
typical: string;
|
||
features: readonly string[];
|
||
};
|
||
|
||
// ---------- Main Component ----------
|
||
export default function PricingConfigurator({
|
||
carePlans,
|
||
vpsPlans,
|
||
minecraftPlans,
|
||
}: {
|
||
carePlans: readonly CarePlan[];
|
||
vpsPlans: readonly VPSPlan[];
|
||
minecraftPlans: readonly MCPlan[];
|
||
}) {
|
||
const [tab, setTab] = useState<"care" | "vps" | "mc">("care");
|
||
const [billing, setBilling] = useState<"monthly" | "yearly">("monthly");
|
||
const [vpsOwnership, setVpsOwnership] = useState<"managed" | "owned">("managed");
|
||
|
||
return (
|
||
<div className="rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white/60 dark:bg-neutral-900/60 p-6 sm:p-8 shadow-sm">
|
||
{/* Tabs */}
|
||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||
<div className="inline-flex rounded-xl border border-neutral-200 dark:border-neutral-800 p-1">
|
||
{[
|
||
{ id: "care", label: "Managed Hosting & Care" },
|
||
{ id: "vps", label: "VPS (Managed or Owned)" },
|
||
{ id: "mc", label: "Minecraft Hosting" },
|
||
].map((t) => (
|
||
<button
|
||
key={t.id}
|
||
onClick={() => setTab(t.id as any)}
|
||
className={`px-3 py-1.5 text-sm rounded-lg ${
|
||
tab === t.id
|
||
? "bg-neutral-900 text-white dark:bg-white dark:text-neutral-900"
|
||
: ""
|
||
}`}
|
||
>
|
||
{t.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Billing toggle */}
|
||
{tab === "care" && (
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-xs text-neutral-600 dark:text-neutral-400">Billing:</span>
|
||
<div className="inline-flex rounded-xl border border-neutral-200 dark:border-neutral-800 p-1">
|
||
{(["monthly", "yearly"] as const).map((b) => (
|
||
<button
|
||
key={b}
|
||
onClick={() => setBilling(b)}
|
||
className={`px-3 py-1.5 text-sm rounded-lg ${
|
||
billing === b ? "bg-blue-600 text-white" : ""
|
||
}`}
|
||
>
|
||
{b === "monthly" ? "Monthly" : "Yearly (-10%)"}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Panels */}
|
||
<div className="mt-8">
|
||
{tab === "care" && <CareGrid plans={carePlans} billing={billing} />}
|
||
{tab === "vps" && (
|
||
<VPSGrid
|
||
plans={vpsPlans}
|
||
ownership={vpsOwnership}
|
||
onOwnershipChange={setVpsOwnership}
|
||
/>
|
||
)}
|
||
{tab === "mc" && <MinecraftGrid plans={minecraftPlans} />}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---------- CARE ----------
|
||
function CareGrid({
|
||
plans,
|
||
billing,
|
||
}: {
|
||
plans: readonly CarePlan[];
|
||
billing: "monthly" | "yearly";
|
||
}) {
|
||
return (
|
||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||
{plans.map((p) => {
|
||
const price =
|
||
billing === "yearly"
|
||
? Math.round(p.monthlyPrice * (1 - p.yearlyDiscount))
|
||
: p.monthlyPrice;
|
||
|
||
return (
|
||
<div
|
||
key={p.id}
|
||
className={`relative rounded-2xl border p-6 ${
|
||
p.popular
|
||
? "border-blue-600 bg-blue-50/40 dark:bg-blue-950/20"
|
||
: "border-neutral-200 dark:border-neutral-800 bg-white/60 dark:bg-neutral-900/60"
|
||
}`}
|
||
>
|
||
{p.popular && (
|
||
<div className="absolute -top-3 right-4 rounded-full bg-blue-600 text-white text-xs font-semibold px-3 py-1 shadow">
|
||
Most Popular
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex items-baseline justify-between gap-3">
|
||
<h3 className="text-lg font-semibold">{p.name}</h3>
|
||
<div className="text-right">
|
||
<div className="text-2xl font-extrabold">
|
||
€{price}
|
||
<span className="text-sm font-medium text-neutral-500">/mo</span>
|
||
</div>
|
||
{billing === "yearly" && (
|
||
<div className="text-xs text-neutral-500">billed annually (-10%)</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||
{p.bestFor}
|
||
</div>
|
||
|
||
<ul className="mt-4 space-y-2 text-sm">
|
||
{p.outcomes.map((o) => (
|
||
<li key={o} className="flex items-start gap-2">
|
||
<span className="mt-0.5">🎯</span>
|
||
<span>{o}</span>
|
||
</li>
|
||
))}
|
||
{p.inclusions.map((i) => (
|
||
<li key={i} className="flex items-start gap-2">
|
||
<span className="mt-0.5">✅</span>
|
||
<span>{i}</span>
|
||
</li>
|
||
))}
|
||
<li className="flex items-start gap-2">
|
||
<span className="mt-0.5">🛡️</span>
|
||
<span>{p.sla}</span>
|
||
</li>
|
||
</ul>
|
||
|
||
<div className="mt-6">
|
||
<Link
|
||
href={p.cta.href}
|
||
className={`inline-flex items-center rounded-lg px-4 py-2 font-semibold ${
|
||
p.contactOnly
|
||
? "border border-neutral-300 dark:border-neutral-700"
|
||
: "bg-gradient-to-r from-blue-600 via-purple-600 to-blue-600 text-white"
|
||
}`}
|
||
>
|
||
{p.cta.label}
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---------- VPS ----------
|
||
function VPSGrid({
|
||
plans,
|
||
ownership,
|
||
onOwnershipChange,
|
||
}: {
|
||
plans: readonly VPSPlan[];
|
||
ownership: "managed" | "owned";
|
||
onOwnershipChange: (m: "managed" | "owned") => void;
|
||
}) {
|
||
return (
|
||
<div>
|
||
<div className="mb-4 flex items-center justify-between">
|
||
<div className="text-sm text-neutral-600 dark:text-neutral-400">
|
||
Choose how you want to run your VPS.
|
||
</div>
|
||
<div className="inline-flex rounded-xl border border-neutral-200 dark:border-neutral-800 p-1">
|
||
{(["managed", "owned"] as const).map((m) => (
|
||
<button
|
||
key={m}
|
||
onClick={() => onOwnershipChange(m)}
|
||
className={`px-3 py-1.5 text-sm rounded-lg ${
|
||
ownership === m ? "bg-blue-600 text-white" : ""
|
||
}`}
|
||
>
|
||
{m === "managed" ? "Managed (default)" : "Owned (your account)"}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||
{plans.map((p) => (
|
||
<div
|
||
key={p.id}
|
||
className="rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white/60 dark:bg-neutral-900/60 p-6 flex flex-col"
|
||
>
|
||
<div className="flex items-baseline justify-between">
|
||
<h3 className="text-lg font-semibold">{p.name}</h3>
|
||
<div className="text-right">
|
||
<div className="text-xl font-extrabold">
|
||
{p.price ? (
|
||
<>
|
||
€{p.price}
|
||
<span className="text-sm font-medium text-neutral-500">/mo</span>
|
||
</>
|
||
) : (
|
||
"Custom"
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="mt-1 text-sm 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>
|
||
|
||
<ul className="mt-4 space-y-2 text-sm">
|
||
<li className="flex items-start gap-2">
|
||
<span className="mt-0.5">🛡️</span>
|
||
<span>CIS-style hardening, updates & monitoring</span>
|
||
</li>
|
||
<li className="flex items-start gap-2">
|
||
<span className="mt-0.5">💾</span>
|
||
<span>Backups with scheduled restore tests</span>
|
||
</li>
|
||
<li className="flex items-start gap-2">
|
||
<span className="mt-0.5">📈</span>
|
||
<span>Incident notes & SLA-backed response</span>
|
||
</li>
|
||
</ul>
|
||
|
||
<div className="mt-6 flex gap-3">
|
||
{p.id === "dedicated" ? (
|
||
<Link
|
||
href="/contact?type=vps"
|
||
className="inline-flex items-center rounded-lg border border-neutral-300 dark:border-neutral-700 px-4 py-2 font-semibold"
|
||
>
|
||
Request Custom Plan
|
||
</Link>
|
||
) : (
|
||
<Link
|
||
href={`/vps#deploy`}
|
||
className="inline-flex items-center rounded-lg bg-gradient-to-r from-blue-600 via-purple-600 to-blue-600 text-white px-4 py-2 font-semibold"
|
||
>
|
||
{ownership === "managed" ? "Provision (Managed)" : "Provision (Owned)"}
|
||
</Link>
|
||
)}
|
||
</div>
|
||
|
||
<p className="mt-3 text-xs text-neutral-600 dark:text-neutral-400">
|
||
{ownership === "managed"
|
||
? "We host & bill the VPS on our platform. Management included."
|
||
: "Provisioned into your cloud account via secure connect. You retain ownership."}
|
||
</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---------- MINECRAFT ----------
|
||
function MinecraftGrid({ plans }: { plans: readonly MCPlan[] }) {
|
||
return (
|
||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||
{plans.map((p) => (
|
||
<div
|
||
key={p.id}
|
||
className="rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white/60 dark:bg-neutral-900/60 p-6 flex flex-col"
|
||
>
|
||
<div className="flex items-baseline justify-between">
|
||
<h3 className="text-lg font-semibold">{p.name}</h3>
|
||
<div className="text-xl font-extrabold">
|
||
{p.price ? (
|
||
<>
|
||
€{p.price}
|
||
<span className="text-sm font-medium text-neutral-500">/mo</span>
|
||
</>
|
||
) : (
|
||
"Custom"
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="mt-1 text-sm 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>
|
||
|
||
<ul className="mt-4 space-y-2 text-sm">
|
||
{p.features.map((f) => (
|
||
<li key={f} className="flex items-start gap-2">
|
||
<span className="mt-0.5">✅</span>
|
||
<span>{f}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
|
||
<div className="mt-6">
|
||
{p.id === "network" ? (
|
||
<Link
|
||
href="/contact?type=minecraft-hosting"
|
||
className="inline-flex items-center rounded-lg border border-neutral-300 dark:border-neutral-700 px-4 py-2 font-semibold"
|
||
>
|
||
Request Custom Plan
|
||
</Link>
|
||
) : (
|
||
<Link
|
||
href="/minecraft#deploy"
|
||
className="inline-flex items-center rounded-lg bg-gradient-to-r from-blue-600 via-purple-600 to-blue-600 text-white px-4 py-2 font-semibold"
|
||
>
|
||
Deploy Server
|
||
</Link>
|
||
)}
|
||
</div>
|
||
|
||
<p className="mt-3 text-xs text-neutral-600 dark:text-neutral-400">
|
||
Backups with restore tests, tuning & monitoring included. Networks & modpacks may require custom sizing.
|
||
</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|