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

359 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}