From eaff18b5917bcf63e88330257e10c05066c59a3e Mon Sep 17 00:00:00 2001 From: TheRealToxicDev Date: Mon, 6 Jul 2026 18:10:27 -0600 Subject: [PATCH] fix: product handling --- app/api/products/overrides/route.ts | 29 -- app/dedicated/page.tsx | 9 +- app/games/[slug]/page.tsx | 95 +++++++ app/games/gmod/page.tsx | 48 ---- app/games/hytale/page.tsx | 71 ----- app/games/minecraft/page.tsx | 81 ------ app/games/page.tsx | 48 ++-- app/games/palworld/page.tsx | 48 ---- app/games/rust/page.tsx | 82 ------ app/games/terraria/page.tsx | 48 ---- app/layout.tsx | 10 +- app/vps/page.tsx | 15 +- packages/core/constants/catalog-hubs.ts | 11 + packages/core/constants/game/gmod.ts | 5 - packages/core/constants/game/hytale.ts | 28 -- packages/core/constants/game/index.ts | 78 ------ packages/core/constants/game/minecraft.ts | 148 ++++++----- packages/core/constants/game/palworld.ts | 5 - packages/core/constants/game/rust.ts | 144 +++++----- packages/core/constants/game/terraria.ts | 5 - packages/core/constants/services.ts | 19 -- packages/core/constants/status-mapping.ts | 4 +- packages/core/lib/bytepay.ts | 61 ++++- packages/core/lib/spec-parser.ts | 37 ++- packages/core/products/billing-service.ts | 6 +- packages/core/products/catalog-config.ts | 247 ++++++++++++++++++ packages/core/products/index.ts | 8 - packages/core/products/override-store.ts | 24 -- packages/core/products/server.ts | 35 --- packages/core/products/service.ts | 106 -------- packages/core/products/types.ts | 40 --- packages/core/types/servers/game.ts | 6 + .../components/Layouts/About/about-page.tsx | 2 +- .../Layouts/Dedicated/dedicated-hub.tsx | 9 +- .../Layouts/Games/game-features.tsx | 26 +- packages/ui/components/Layouts/Home/about.tsx | 2 +- .../components/Layouts/Home/hero-graphic.tsx | 2 +- .../ui/components/Layouts/Home/services.tsx | 38 ++- .../components/Layouts/Nodes/nodes-client.tsx | 12 +- .../ui/components/Layouts/VPS/vps-hub.tsx | 9 +- packages/ui/components/Static/navigation.tsx | 35 +-- packages/ui/components/layout-chrome.tsx | 5 +- translations | 2 +- 43 files changed, 773 insertions(+), 970 deletions(-) delete mode 100644 app/api/products/overrides/route.ts create mode 100644 app/games/[slug]/page.tsx delete mode 100644 app/games/gmod/page.tsx delete mode 100644 app/games/hytale/page.tsx delete mode 100644 app/games/minecraft/page.tsx delete mode 100644 app/games/palworld/page.tsx delete mode 100644 app/games/rust/page.tsx delete mode 100644 app/games/terraria/page.tsx create mode 100644 packages/core/constants/catalog-hubs.ts create mode 100644 packages/core/products/catalog-config.ts delete mode 100644 packages/core/products/index.ts delete mode 100644 packages/core/products/override-store.ts delete mode 100644 packages/core/products/server.ts delete mode 100644 packages/core/products/service.ts delete mode 100644 packages/core/products/types.ts diff --git a/app/api/products/overrides/route.ts b/app/api/products/overrides/route.ts deleted file mode 100644 index 166e32d..0000000 --- a/app/api/products/overrides/route.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { NextResponse } from "next/server" -import { getAllOverrides, setOverride } from "@/packages/core/products/override-store" -import type { ProductOverride } from "@/packages/core/products/override-store" - -export async function GET() { - return NextResponse.json(getAllOverrides()) -} - -export async function PATCH(request: Request) { - let body: { id?: string } & Partial - - try { - body = await request.json() - } catch { - return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }) - } - - const { id, stock, enabled } = body - - if (!id || stock === undefined || enabled === undefined) { - return NextResponse.json( - { error: "id, stock and enabled are required" }, - { status: 400 }, - ) - } - - setOverride(id, { stock, enabled }) - return NextResponse.json({ ok: true }) -} diff --git a/app/dedicated/page.tsx b/app/dedicated/page.tsx index a12908f..cf7aae6 100644 --- a/app/dedicated/page.tsx +++ b/app/dedicated/page.tsx @@ -1,6 +1,8 @@ import type { Metadata } from "next" import { DedicatedHub } from "@/packages/ui/components/Layouts/Dedicated/dedicated-hub" import { getDedicatedPlans } from "@/packages/core/products/billing-service" +import { getCategoryHub } from "@/packages/core/lib/bytepay" +import { DEDICATED_HUB_SLUGS } from "@/packages/core/constants/catalog-hubs" export const metadata: Metadata = { title: "Dedicated Servers", @@ -9,6 +11,11 @@ export const metadata: Metadata = { } export default async function DedicatedPage() { - const plans = await getDedicatedPlans("dedicated-servers") + const hub = await getCategoryHub(DEDICATED_HUB_SLUGS) + const children = hub?.children ?? [] + + const plansByCategory = await Promise.all(children.map((c) => getDedicatedPlans(c.slug))) + const plans = plansByCategory.flat() + return } diff --git a/app/games/[slug]/page.tsx b/app/games/[slug]/page.tsx new file mode 100644 index 0000000..5b46079 --- /dev/null +++ b/app/games/[slug]/page.tsx @@ -0,0 +1,95 @@ +import type { Metadata } from "next" +import { notFound } from "next/navigation" +import { Blocks, Gamepad2, Sparkles, Leaf, Pickaxe, Wrench } from "lucide-react" +import { getTranslations } from "next-intl/server" +import { GameHero } from "@/packages/ui/components/Layouts/Games/game-hero" +import { GameFeatures } from "@/packages/ui/components/Layouts/Games/game-features" +import { GamePricing } from "@/packages/ui/components/Layouts/Games/game-pricing" +import { GameFAQ } from "@/packages/ui/components/Layouts/Games/game-faq" +import { getGamePlans } from "@/packages/core/products/billing-service" +import { resolveGameDisplayConfig } from "@/packages/core/products/catalog-config" +import { getCategoryHub } from "@/packages/core/lib/bytepay" +import { GAME_HUB_SLUGS } from "@/packages/core/constants/catalog-hubs" + +const HEADER_ICON_MAP = { Blocks, Gamepad2, Sparkles, Leaf, Pickaxe, Wrench } + +interface PageProps { + params: Promise<{ slug: string }> +} + +async function resolveCategory(slug: string) { + const hub = await getCategoryHub(GAME_HUB_SLUGS) + return hub?.children.find((c) => c.slug === slug) ?? null +} + +export async function generateStaticParams() { + const hub = await getCategoryHub(GAME_HUB_SLUGS) + return (hub?.children ?? []).map((c) => ({ slug: c.slug })) +} + +export async function generateMetadata({ params }: PageProps): Promise { + const { slug } = await params + const category = await resolveCategory(slug) + if (!category) return {} + + const config = resolveGameDisplayConfig(category) + return { + title: `${config.name} Server Hosting`, + description: config.description, + } +} + +export default async function GameCategoryPage({ params }: PageProps) { + const { slug } = await params + const category = await resolveCategory(slug) + if (!category) notFound() + + const t = await getTranslations() + const config = resolveGameDisplayConfig(category) + const rawPlans = await getGamePlans(slug) + + const plans = rawPlans.map((plan) => { + const display = config.resolvePlanDisplay(plan) + return { + name: display.name, + description: display.description, + priceGBP: plan.priceGBP, + prices: plan.prices, + period: t("pricing.perMonth"), + popular: plan.popular, + location: plan.location, + stock: plan.stock, + features: display.features, + url: plan.url, + } + }) + + const HeaderIcon = HEADER_ICON_MAP[config.iconName] + + return ( + <> + + } + headerGradient={config.headerGradient} + headerIconBg={config.headerIconBg} + /> + + + + ) +} diff --git a/app/games/gmod/page.tsx b/app/games/gmod/page.tsx deleted file mode 100644 index f0fc161..0000000 --- a/app/games/gmod/page.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import type { Metadata } from "next" -import { Wrench } from "lucide-react" -import { GameHero } from "@/packages/ui/components/Layouts/Games/game-hero" -import { GameFeatures } from "@/packages/ui/components/Layouts/Games/game-features" -import { GamePricing } from "@/packages/ui/components/Layouts/Games/game-pricing" -import { GameFAQ } from "@/packages/ui/components/Layouts/Games/game-faq" -import { LINKS } from "@/packages/core/constants/links" -import { - GMOD_FEATURES, - GMOD_FAQS, - GMOD_HERO_FEATURES, - GMOD_CONFIG, -} from "@/packages/core/constants/game" - -export const metadata: Metadata = { - title: "Garry's Mod Server Hosting", - description: - "High-performance Garry's Mod server hosting with full Steam Workshop support, MySQL integration, DarkRP-ready setup, and enterprise DDoS protection.", -} - -export default function GModPage() { - return ( - <> - - } - headerGradient={GMOD_CONFIG.headerGradient} - headerIconBg={GMOD_CONFIG.headerIconBg} - /> - - - - ) -} diff --git a/app/games/hytale/page.tsx b/app/games/hytale/page.tsx deleted file mode 100644 index 7af1cf6..0000000 --- a/app/games/hytale/page.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { GameHero } from "@/packages/ui/components/Layouts/Games/game-hero" -import { Sparkles } from "lucide-react" -import { GameFeatures } from "@/packages/ui/components/Layouts/Games/game-features" -import { GamePricing } from "@/packages/ui/components/Layouts/Games/game-pricing" -import { GameFAQ } from "@/packages/ui/components/Layouts/Games/game-faq" -import type { Metadata } from "next" -import { getTranslations } from "next-intl/server" -import { LINKS } from "@/packages/core/constants/links" -import { - HYTALE_PLAN_DISPLAY, - HYTALE_PLAN_STATIC_FEATURES, - HYTALE_FEATURES, - HYTALE_FAQS, - HYTALE_HERO_FEATURES, - HYTALE_CONFIG, -} from "@/packages/core/constants/game" -import { getGamePlans } from "@/packages/core/products/billing-service" - -export const metadata: Metadata = { - title: "Hytale Server Hosting", - description: "Be ready when Hytale launches. NodeByte Hosting will offer high-performance Hytale server hosting with mod support, custom maps, DDoS protection, and 24/7 support.", -} - -export default async function HytalePage() { - const t = await getTranslations() - - const plans = (await getGamePlans("hytale")).map((plan) => { - const display = HYTALE_PLAN_DISPLAY[plan.id as keyof typeof HYTALE_PLAN_DISPLAY] - return { - name: display.name, - description: display.description, - priceGBP: plan.priceGBP, - prices: plan.prices, - period: t("pricing.perMonth"), - popular: plan.popular, - url: plan.url, - stock: plan.stock, - features: [ - ...HYTALE_PLAN_STATIC_FEATURES, - `${plan.ramGB}GB DDR4 RAM`, - `${plan.storageGB}GB SSD Storage`, - ], - } - }) - - return ( - <> - - } - headerGradient={HYTALE_CONFIG.headerGradient} - headerIconBg={HYTALE_CONFIG.headerIconBg} - /> - - - - ) -} - diff --git a/app/games/minecraft/page.tsx b/app/games/minecraft/page.tsx deleted file mode 100644 index 0ddc6cb..0000000 --- a/app/games/minecraft/page.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import type { Metadata } from "next" -import { Blocks } from "lucide-react" -import { GameHero } from "@/packages/ui/components/Layouts/Games/game-hero" -import { GameFeatures } from "@/packages/ui/components/Layouts/Games/game-features" -import { GamePricing } from "@/packages/ui/components/Layouts/Games/game-pricing" -import { GameFAQ } from "@/packages/ui/components/Layouts/Games/game-faq" -import { getTranslations } from "next-intl/server" -import { LINKS } from "@/packages/core/constants/links" -import { - MINECRAFT_PLAN_FEATURE_KEYS, - MINECRAFT_FEATURE_KEYS, - MINECRAFT_FAQ_KEYS, - MINECRAFT_HERO_FEATURES, - MINECRAFT_CONFIG, -} from "@/packages/core/constants/game" -import { getGamePlans } from "@/packages/core/products/billing-service" - -export const metadata: Metadata = { - title: "Minecraft Server Hosting", - description: "High-performance Minecraft server hosting with instant setup, Java & Bedrock support, one-click Forge & Fabric mod loaders, and enterprise DDoS protection.", -} - -export default async function MinecraftPage() { - const t = await getTranslations() - - const plans = (await getGamePlans("minecraft")).map((plan) => ({ - name: t(`games.minecraft.plans.${plan.id}.name`), - description: t(`games.minecraft.plans.${plan.id}.description`), - priceGBP: plan.priceGBP, - prices: plan.prices, - period: t("pricing.perMonth"), - popular: plan.popular, - location: plan.location, - stock: plan.stock, - features: MINECRAFT_PLAN_FEATURE_KEYS.map((key) => { - if (key === "ram") return t("games.minecraft.planFeatures.ram", { amount: plan.ramGB }) - if (key === "storage") return t("games.minecraft.planFeatures.storage", { amount: plan.storageGB }) - return t(`games.minecraft.planFeatures.${key}`) - }), - url: plan.url, - })) - - const features = MINECRAFT_FEATURE_KEYS.map(({ key, icon }) => ({ - title: t(`games.minecraft.pageFeatures.${key}.title`), - description: t(`games.minecraft.pageFeatures.${key}.description`), - icon, - highlights: Array.from({ length: 4 }, (_, i) => - t(`games.minecraft.pageFeatures.${key}.highlights.${i}`) - ), - })) - - const faqs = MINECRAFT_FAQ_KEYS.map((key) => ({ - question: t(`games.minecraft.faqs.${key}.question`), - answer: t(`games.minecraft.faqs.${key}.answer`), - })) - - return ( - <> - - } - headerGradient={MINECRAFT_CONFIG.headerGradient} - headerIconBg={MINECRAFT_CONFIG.headerIconBg} - /> - - - - ) -} diff --git a/app/games/page.tsx b/app/games/page.tsx index 04feb13..5a5b9cc 100644 --- a/app/games/page.tsx +++ b/app/games/page.tsx @@ -8,7 +8,10 @@ import Link from "next/link" import { getTranslations } from "next-intl/server" import { cn } from "@/lib/utils" import type { Metadata } from "next" -import { GAME_OPTIONS } from "@/packages/core/constants/game" +import { getCategoryHub } from "@/packages/core/lib/bytepay" +import { GAME_HUB_SLUGS } from "@/packages/core/constants/catalog-hubs" +import { getGamePlans } from "@/packages/core/products/billing-service" +import { resolveGameDisplayConfig } from "@/packages/core/products/catalog-config" const ICON_MAP: Record = { Blocks, Gamepad2, Sparkles, Leaf, Pickaxe, Wrench } @@ -22,27 +25,36 @@ export async function generateMetadata(): Promise { export default async function GamesPage() { const t = await getTranslations() + const hub = await getCategoryHub(GAME_HUB_SLUGS) + const children = hub?.children ?? [] - const games = GAME_OPTIONS.map((g) => ({ - ...g, - comingSoon: g.comingSoon ?? false, - icon: ICON_MAP[g.iconName], - description: t(`games.${g.slug}.description`), - tag: t(`games.${g.slug}.tag`), - features: [ - t(`games.${g.slug}.features.0`), - t(`games.${g.slug}.features.1`), - t(`games.${g.slug}.features.2`), - t(`games.${g.slug}.features.3`), - ], - })) + const games = await Promise.all( + children.map(async (category) => { + const config = resolveGameDisplayConfig(category) + const plans = await getGamePlans(category.slug) + const comingSoon = plans.length === 0 + const startingPriceGBP = comingSoon ? 0 : Math.min(...plans.map((p) => p.priceGBP)) + return { + slug: category.slug, + name: config.name, + description: config.description, + banner: config.banner, + tag: config.tag, + tagColor: config.tagColor, + icon: ICON_MAP[config.iconName], + features: config.heroFeatures, + comingSoon, + startingPriceGBP, + } + }), + ) return (
{/* Background */}
- + {/* Animated orbs */}
@@ -71,7 +83,7 @@ export default async function GamesPage() {
{games.map((game) => (
- + {/* Tag */}
- {game.features.map((feature, i) => ( + {game.features.map((feature) => (
  • {feature} diff --git a/app/games/palworld/page.tsx b/app/games/palworld/page.tsx deleted file mode 100644 index 983e5a4..0000000 --- a/app/games/palworld/page.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import type { Metadata } from "next" -import { Leaf } from "lucide-react" -import { GameHero } from "@/packages/ui/components/Layouts/Games/game-hero" -import { GameFeatures } from "@/packages/ui/components/Layouts/Games/game-features" -import { GamePricing } from "@/packages/ui/components/Layouts/Games/game-pricing" -import { GameFAQ } from "@/packages/ui/components/Layouts/Games/game-faq" -import { LINKS } from "@/packages/core/constants/links" -import { - PALWORLD_FEATURES, - PALWORLD_FAQS, - PALWORLD_HERO_FEATURES, - PALWORLD_CONFIG, -} from "@/packages/core/constants/game" - -export const metadata: Metadata = { - title: "Palworld Server Hosting", - description: - "High-performance Palworld server hosting with custom world configuration, mod support, automatic backups, and enterprise DDoS protection.", -} - -export default function PalworldPage() { - return ( - <> - - } - headerGradient={PALWORLD_CONFIG.headerGradient} - headerIconBg={PALWORLD_CONFIG.headerIconBg} - /> - - - - ) -} diff --git a/app/games/rust/page.tsx b/app/games/rust/page.tsx deleted file mode 100644 index 1eb4aa2..0000000 --- a/app/games/rust/page.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import type { Metadata } from "next" -import { GameHero } from "@/packages/ui/components/Layouts/Games/game-hero" -import { Gamepad2 } from "lucide-react" -import { GameFeatures } from "@/packages/ui/components/Layouts/Games/game-features" -import { GamePricing } from "@/packages/ui/components/Layouts/Games/game-pricing" -import { GameFAQ } from "@/packages/ui/components/Layouts/Games/game-faq" -import { getTranslations } from "next-intl/server" -import { LINKS } from "@/packages/core/constants/links" -import { - RUST_PLAN_FEATURE_KEYS, - RUST_FEATURE_KEYS, - RUST_FAQ_KEYS, - RUST_HERO_FEATURES, - RUST_CONFIG, -} from "@/packages/core/constants/game" -import { getGamePlans } from "@/packages/core/products/billing-service" - -export const metadata: Metadata = { - title: "Rust Server Hosting", - description: "High-performance Rust server hosting with Oxide/uMod support, custom maps, wipe scheduling, RCON access, and enterprise DDoS protection.", -} - -export default async function RustPage() { - const t = await getTranslations() - - const plans = (await getGamePlans("rust")).map((plan) => ({ - name: t(`games.rust.plans.${plan.id}.name`), - description: t(`games.rust.plans.${plan.id}.description`), - priceGBP: plan.priceGBP, - prices: plan.prices, - period: t("pricing.perMonth"), - popular: plan.popular, - location: plan.location, - stock: plan.stock, - features: RUST_PLAN_FEATURE_KEYS.map((key) => { - if (key === "ram") return t("games.rust.planFeatures.ram", { amount: plan.ramGB }) - if (key === "storage") return t("games.rust.planFeatures.storage", { amount: plan.storageGB }) - return t(`games.rust.planFeatures.${key}`) - }), - url: plan.url, - })) - - const features = RUST_FEATURE_KEYS.map(({ key, icon }) => ({ - title: t(`games.rust.pageFeatures.${key}.title`), - description: t(`games.rust.pageFeatures.${key}.description`), - icon, - highlights: Array.from({ length: 4 }, (_, i) => - t(`games.rust.pageFeatures.${key}.highlights.${i}`) - ), - })) - - const faqs = RUST_FAQ_KEYS.map((key) => ({ - question: t(`games.rust.faqs.${key}.question`), - answer: t(`games.rust.faqs.${key}.answer`), - })) - - return ( - <> - - } - headerGradient={RUST_CONFIG.headerGradient} - headerIconBg={RUST_CONFIG.headerIconBg} - /> - - - - ) -} - diff --git a/app/games/terraria/page.tsx b/app/games/terraria/page.tsx deleted file mode 100644 index 6f5e207..0000000 --- a/app/games/terraria/page.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import type { Metadata } from "next" -import { Pickaxe } from "lucide-react" -import { GameHero } from "@/packages/ui/components/Layouts/Games/game-hero" -import { GameFeatures } from "@/packages/ui/components/Layouts/Games/game-features" -import { GamePricing } from "@/packages/ui/components/Layouts/Games/game-pricing" -import { GameFAQ } from "@/packages/ui/components/Layouts/Games/game-faq" -import { LINKS } from "@/packages/core/constants/links" -import { - TERRARIA_FEATURES, - TERRARIA_FAQS, - TERRARIA_HERO_FEATURES, - TERRARIA_CONFIG, -} from "@/packages/core/constants/game" - -export const metadata: Metadata = { - title: "Terraria Server Hosting", - description: - "High-performance Terraria server hosting with full tModLoader support, custom world configuration, automatic backups, and enterprise DDoS protection.", -} - -export default function TerrariaPage() { - return ( - <> - - } - headerGradient={TERRARIA_CONFIG.headerGradient} - headerIconBg={TERRARIA_CONFIG.headerIconBg} - /> - - - - ) -} diff --git a/app/layout.tsx b/app/layout.tsx index 9c353b4..5f03d09 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -14,6 +14,8 @@ import { ThemeProvider } from "@/packages/ui/components/theme-provider" import { CurrencyProvider } from "@/packages/core/hooks/use-currency" import { LocaleProvider } from "@/packages/core/hooks/use-locale" import { LayoutChrome } from "@/packages/ui/components/layout-chrome" +import { getCategoryHub } from "@/packages/core/lib/bytepay" +import { GAME_HUB_SLUGS } from "@/packages/core/constants/catalog-hubs" const geist = Geist({ subsets: ["latin"], @@ -93,6 +95,12 @@ export default async function RootLayout({ const locale = await getLocale() const messages = await getMessages() + // Never let a billing-panel outage take down every page on the site — the + // nav just falls back to no games submenu entries if this fails. + const gamesNav = await getCategoryHub(GAME_HUB_SLUGS) + .then((hub) => (hub?.children ?? []).map((c) => ({ slug: c.slug, name: c.name }))) + .catch(() => []) + const htmlClass = [geist.variable, geistMono.variable, themeClass].filter(Boolean).join(" ") return ( @@ -148,7 +156,7 @@ export default async function RootLayout({ - + {children} diff --git a/app/vps/page.tsx b/app/vps/page.tsx index 64e0a6f..3b5e94f 100644 --- a/app/vps/page.tsx +++ b/app/vps/page.tsx @@ -1,6 +1,8 @@ import type { Metadata } from "next" import { VpsHub } from "@/packages/ui/components/Layouts/VPS/vps-hub" import { getVpsPlans } from "@/packages/core/products/billing-service" +import { getCategoryHub } from "@/packages/core/lib/bytepay" +import { VPS_HUB_SLUGS } from "@/packages/core/constants/catalog-hubs" export const metadata: Metadata = { title: "VPS Hosting", @@ -9,10 +11,11 @@ export const metadata: Metadata = { } export default async function VpsPage() { - const [sharedPlans, dedicatedPlans] = await Promise.all([ - getVpsPlans("shared-cpu"), - getVpsPlans("dedicated-cpu"), - ]) - return -} + const hub = await getCategoryHub(VPS_HUB_SLUGS) + const children = hub?.children ?? [] + + const plansByCategory = await Promise.all(children.map((c) => getVpsPlans(c.slug))) + const plans = plansByCategory.flat() + return +} diff --git a/packages/core/constants/catalog-hubs.ts b/packages/core/constants/catalog-hubs.ts new file mode 100644 index 0000000..8eb0961 --- /dev/null +++ b/packages/core/constants/catalog-hubs.ts @@ -0,0 +1,11 @@ +/** + * The site has exactly 3 fundamentally different page templates (games, + * VPS, dedicated) — which parent category in Paymenter maps to which + * template is the one thing that stays a fixed, hand-maintained mapping. + * Everything under a hub (which games/lines/tiers exist) is discovered live. + * + * Name the parent category in Paymenter as either alias to be picked up. + */ +export const GAME_HUB_SLUGS = ["game-servers", "games"] +export const VPS_HUB_SLUGS = ["vps-hosting", "vps"] +export const DEDICATED_HUB_SLUGS = ["dedicated-servers", "dedicated"] diff --git a/packages/core/constants/game/gmod.ts b/packages/core/constants/game/gmod.ts index 82f92d4..2b4c529 100644 --- a/packages/core/constants/game/gmod.ts +++ b/packages/core/constants/game/gmod.ts @@ -1,8 +1,3 @@ -import type { GamePlanSpec } from "@/packages/core/types/servers/game"; - -/** Garry's Mod hosting is coming soon — no plans yet. */ -export const GMOD_PLANS: GamePlanSpec[] = [] - /** Static features shared across all Garry's Mod plans (for future use). */ export const GMOD_PLAN_STATIC_FEATURES = [ "AMD Ryzen™ 9 5900X", diff --git a/packages/core/constants/game/hytale.ts b/packages/core/constants/game/hytale.ts index f5e0e95..9b8197c 100644 --- a/packages/core/constants/game/hytale.ts +++ b/packages/core/constants/game/hytale.ts @@ -1,31 +1,3 @@ -import type { GamePlanSpec } from "@/packages/core/types/servers/game"; - -/** Hytale is not yet released — plans are placeholder/early-access pricing. */ -export const HYTALE_PLANS: GamePlanSpec[] = [ - { - id: "starter", - priceGBP: 5, - ramGB: 4, - storageGB: 40, - url: "https://billing.nodebyte.host/products/hytale-hosting/hytale-starter", - }, - { - id: "standard", - priceGBP: 7.5, - ramGB: 6, - storageGB: 60, - url: "https://billing.nodebyte.host/products/hytale-hosting/hytale-standard", - }, - { - id: "performance", - priceGBP: 10, - ramGB: 8, - storageGB: 80, - popular: true, - url: "https://billing.nodebyte.host/products/hytale-hosting/hytale-performance", - }, -] - /** * Hytale doesn't have translation keys yet — names/descriptions are stored * here directly instead of being keyed through i18n. diff --git a/packages/core/constants/game/index.ts b/packages/core/constants/game/index.ts index 796df27..b707dc6 100644 --- a/packages/core/constants/game/index.ts +++ b/packages/core/constants/game/index.ts @@ -4,81 +4,3 @@ export * from "./hytale" export * from "./terraria" export * from "./gmod" export * from "./palworld" - -import { MINECRAFT_PLANS } from "./minecraft" -import { RUST_PLANS } from "./rust" -import { HYTALE_PLANS } from "./hytale" -import { TERRARIA_PLANS } from "./terraria" -import { GMOD_PLANS } from "./gmod" -import { PALWORLD_PLANS } from "./palworld" - -/** - * Metadata for each game offering — used by the /games index page. - * Mirrors VPS_OPTIONS in packages/core/constants/vps/index.ts. - */ -export const GAME_OPTIONS = [ - { - slug: "minecraft" as const, - name: "Minecraft", - startingPriceGBP: Math.min(...MINECRAFT_PLANS.map((p) => p.priceGBP)), - banner: "/games/minecraft.png", - iconName: "Blocks" as const, - tagColor: "bg-primary text-primary-foreground", - headerGradient: "from-primary/20 via-primary/10 to-accent/5", - headerIconBg: "bg-primary/10 text-primary", - }, - { - slug: "rust" as const, - name: "Rust", - startingPriceGBP: Math.min(...RUST_PLANS.map((p) => p.priceGBP)), - banner: "/games/rust.png", - iconName: "Gamepad2" as const, - tagColor: "bg-accent text-accent-foreground", - headerGradient: "from-accent/20 via-accent/10 to-primary/5", - headerIconBg: "bg-accent/10 text-accent", - }, - { - slug: "hytale" as const, - name: "Hytale", - startingPriceGBP: HYTALE_PLANS.length ? Math.min(...HYTALE_PLANS.map((p) => p.priceGBP)) : 0, - comingSoon: !HYTALE_PLANS.length, - banner: "/games/hytale.png", - iconName: "Sparkles" as const, - tagColor: "bg-amber-500/15 text-amber-400 border border-amber-500/20", - headerGradient: "from-amber-500/20 via-amber-500/10 to-primary/5", - headerIconBg: "bg-amber-500/10 text-amber-400", - }, - { - slug: "terraria" as const, - name: "Terraria", - startingPriceGBP: TERRARIA_PLANS.length ? Math.min(...TERRARIA_PLANS.map((p) => p.priceGBP)) : 0, - comingSoon: !TERRARIA_PLANS.length, - banner: "/games/terraria.png", - iconName: "Pickaxe" as const, - tagColor: "bg-lime-500/15 text-lime-400 border border-lime-500/20", - headerGradient: "from-lime-500/20 via-lime-500/10 to-primary/5", - headerIconBg: "bg-lime-500/10 text-lime-400", - }, - { - slug: "gmod" as const, - name: "Garry's Mod", - startingPriceGBP: GMOD_PLANS.length ? Math.min(...GMOD_PLANS.map((p) => p.priceGBP)) : 0, - comingSoon: !GMOD_PLANS.length, - banner: "/games/gmod.png", - iconName: "Wrench" as const, - tagColor: "bg-orange-500/15 text-orange-400 border border-orange-500/20", - headerGradient: "from-orange-500/20 via-orange-500/10 to-primary/5", - headerIconBg: "bg-orange-500/10 text-orange-400", - }, - { - slug: "palworld" as const, - name: "Palworld", - startingPriceGBP: PALWORLD_PLANS.length ? Math.min(...PALWORLD_PLANS.map((p) => p.priceGBP)) : 0, - comingSoon: !PALWORLD_PLANS.length, - banner: "/games/palworld.png", - iconName: "Leaf" as const, - tagColor: "bg-green-500/15 text-green-400 border border-green-500/20", - headerGradient: "from-green-500/20 via-green-500/10 to-primary/5", - headerIconBg: "bg-green-500/10 text-green-400", - }, -] diff --git a/packages/core/constants/game/minecraft.ts b/packages/core/constants/game/minecraft.ts index aa5aae4..703706f 100644 --- a/packages/core/constants/game/minecraft.ts +++ b/packages/core/constants/game/minecraft.ts @@ -1,86 +1,95 @@ -import { GamePlanSpec } from "@/packages/core/types/servers/game"; - /** - * MINECRAFT PLAN LIST - * @type {GamePlanSpec} The Gameplan Typing Spec + * Minecraft doesn't need translation keys — plan/feature/FAQ copy is stored + * here directly, same pattern as Hytale/Terraria/Gmod/Palworld. Plan-level + * content always prefers the live billing panel data first; this is only a + * fallback for the 5 originally-curated plan ids. */ -export const MINECRAFT_PLANS: GamePlanSpec[] = [ +export const MINECRAFT_PLAN_DISPLAY = { + ember: { name: "Ember", description: "Perfect for small servers and testing" }, + blaze: { name: "Blaze", description: "Great for growing communities" }, + inferno: { name: "Inferno", description: "Ideal for medium sized communities" }, + firestorm: { name: "Firestorm", description: "Built for large and active communities" }, + supernova: { name: "Supernova", description: "Maximum power for massive servers" }, +} as const satisfies Record + +/** Static features shared across all Minecraft plans; RAM/storage are appended parametrically per plan. */ +export const MINECRAFT_PLAN_STATIC_FEATURES = [ + "High Performance CPU", + "10 MySQL Databases", + "DDoS Protection", + "BytePanel", + "Auto/Pre Installed Jars", + "99.6% Uptime SLA", +] as const + +export const MINECRAFT_FEATURES = [ { - id: "ember", - priceGBP: 4, - ramGB: 4, - storageGB: 40, - url: "https://billing.nodebyte.host/products/minecraft-server-hosting/ember", + title: "One-Click Mod Loaders", + description: "Install Forge, Fabric, Paper, Spigot, and more with a single click from our control panel.", + icon: "Settings" as const, + highlights: ["Forge & Fabric support", "Paper & Spigot servers", "Bukkit compatibility", "Custom JAR uploads"], }, { - id: "blaze", - priceGBP: 6, - ramGB: 6, - storageGB: 60, - url: "https://billing.nodebyte.host/products/minecraft-server-hosting/blaze", + title: "High Performance Hardware", + description: "Enterprise grade processors with NVMe storage for blazing fast performance.", + icon: "Cpu" as const, + highlights: ["High performance CPUs", "NVMe SSD storage", "DDR4 ECC memory", "High clock speed processors"], }, { - id: "inferno", - priceGBP: 7.5, - ramGB: 8, - storageGB: 80, - popular: true, - url: "https://billing.nodebyte.host/products/minecraft-server-hosting/inferno", + title: "DDoS Protection", + description: "Enterprise grade DDoS mitigation keeps your server online even during the largest attacks.", + icon: "Shield" as const, + highlights: ["Layer 3/4/7 protection", "Enterprise network filtering", "Zero downtime mitigation", "Enterprise POPs"], }, { - id: "firestorm", - priceGBP: 15, - ramGB: 16, - storageGB: 160, - popular: true, - url: "https://billing.nodebyte.host/products/minecraft-server-hosting/firestorm", + title: "Instant Setup", + description: "Your server is deployed within seconds. Start playing immediately after purchase.", + icon: "Zap" as const, + highlights: ["Automated provisioning", "Pre configured settings", "Ready in under 60 seconds", "No technical knowledge needed"], }, { - id: "supernova", - priceGBP: 30, - ramGB: 32, - storageGB: 320, - popular: true, - url: "https://billing.nodebyte.host/products/minecraft-server-hosting/supernova", + title: "Full FTP Access", + description: "Complete file access via FTP/SFTP. Upload worlds, plugins, and configurations with ease.", + icon: "HardDrive" as const, + highlights: ["SFTP file access", "Web based file manager", "Drag & drop uploads", "Automatic backups"], + }, + { + title: "Unlimited Slots", + description: "No artificial player limits. Host as many players as your hardware can handle.", + icon: "Users" as const, + highlights: ["No slot restrictions", "Scalable resources", "Upgrade anytime", "Fair resource allocation"], }, -] - -/** - * Plan feature keys — maps to `games.minecraft.planFeatures.` in translations. - * "ram" and "storage" are parametric (use plan.ramGB / plan.storageGB). - */ -export const MINECRAFT_PLAN_FEATURE_KEYS = [ - "cpu", - "ram", - "storage", - "databases", - "ddos", - "panel", - "jars", - "uptime", -] as const - -export type MinecraftPlanFeatureKey = (typeof MINECRAFT_PLAN_FEATURE_KEYS)[number] - -/** Feature section keys — maps to `games.minecraft.pageFeatures..*` in translations. */ -export const MINECRAFT_FEATURE_KEYS = [ - { key: "modLoaders", icon: "Settings" as const }, - { key: "hardware", icon: "Cpu" as const }, - { key: "ddos", icon: "Shield" as const }, - { key: "instant", icon: "Zap" as const }, - { key: "ftp", icon: "HardDrive" as const }, - { key: "slots", icon: "Users" as const }, ] as const -/** FAQ keys — maps to `games.minecraft.faqs..{question,answer}` in translations. */ -export const MINECRAFT_FAQ_KEYS = [ - "versions", - "mods", - "upload", - "playerLimit", - "upgrade", - "refunds", - "location", +export const MINECRAFT_FAQS = [ + { + question: "What Minecraft versions do you support?", + answer: "We support all Minecraft versions from 1.7.10 to the latest release, including snapshots. Both Java Edition and Bedrock Edition servers are available.", + }, + { + question: "Can I install mods and plugins?", + answer: "Yes! We support all major mod loaders including Forge, Fabric, and NeoForge. For plugins, we support Paper, Spigot, Bukkit, and Purpur. You can also upload custom JARs.", + }, + { + question: "How do I upload my existing world?", + answer: "You can upload your world files via our web based file manager or through SFTP. Simply drag and drop your world folder and it will be ready to use.", + }, + { + question: "Is there a player limit?", + answer: "No, we don't impose artificial player limits. Your server can host as many players as your allocated resources can handle.", + }, + { + question: "Can I upgrade my plan later?", + answer: "Absolutely! You can upgrade or downgrade your plan at any time from our billing panel. Changes take effect immediately with no downtime.", + }, + { + question: "Do you offer refunds?", + answer: "Yes, we offer a 48 hour money back guarantee on all new purchases. If you're not satisfied, contact support for a full refund.", + }, + { + question: "Can I choose my server location?", + answer: "Yes! You can select your preferred data centre location at checkout. We offer multiple locations to ensure the best latency for you and your players.", + }, ] as const /** Static hero feature pills. */ @@ -97,6 +106,7 @@ export const MINECRAFT_CONFIG = { description: "High performance Minecraft server hosting with instant setup, one click mod loaders, and enterprise grade DDoS protection. Java & Bedrock support.", banner: "/games/minecraft.png", + tag: "Most Popular", /** String name matching a lucide-react icon — instantiate in the page component */ iconName: "Blocks", tagColor: "bg-primary/10 border border-primary/20 text-primary", diff --git a/packages/core/constants/game/palworld.ts b/packages/core/constants/game/palworld.ts index e232222..82addbd 100644 --- a/packages/core/constants/game/palworld.ts +++ b/packages/core/constants/game/palworld.ts @@ -1,8 +1,3 @@ -import type { GamePlanSpec } from "@/packages/core/types/servers/game"; - -/** Palworld hosting is coming soon — no plans yet. */ -export const PALWORLD_PLANS: GamePlanSpec[] = [] - /** Static features shared across all Palworld plans (for future use). */ export const PALWORLD_PLAN_STATIC_FEATURES = [ "AMD Ryzen™ 9 5900X", diff --git a/packages/core/constants/game/rust.ts b/packages/core/constants/game/rust.ts index 3fe8779..c4698df 100644 --- a/packages/core/constants/game/rust.ts +++ b/packages/core/constants/game/rust.ts @@ -1,75 +1,96 @@ -import type { GamePlanSpec } from "@/packages/core/types/servers/game"; +/** + * Rust doesn't need translation keys — plan/feature/FAQ copy is stored here + * directly, same pattern as Hytale/Terraria/Gmod/Palworld. Plan-level content + * always prefers the live billing panel data first; this is only a fallback + * for the 4 originally-curated plan ids. + */ +export const RUST_PLAN_DISPLAY = { + starter: { name: "Starter", description: "Recommended for 40 Players" }, + standard: { name: "Standard", description: "Recommended for 75 Players" }, + performance: { name: "Performance", description: "Recommended for 100 Players" }, + premium: { name: "Premium", description: "Recommended for 150+ Players" }, +} as const satisfies Record + +/** Static features shared across all Rust plans; RAM/storage are appended parametrically per plan. */ +export const RUST_PLAN_STATIC_FEATURES = [ + "High Performance CPU", + "DDoS Protection", + "Multiple Locations", + "10 MySQL Databases", + "BytePanel (GSM)", + "Oxide/Umod Supported", + "Rust+ Supported", + "99.6% Uptime SLA", +] as const -export const RUST_PLANS: GamePlanSpec[] = [ +export const RUST_FEATURES = [ { - id: "starter", - priceGBP: 5.75, - ramGB: 8, - storageGB: 150, - url: "https://billing.nodebyte.host/products/rust-hosting/starter" + title: "Oxide/uMod Support", + description: "Full support for Oxide and uMod plugins. Install and manage plugins directly from our control panel.", + icon: "Settings" as const, + highlights: ["One-click Oxide install", "Plugin manager", "Auto updates available", "Permission management"], }, { - id: "standard", - priceGBP: 8.95, - ramGB: 12, - storageGB: 200, - popular: true, - url: "https://billing.nodebyte.host/products/rust-hosting/standard" + title: "Custom Maps", + description: "Use procedurally generated maps or upload your own custom maps. Full map customization support.", + icon: "Map" as const, + highlights: ["Procedural generation", "Custom map uploads", "Map size control", "Seed customization"], }, { - id: "performance", - priceGBP: 12.75, - ramGB: 16, - storageGB: 250, - url: "https://billing.nodebyte.host/products/rust-hosting/performance" + title: "High Performance", + description: "Rust demands powerful hardware. Our servers use high performance CPUs and NVMe storage for a smooth experience.", + icon: "Cpu" as const, + highlights: ["High performance CPUs", "NVMe SSD storage", "High single thread performance", "Low-latency networking"], }, { - id: "premium", - priceGBP: 18.99, - ramGB: 32, - storageGB: 350, - url: "https://billing.nodebyte.host/products/rust-hosting/premium" + title: "DDoS Protection", + description: "Enterprise grade DDoS mitigation through multiple network POPs protects your server from attacks 24/7.", + icon: "Shield" as const, + highlights: ["Enterprise network filtering", "Global POPs", "Layer 3/4/7 protection", "Zero downtime"], + }, + { + title: "Wipe Scheduler", + description: "Automated wipe scheduling to keep your server fresh. Configure weekly, bi-weekly, or monthly wipes.", + icon: "Zap" as const, + highlights: ["Automated wipes", "Blueprint wipe options", "Map wipe scheduling", "Discord notifications"], + }, + { + title: "Full RCON Access", + description: "Complete remote console access for server management. Execute commands from anywhere.", + icon: "Server" as const, + highlights: ["Web-based RCON", "Command scheduling", "Player management", "Real-time logs"], }, -] - -/** - * Plan feature keys — maps to `games.rust.planFeatures.` in translations. - * "ram" and "storage" are parametric (use plan.ramGB / plan.storageGB). - */ -export const RUST_PLAN_FEATURE_KEYS = [ - "cpu", - "ram", - "storage", - "ddos", - "location", - "databases", - "panel", - "oxide", - "rustplus", - "uptime", -] as const - -export type RustPlanFeatureKey = (typeof RUST_PLAN_FEATURE_KEYS)[number] - -/** Feature section keys — maps to `games.rust.pageFeatures..*` in translations. */ -export const RUST_FEATURE_KEYS = [ - { key: "oxide", icon: "Settings" as const }, - { key: "maps", icon: "Map" as const }, - { key: "performance", icon: "Cpu" as const }, - { key: "ddos", icon: "Shield" as const }, - { key: "wipe", icon: "Zap" as const }, - { key: "rcon", icon: "Server" as const }, ] as const -/** FAQ keys — maps to `games.rust.faqs..{question,answer}` in translations. */ -export const RUST_FAQ_KEYS = [ - "oxide", - "maps", - "wipe", - "tickRate", - "rcon", - "modded", - "location", +export const RUST_FAQS = [ + { + question: "Do you support Oxide/uMod plugins?", + answer: "Yes! We fully support Oxide and uMod. You can install Oxide with one click from our control panel and manage plugins through our plugin manager or via FTP.", + }, + { + question: "Can I use custom maps?", + answer: "Absolutely! You can use procedurally generated maps with custom seeds and sizes, or upload your own custom map files via FTP.", + }, + { + question: "How does the wipe scheduler work?", + answer: "Our wipe scheduler lets you automate server wipes on a schedule you choose. You can configure map-only wipes or full blueprint wipes, and optionally send Discord notifications.", + }, + { + question: "What's the server tick rate?", + answer: "Our Rust servers run at the default 30 tick rate. Our high-performance hardware ensures consistent performance even with many players online.", + }, + { + question: "Can I access RCON?", + answer: "Yes, you get full RCON access. You can use our web-based RCON console or connect with any standard RCON client.", + }, + { + question: "Do you support modded servers?", + answer: "Yes, we support both vanilla and modded Rust servers. Install Oxide and add any plugins you need to create your perfect modded experience.", + }, + { + question: "Can I choose my server location?", + answer: "Yes! You can select your preferred data centre location at checkout. We offer multiple locations to ensure the best latency for you and your players.", + }, ] as const /** Static hero feature pills. */ @@ -86,6 +107,7 @@ export const RUST_CONFIG = { description: "High-performance Rust server hosting with Oxide/uMod support, custom maps, wipe scheduling, and enterprise-grade DDoS protection.", banner: "/games/rust.png", + tag: "High Performance", /** String name matching a lucide-react icon — instantiate in the page component */ iconName: "Gamepad2", tagColor: "bg-accent/10 border border-accent/20 text-accent", diff --git a/packages/core/constants/game/terraria.ts b/packages/core/constants/game/terraria.ts index 9c89bfc..f01ead4 100644 --- a/packages/core/constants/game/terraria.ts +++ b/packages/core/constants/game/terraria.ts @@ -1,8 +1,3 @@ -import type { GamePlanSpec } from "@/packages/core/types/servers/game"; - -/** Terraria hosting is coming soon — no plans yet. */ -export const TERRARIA_PLANS: GamePlanSpec[] = [] - /** Static features shared across all Terraria plans (for future use). */ export const TERRARIA_PLAN_STATIC_FEATURES = [ "AMD Ryzen™ 9 5900X", diff --git a/packages/core/constants/services.ts b/packages/core/constants/services.ts index c993694..a4a7f67 100644 --- a/packages/core/constants/services.ts +++ b/packages/core/constants/services.ts @@ -89,23 +89,4 @@ export const SERVICE_CATEGORIES: ServiceCategory[] = [ ], enabled: true, }, - { - id: "dedicated", - name: "Dedicated Servers", - description: - "Physical bare-metal servers with fully dedicated CPU cores, enterprise storage, and IPMI out-of-band access. Zero resource contention and maximum raw performance.", - href: "/dedicated", - icon: Cpu, - gradient: "from-amber-600/25 via-amber-500/8 to-transparent", - iconColor: "text-amber-400", - accentBorder: "hover:border-amber-400/40", - startingPriceGBP: 50, - highlights: [ - "100% dedicated CPU cores", - "IPMI out-of-band access", - "Enterprise storage", - "Enterprise DDoS protection", - ], - enabled: true, - }, ] diff --git a/packages/core/constants/status-mapping.ts b/packages/core/constants/status-mapping.ts index b902edb..7a55c09 100644 --- a/packages/core/constants/status-mapping.ts +++ b/packages/core/constants/status-mapping.ts @@ -8,6 +8,7 @@ /** Website node `name` (STATIC_NODES) → status.nodebyte.host monitor `name`. */ export const NODE_MONITOR_MAP: Record = { "NEWC-GAME1": "NEWC-GAME1", + "NEWY-GAME1": "NEWY-GAME1", "HEL-VPS1": "HEL-VPS1", } @@ -23,7 +24,8 @@ export const LOCATION_MONITOR_MAP: Record = { fra: "Frankfurt, DE", hel: "Helsinki, FI", tor: "Toronto, ON", - vhv: "Ashburn, VA", // Vint Hill, VA is in the same Northern Virginia / DC-metro area + vhv: "Ashburn, VA", + newy: "New York, USA", sgp: "Singapore, Singapore", syd: "Sydney, Australia", mum: "Mumbai, India", diff --git a/packages/core/lib/bytepay.ts b/packages/core/lib/bytepay.ts index a2ffee1..7a60723 100644 --- a/packages/core/lib/bytepay.ts +++ b/packages/core/lib/bytepay.ts @@ -126,9 +126,11 @@ function nameToSlug(name: string): string { .replace(/^-|-$/g, "") } -interface CategoryInfo { +export interface CategoryInfo { id: string name: string + slug: string + description: string | null parentId: string | null } @@ -155,9 +157,12 @@ async function fetchAllCategories(): Promise { const json: JsonApiResponse = await res.json() for (const cat of json.data) { + const name = (cat.attributes.name as string) ?? "" all.push({ id: cat.id, - name: (cat.attributes.name as string) ?? "", + name, + slug: nameToSlug(name), + description: (cat.attributes.description as string | null) ?? null, parentId: cat.attributes.parent_id != null ? String(cat.attributes.parent_id) : null, }) } @@ -173,6 +178,56 @@ const getCachedCategories = unstable_cache(fetchAllCategories, ["billing-categor revalidate: 600, }) +export interface CategoryHub { + id: string + name: string + slug: string + description: string | null + children: CategoryInfo[] +} + +/** + * Group the flat category list into hubs (top-level categories with no + * parent) and their direct children (the leaf categories products actually + * belong to). Powers live discovery of "which games/VPS lines/dedicated + * tiers exist" instead of hardcoding them per page. + */ +async function fetchCategoryTree(): Promise { + const categories = await getCachedCategories() + const byParent = new Map() + + for (const cat of categories) { + if (!cat.parentId) continue + if (!byParent.has(cat.parentId)) byParent.set(cat.parentId, []) + byParent.get(cat.parentId)!.push(cat) + } + + return categories + .filter((cat) => !cat.parentId) + .map((hub) => ({ + id: hub.id, + name: hub.name, + slug: hub.slug, + description: hub.description, + children: byParent.get(hub.id) ?? [], + })) +} + +export const getCachedCategoryTree = unstable_cache(fetchCategoryTree, ["billing-category-tree"], { + revalidate: 600, +}) + +/** + * Find a hub by its slugified name — accepts one or more acceptable aliases + * (e.g. "vps-hosting" or "vps") since the exact parent category name is + * whatever's configured in Paymenter. + */ +export async function getCategoryHub(hubSlugOrAliases: string | string[]): Promise { + const aliases = Array.isArray(hubSlugOrAliases) ? hubSlugOrAliases : [hubSlugOrAliases] + const tree = await getCachedCategoryTree() + return tree.find((hub) => aliases.includes(hub.slug)) ?? null +} + /** * Build category id → slug from the authoritative category list, warning on * any two categories whose names slugify to the same value (since site pages @@ -184,7 +239,7 @@ function buildCategorySlugMap(categories: CategoryInfo[]): Map { const ownerOfSlug = new Map() for (const cat of categories) { - const slug = nameToSlug(cat.name) + const slug = cat.slug slugMap.set(cat.id, slug) const existingOwner = ownerOfSlug.get(slug) diff --git a/packages/core/lib/spec-parser.ts b/packages/core/lib/spec-parser.ts index 367f0d5..8fd7928 100644 --- a/packages/core/lib/spec-parser.ts +++ b/packages/core/lib/spec-parser.ts @@ -14,9 +14,13 @@ export interface ParsedSpecs { cpu?: number ramGB?: number + /** RAM generation if the description names one, e.g. "DDR3"/"DDR4"/"DDR5" — omitted when unspecified. */ + ramType?: string storageGB?: number /** Raw storage label extracted from the description, e.g. "2 × 1 TB NVMe SSD (RAID 1)" */ storageDescription?: string + /** Which storage keyword the description actually used — "generic" when it only said e.g. "40 GB Storage Array" with no drive type. */ + storageType?: "nvme" | "ssd" | "hdd" | "generic" bandwidth?: { amount: number; unit: "MB" | "GB" | "TB" } | null uplink?: { amount: number; unit: "Mbps" | "Gbps" } cpuModel?: string @@ -66,11 +70,19 @@ export function parseDescriptionSpecs(html: string | null): ParsedSpecs { if (new RegExp(`\\b${name}[- ]?[Cc]ore\\b`, 'i').test(text)) { cpu = count; break } } } + if (!cpu) { + // "2 vCPU", "4 vCPUs" — common cloud/VPS-style core count phrasing + const m = text.match(/\b(\d+)\s*vCPUs?\b/i) + if (m) cpu = parseInt(m[1]) + } // ── RAM ──────────────────────────────────────────────────────────────────── // "2 GB DDR4 RAM", "4 GB ECC RAM", "8GB DDR4 RAM", "1 GB RAM" - const ramMatch = text.match(/(\d+)\s*GB\s+(?:\w+\s+)*?RAM\b/i) + // "4 GB High-Speed DDR4 RAM" — hyphenated adjectives allowed between the size and "RAM" + const ramMatch = text.match(/(\d+)\s*GB\s+(?:[\w-]+\s+)*?RAM\b/i) const ramGB = ramMatch ? parseInt(ramMatch[1]) : undefined + const ramTypeMatch = ramMatch ? ramMatch[0].match(/DDR\s?([345])/i) : null + const ramType = ramTypeMatch ? `DDR${ramTypeMatch[1]}` : undefined // ── Storage ──────────────────────────────────────────────────────────────── // "25 GB SSD", "40 GB NVMe SSD", "100 GB SSD Storage", "40GB Disk Storage" @@ -86,6 +98,17 @@ export function parseDescriptionSpecs(html: string | null): ParsedSpecs { ? parseInt(storageMatchTB[1]) * 1024 : undefined + const storageMatchText = storageMatchGB?.[0] ?? storageMatchTB?.[0] + const storageType: ParsedSpecs["storageType"] = storageMatchText + ? /nvme/i.test(storageMatchText) + ? "nvme" + : /ssd/i.test(storageMatchText) + ? "ssd" + : /hdd/i.test(storageMatchText) + ? "hdd" + : "generic" + : undefined + // Raw storage label for multi-drive dedicated configs let storageDescription: string | undefined for (const line of lines) { @@ -145,7 +168,17 @@ export function parseDescriptionSpecs(html: string | null): ParsedSpecs { if (m) { description = m[1].trim(); break } } - return { cpu, ramGB, storageGB, storageDescription, bandwidth, uplink, cpuModel, hardware, description } + return { cpu, ramGB, ramType, storageGB, storageDescription, storageType, bandwidth, uplink, cpuModel, hardware, description } +} + +/** Human-friendly storage type label — falls back to "Storage Array" when the description didn't name a drive type. */ +export function formatStorageType(type: ParsedSpecs["storageType"]): string { + switch (type) { + case "nvme": return "NVMe SSD Storage" + case "ssd": return "SSD Storage" + case "hdd": return "HDD Storage" + default: return "Storage Array" + } } /** diff --git a/packages/core/products/billing-service.ts b/packages/core/products/billing-service.ts index 03bfbc8..a6d0064 100644 --- a/packages/core/products/billing-service.ts +++ b/packages/core/products/billing-service.ts @@ -10,7 +10,7 @@ import { getStockStatus, getBillingUrl, } from "@/packages/core/lib/bytepay" -import { parseDescriptionSpecs, parseProductName } from "@/packages/core/lib/spec-parser" +import { parseDescriptionSpecs, parseProductName, formatStorageType } from "@/packages/core/lib/spec-parser" import { POPULAR_SLUGS, DEFAULT_DDOS } from "@/packages/core/constants/product-overrides" import type { BillingProduct } from "@/packages/core/lib/bytepay" @@ -57,8 +57,12 @@ export async function getGamePlans(categorySlug: string): Promise GamePlanDisplay +} + +// ─── Generic fallback (any category with no curated override) ────────────── + +const GENERIC_ICON_ROTATION: GameIconName[] = ["Gamepad2", "Sparkles", "Blocks", "Pickaxe", "Wrench", "Leaf"] +const GENERIC_PALETTES = [ + { tagColor: "bg-primary/10 border border-primary/20 text-primary", headerGradient: "from-primary/20 via-primary/10 to-accent/5", headerIconBg: "bg-primary/10 text-primary" }, + { tagColor: "bg-blue-500/15 text-blue-400 border border-blue-500/20", headerGradient: "from-blue-500/20 via-blue-500/10 to-primary/5", headerIconBg: "bg-blue-500/10 text-blue-400" }, + { tagColor: "bg-violet-500/15 text-violet-400 border border-violet-500/20", headerGradient: "from-violet-500/20 via-violet-500/10 to-primary/5", headerIconBg: "bg-violet-500/10 text-violet-400" }, + { tagColor: "bg-rose-500/15 text-rose-400 border border-rose-500/20", headerGradient: "from-rose-500/20 via-rose-500/10 to-primary/5", headerIconBg: "bg-rose-500/10 text-rose-400" }, +] + +/** Deterministic small hash so the same category always gets the same generic palette/icon. */ +function hashSlug(slug: string): number { + let hash = 0 + for (let i = 0; i < slug.length; i++) hash = (hash * 31 + slug.charCodeAt(i)) >>> 0 + return hash +} + +function formatStorage(gb: number): string { + return gb >= 1024 ? `${gb / 1024} TB` : `${gb} GB` +} + +/** Plan display built straight from live billing data — used as the generic fallback, and for any plan added to a curated category that has no curated entry. */ +function buildGenericPlanDisplay(plan: GamePlanSpec): GamePlanDisplay { + const storageLabel = plan.storageLabel ?? "Storage Array" + const ramLabel = `${plan.ramGB} GB ${plan.ramType ? plan.ramType + " " : ""}RAM` + return { + name: plan.name || plan.id, + description: plan.description || `${formatStorage(plan.storageGB)} ${storageLabel}, ${ramLabel}.`, + features: [ + ramLabel, + `${formatStorage(plan.storageGB)} ${storageLabel}`, + "Enterprise DDoS Protection", + "BytePanel Control Panel", + ], + } +} + +function buildGenericDisplayConfig(category: CategoryInfo): GameDisplayConfig { + const hash = hashSlug(category.slug) + const icon = GENERIC_ICON_ROTATION[hash % GENERIC_ICON_ROTATION.length] + const palette = GENERIC_PALETTES[hash % GENERIC_PALETTES.length] + + return { + name: category.name, + description: + category.description || + `High-performance ${category.name} server hosting with instant setup, enterprise DDoS protection, and 24/7 support.`, + banner: "/games/generic.png", + iconName: icon, + tag: "Game Server", + ...palette, + billingUrl: `${LINKS.billing.root}/products/${category.slug}`, + heroFeatures: ["Instant Setup", "DDoS Protection", "24/7 Support", "Upgrade Anytime"], + pageFeatures: [ + { title: "Instant Setup", description: `Your ${category.name} server deploys automatically the moment your order completes — no waiting on manual provisioning.`, icon: "Zap", highlights: ["Automated deployment", "No setup fees", "Ready in minutes", "Zero manual steps"] }, + { title: "Enterprise Hardware", description: "Servers run on enterprise-grade CPUs with NVMe SSD storage for consistently fast, low-latency performance.", icon: "Cpu", highlights: ["NVMe SSD storage", "High clock speed CPUs", "Low latency networking", "DDR4 ECC memory"] }, + { title: "DDoS Protection", description: "Enterprise-grade DDoS mitigation is included on every plan, keeping your server online during attacks.", icon: "Shield", highlights: ["Always-on protection", "Layer 3/4/7 filtering", "Zero downtime", "Global POPs"] }, + { title: "24/7 Support", description: "Our support team is available around the clock to help with setup, configuration, or troubleshooting.", icon: "Server", highlights: ["24/7 availability", "Knowledgeable staff", "Fast response times", "Discord & ticket support"] }, + ], + faqs: [ + { question: "How quickly will my server be online?", answer: "Your server is provisioned automatically as soon as your order completes — usually within a couple of minutes." }, + { question: "Can I upgrade my plan later?", answer: "Yes, you can upgrade or downgrade your plan at any time from the billing panel." }, + { question: "Is DDoS protection included?", answer: "Yes, enterprise-grade DDoS protection is included on every plan at no extra cost." }, + ], + resolvePlanDisplay: buildGenericPlanDisplay, + } +} + +// ─── Curated overrides ─────────────────────────────────────────────────────── + +/** Build a resolvePlanDisplay for a curated game — curated copy for known plan ids, live data for anything else. */ +function curatedPlanDisplay( + display: Record, + staticFeatures: readonly string[], +): (plan: GamePlanSpec) => GamePlanDisplay { + return (plan) => { + const entry = display[plan.id] + if (!entry) return buildGenericPlanDisplay(plan) + return { + name: entry.name, + description: entry.description, + features: [ + ...staticFeatures, + `${plan.ramGB}GB ${plan.ramType ? plan.ramType + " " : ""}RAM`, + `${plan.storageGB}GB ${plan.storageLabel ?? "Storage Array"}`, + ], + } + } +} + +/** + * Curated display config, keyed by the Paymenter category slug. A category + * without an entry here falls back to buildGenericDisplayConfig() — nothing + * needs to be added here for a new game/category to work. + */ +const GAME_OVERRIDES: Record GameDisplayConfig> = { + minecraft: () => ({ + ...MINECRAFT_CONFIG, + billingUrl: LINKS.billing.minecraftHosting, + heroFeatures: [...MINECRAFT_HERO_FEATURES], + pageFeatures: MINECRAFT_FEATURES.map((f) => ({ ...f, highlights: [...f.highlights] })), + faqs: [...MINECRAFT_FAQS], + resolvePlanDisplay: curatedPlanDisplay(MINECRAFT_PLAN_DISPLAY, MINECRAFT_PLAN_STATIC_FEATURES), + }), + rust: () => ({ + ...RUST_CONFIG, + billingUrl: LINKS.billing.rustHosting, + heroFeatures: [...RUST_HERO_FEATURES], + pageFeatures: RUST_FEATURES.map((f) => ({ ...f, highlights: [...f.highlights] })), + faqs: [...RUST_FAQS], + resolvePlanDisplay: curatedPlanDisplay(RUST_PLAN_DISPLAY, RUST_PLAN_STATIC_FEATURES), + }), + hytale: () => ({ + ...HYTALE_CONFIG, + billingUrl: LINKS.billing.hytaleHosting, + heroFeatures: [...HYTALE_HERO_FEATURES], + pageFeatures: HYTALE_FEATURES.map((f) => ({ ...f, highlights: [...f.highlights] })), + faqs: [...HYTALE_FAQS], + resolvePlanDisplay: curatedPlanDisplay(HYTALE_PLAN_DISPLAY, HYTALE_PLAN_STATIC_FEATURES), + }), + terraria: () => ({ + ...TERRARIA_CONFIG, + billingUrl: LINKS.billing.terrariaHosting, + heroFeatures: [...TERRARIA_HERO_FEATURES], + pageFeatures: TERRARIA_FEATURES.map((f) => ({ ...f, highlights: [...f.highlights] })), + faqs: [...TERRARIA_FAQS], + resolvePlanDisplay: buildGenericPlanDisplay, + }), + gmod: () => ({ + ...GMOD_CONFIG, + billingUrl: LINKS.billing.gmodHosting, + heroFeatures: [...GMOD_HERO_FEATURES], + pageFeatures: GMOD_FEATURES.map((f) => ({ ...f, highlights: [...f.highlights] })), + faqs: [...GMOD_FAQS], + resolvePlanDisplay: buildGenericPlanDisplay, + }), + palworld: () => ({ + ...PALWORLD_CONFIG, + billingUrl: LINKS.billing.palworldHosting, + heroFeatures: [...PALWORLD_HERO_FEATURES], + pageFeatures: PALWORLD_FEATURES.map((f) => ({ ...f, highlights: [...f.highlights] })), + faqs: [...PALWORLD_FAQS], + resolvePlanDisplay: buildGenericPlanDisplay, + }), +} + +/** Resolve the display config for a game category — curated override if one exists, else auto-generated. */ +export function resolveGameDisplayConfig(category: CategoryInfo): GameDisplayConfig { + const override = GAME_OVERRIDES[category.slug] + return override ? override() : buildGenericDisplayConfig(category) +} diff --git a/packages/core/products/index.ts b/packages/core/products/index.ts deleted file mode 100644 index 94a3ab3..0000000 --- a/packages/core/products/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export type { ProductEntry, ProductType, StockStatus } from "./types" -export { - getAllProducts, - getProductsByType, - getProductsByCategory, - isCategoryOutOfStock, - getCategoryStartingPrice, -} from "./service" diff --git a/packages/core/products/override-store.ts b/packages/core/products/override-store.ts deleted file mode 100644 index f80fb4d..0000000 --- a/packages/core/products/override-store.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { StockStatus } from "./types" - -export interface ProductOverride { - stock: StockStatus - enabled: boolean -} - -/** - * Module-level store — persists across requests within the same server process. - * Resets on server restart. Replace with DB/Redis when a real backend is available. - */ -const store = new Map() - -export function getOverride(id: string): ProductOverride | undefined { - return store.get(id) -} - -export function getAllOverrides(): Record { - return Object.fromEntries(store.entries()) -} - -export function setOverride(id: string, data: ProductOverride): void { - store.set(id, data) -} diff --git a/packages/core/products/server.ts b/packages/core/products/server.ts deleted file mode 100644 index 54f6764..0000000 --- a/packages/core/products/server.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { GamePlanSpec } from "@/packages/core/types/servers/game" -import type { VpsPlanSpec } from "@/packages/core/types/servers/vps" -import { getOverride } from "./override-store" - -/** - * Returns game plans with admin overrides applied. - * Plans with enabled=false are filtered out entirely (category shows OOS when all removed). - * Plans with stock overridden show their OOS badge on the pricing card. - */ -function applyOverrides( - category: string, - plans: T[], -): T[] { - const result: T[] = [] - for (const plan of plans) { - const ov = getOverride(`${category}-${plan.id}`) - if (ov && !ov.enabled) continue - result.push(ov ? { ...plan, stock: ov.stock } as T : plan) - } - return result -} - -export function applyGamePlanOverrides( - category: string, - plans: GamePlanSpec[], -): GamePlanSpec[] { - return applyOverrides(category, plans) -} - -export function applyVpsPlanOverrides( - category: string, - plans: VpsPlanSpec[], -): VpsPlanSpec[] { - return applyOverrides(category, plans) -} diff --git a/packages/core/products/service.ts b/packages/core/products/service.ts deleted file mode 100644 index eeab69c..0000000 --- a/packages/core/products/service.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { MINECRAFT_PLANS } from "@/packages/core/constants/game/minecraft" -import { RUST_PLANS } from "@/packages/core/constants/game/rust" -import { HYTALE_PLANS } from "@/packages/core/constants/game/hytale" -import { TERRARIA_PLANS } from "@/packages/core/constants/game/terraria" -import { GMOD_PLANS } from "@/packages/core/constants/game/gmod" -import { PALWORLD_PLANS } from "@/packages/core/constants/game/palworld" -import { AMD_PLANS } from "@/packages/core/constants/vps/amd" -import { INTEL_PLANS } from "@/packages/core/constants/vps/intel" -import type { GamePlanSpec } from "@/packages/core/types/servers/game" -import type { VpsPlanSpec } from "@/packages/core/types/servers/vps" -import type { ProductEntry } from "./types" - -// ─── Adapters ──────────────────────────────────────────────────────────────── - -function fromGamePlan(plan: GamePlanSpec, category: string): ProductEntry { - return { - id: `${category}-${plan.id}`, - planId: plan.id, - category, - type: "game", - description: plan.description, - priceGBP: plan.priceGBP, - stock: plan.stock ?? "in_stock", - popular: plan.popular, - billingUrl: plan.url, - location: plan.location, - cpuModel: plan.cpuModel, - ramGB: plan.ramGB, - storageGB: plan.storageGB, - bandwidth: plan.bandwidth, - uplink: plan.uplink, - ddos: plan.ddos, - } -} - -function fromVpsPlan(plan: VpsPlanSpec, category: string): ProductEntry { - return { - id: `${category}-${plan.id}`, - planId: plan.id, - category, - type: "vps", - description: plan.description, - priceGBP: plan.priceGBP, - stock: plan.stock ?? "in_stock", - popular: plan.popular, - billingUrl: plan.url, - location: plan.location, - cpu: plan.cpu, - cpuModel: plan.cpuModel, - ramGB: plan.ramGB, - storageGB: plan.storageGB, - bandwidth: plan.bandwidth, - uplink: plan.uplink, - ddos: plan.ddos, - } -} - -// ─── Public API ─────────────────────────────────────────────────────────────── - -/** - * All products derived from the hardcoded plan constants. - * TODO: replace with an API fetch when the backend product endpoint is ready. - */ -export function getAllProducts(): ProductEntry[] { - return [ - ...MINECRAFT_PLANS.map((p) => fromGamePlan(p, "minecraft")), - ...RUST_PLANS.map((p) => fromGamePlan(p, "rust")), - ...HYTALE_PLANS.map((p) => fromGamePlan(p, "hytale")), - ...TERRARIA_PLANS.map((p) => fromGamePlan(p, "fivem")), - ...GMOD_PLANS.map((p) => fromGamePlan(p, "redm")), - ...PALWORLD_PLANS.map((p) => fromGamePlan(p, "palworld")), - ...AMD_PLANS.map((p) => fromVpsPlan(p, "amd")), - ...INTEL_PLANS.map((p) => fromVpsPlan(p, "intel")), - ] -} - -/** Products filtered by product type ("game" | "vps"). */ -export function getProductsByType(type: "game" | "vps"): ProductEntry[] { - return getAllProducts().filter((p) => p.type === type) -} - -/** Products for a specific category slug e.g. "minecraft", "amd". */ -export function getProductsByCategory(category: string): ProductEntry[] { - return getAllProducts().filter((p) => p.category === category) -} - -/** - * Returns true if the whole category should be shown as out-of-stock on its - * landing page — i.e. the plan list is empty OR every plan is individually OOS. - */ -export function isCategoryOutOfStock(category: string): boolean { - const plans = getProductsByCategory(category) - return plans.length === 0 || plans.every((p) => p.stock === "out_of_stock") -} - -/** - * Lowest in-stock price for a category. - * Returns null when every plan is OOS or the category has no plans. - */ -export function getCategoryStartingPrice(category: string): number | null { - const available = getProductsByCategory(category).filter( - (p) => p.stock === "in_stock", - ) - if (available.length === 0) return null - return Math.min(...available.map((p) => p.priceGBP)) -} diff --git a/packages/core/products/types.ts b/packages/core/products/types.ts deleted file mode 100644 index 910c296..0000000 --- a/packages/core/products/types.ts +++ /dev/null @@ -1,40 +0,0 @@ -export type StockStatus = "in_stock" | "out_of_stock" | "coming_soon" - -export type ProductType = "game" | "vps" - -/** - * Unified read-only view of any product plan. - * Consumed by the admin panel and derived from the hardcoded plan constants. - * Replace `getAllProducts()` with an API call in `service.ts` when the backend is ready. - */ -export interface ProductEntry { - /** Unique slug — format: "{category}-{planId}" e.g. "minecraft-ember", "amd-2GB-R71700X" */ - id: string - /** Technical plan id from the spec constant e.g. "ember", "2GB-R71700X" */ - planId: string - /** Category slug: "minecraft" | "rust" | "hytale" | "amd" | "intel" */ - category: string - /** Product type */ - type: ProductType - /** Optional marketing description */ - description?: string - /** Monthly price in GBP */ - priceGBP: number - /** Availability status */ - stock: StockStatus - /** Highlighted as the recommended / most-popular plan */ - popular?: boolean - /** Direct order URL on the billing portal */ - billingUrl?: string - /** Data-centre location string */ - location?: string - - // ── Specs (optional — not all product types expose all fields) ──────────── - cpu?: number - cpuModel?: string - ramGB?: number - storageGB?: number - bandwidth?: { amount: number; unit: "MB" | "GB" | "TB" } | null - uplink?: { amount: number; unit: "Mbps" | "Gbps" } - ddos?: { layers: number[]; autoOn: boolean } -} diff --git a/packages/core/types/servers/game.ts b/packages/core/types/servers/game.ts index dc99167..961bbca 100644 --- a/packages/core/types/servers/game.ts +++ b/packages/core/types/servers/game.ts @@ -16,11 +16,17 @@ */ export interface GamePlanSpec { id: string + /** Raw product name from the billing panel — used as the display name for auto-generated (non-curated) game pages */ + name?: string description?: string cpuModel?: string priceGBP: number ramGB: number + /** RAM generation if the description names one, e.g. "DDR4" — omitted when unspecified */ + ramType?: string storageGB: number + /** Human-friendly storage type, e.g. "NVMe SSD Storage" or "Storage Array" when no drive type was named */ + storageLabel?: string bandwidth: { amount: number; unit: "MB" | "GB" | "TB" } | null uplink?: { amount: number; unit: "Mbps" | "Gbps" } ddos?: { layers: number[]; autoOn: boolean } diff --git a/packages/ui/components/Layouts/About/about-page.tsx b/packages/ui/components/Layouts/About/about-page.tsx index aad457b..c914b9a 100644 --- a/packages/ui/components/Layouts/About/about-page.tsx +++ b/packages/ui/components/Layouts/About/about-page.tsx @@ -49,7 +49,7 @@ export function AboutPage() { ] const displayStats = [ - { value: "3+", label: t("aboutPage.stats.locations"), icon: Globe }, + { value: "10+", label: t("aboutPage.stats.locations"), icon: Globe }, { value: "Always on", label: t("aboutPage.stats.ddos"), icon: Shield }, { value: "~1 Gbps", label: t("aboutPage.stats.network"), icon: Zap }, { value: "99.9%", label: t("aboutPage.stats.uptime"), icon: Server }, diff --git a/packages/ui/components/Layouts/Dedicated/dedicated-hub.tsx b/packages/ui/components/Layouts/Dedicated/dedicated-hub.tsx index d910624..6a89abd 100644 --- a/packages/ui/components/Layouts/Dedicated/dedicated-hub.tsx +++ b/packages/ui/components/Layouts/Dedicated/dedicated-hub.tsx @@ -13,6 +13,7 @@ import { ArrowRight, Star, Lock, + PackageX, } from "lucide-react" import { Button } from "@/packages/ui/components/ui/button" import { Input } from "@/packages/ui/components/ui/input" @@ -300,7 +301,13 @@ export function DedicatedHub({ plans }: DedicatedHubProps) { {/* ── Plan grid ────────────────────────────────────────────────────── */}
    - {filtered.length === 0 ? ( + {plans.length === 0 ? ( +
    + +

    No dedicated servers in stock right now

    +

    Check back soon, or get in touch for a custom configuration.

    +
    + ) : filtered.length === 0 ? (

    No servers match your filters

    diff --git a/packages/ui/components/Layouts/Games/game-features.tsx b/packages/ui/components/Layouts/Games/game-features.tsx index 9403f36..7646391 100644 --- a/packages/ui/components/Layouts/Games/game-features.tsx +++ b/packages/ui/components/Layouts/Games/game-features.tsx @@ -1,18 +1,19 @@ "use client" import { Card } from "@/packages/ui/components/ui/card" -import { - CheckCircle2, - Sparkles, - Settings, - Cpu, - Shield, - Zap, - HardDrive, - Users, - Server, - Map, - Globe +import { + CheckCircle2, + Sparkles, + Settings, + Cpu, + Shield, + Zap, + HardDrive, + Users, + Server, + Map, + Globe, + Gamepad2, } from "lucide-react" import { cn } from "@/lib/utils" import { useTranslations } from "next-intl" @@ -28,6 +29,7 @@ const iconMap = { Map: Map, Globe: Globe, Sparkles: Sparkles, + Gamepad2: Gamepad2, } interface Feature { diff --git a/packages/ui/components/Layouts/Home/about.tsx b/packages/ui/components/Layouts/Home/about.tsx index 7945bfb..f92aa39 100644 --- a/packages/ui/components/Layouts/Home/about.tsx +++ b/packages/ui/components/Layouts/Home/about.tsx @@ -11,7 +11,7 @@ export function About() { const t = useTranslations() const stats = [ - { value: "3+", label: t("about.stats.locations"), icon: Globe }, + { value: "10+", label: t("about.stats.locations"), icon: Globe }, { value: "Always on", label: t("about.stats.ddos"), icon: Shield }, { value: "~1 Gbps", label: t("about.stats.network"), icon: Zap }, { value: "99.6%", label: t("about.stats.uptime"), icon: Server }, diff --git a/packages/ui/components/Layouts/Home/hero-graphic.tsx b/packages/ui/components/Layouts/Home/hero-graphic.tsx index 23bfa67..c5aa71b 100644 --- a/packages/ui/components/Layouts/Home/hero-graphic.tsx +++ b/packages/ui/components/Layouts/Home/hero-graphic.tsx @@ -278,7 +278,7 @@ export default function HeroGraphic() {
    - Global Network · 3+ Data Center Partners + Global Network · 10+ Data Center Partners
    diff --git a/packages/ui/components/Layouts/Home/services.tsx b/packages/ui/components/Layouts/Home/services.tsx index 0a05278..0705857 100644 --- a/packages/ui/components/Layouts/Home/services.tsx +++ b/packages/ui/components/Layouts/Home/services.tsx @@ -3,14 +3,44 @@ import { Card } from "@/packages/ui/components/ui/card" import { Layers, ArrowRight, Check } from "lucide-react" import Link from "next/link" import { cn } from "@/lib/utils" -import { useTranslations } from "next-intl" +import { getTranslations } from "next-intl/server" import { SERVICE_CATEGORIES } from "@/packages/core/constants/services" import { Price } from "@/packages/ui/components/ui/price" +import { getCategoryHub } from "@/packages/core/lib/bytepay" +import { getGamePlans, getVpsPlans, getDedicatedPlans } from "@/packages/core/products/billing-service" +import { GAME_HUB_SLUGS, VPS_HUB_SLUGS, DEDICATED_HUB_SLUGS } from "@/packages/core/constants/catalog-hubs" -export function Services() { - const t = useTranslations() +/** Live starting price (min across all of a hub's children's plans), falling back to the static config value if a hub has no live pricing yet or the billing panel is unreachable. */ +async function getLiveStartingPrice( + hubSlugs: string[], + getPlans: (categorySlug: string) => Promise<{ priceGBP: number }[]>, +): Promise { + try { + const hub = await getCategoryHub(hubSlugs) + if (!hub) return null + const plansByCategory = await Promise.all(hub.children.map((c) => getPlans(c.slug))) + const prices = plansByCategory.flat().map((p) => p.priceGBP) + return prices.length ? Math.min(...prices) : null + } catch { + return null + } +} + +const HUB_PRICE_RESOLVERS: Record Promise> = { + "game-servers": () => getLiveStartingPrice(GAME_HUB_SLUGS, getGamePlans), + "vps": () => getLiveStartingPrice(VPS_HUB_SLUGS, getVpsPlans), + "dedicated": () => getLiveStartingPrice(DEDICATED_HUB_SLUGS, getDedicatedPlans), +} - const activeServices = SERVICE_CATEGORIES.filter((s) => s.enabled) +export async function Services() { + const t = await getTranslations() + + const activeServices = await Promise.all( + SERVICE_CATEGORIES.filter((s) => s.enabled).map(async (service) => { + const livePrice = await HUB_PRICE_RESOLVERS[service.id]?.() + return { ...service, startingPriceGBP: livePrice ?? service.startingPriceGBP } + }), + ) return (
    diff --git a/packages/ui/components/Layouts/Nodes/nodes-client.tsx b/packages/ui/components/Layouts/Nodes/nodes-client.tsx index c9676f3..e7d8ecf 100644 --- a/packages/ui/components/Layouts/Nodes/nodes-client.tsx +++ b/packages/ui/components/Layouts/Nodes/nodes-client.tsx @@ -48,6 +48,15 @@ const STATIC_NODES: ExtendedNode[] = [ }, { id: 2, + name: "NEWY-GAME1", + locationCode: "New York, USA", + isMaintenanceMode: false, + memory: 65104, + disk: 512000, + uptime: 99.8, + }, + { + id: 3, name: "HEL-VPS1", locationCode: "Helsinki, FI", isMaintenanceMode: false, @@ -97,6 +106,7 @@ const LOCATIONS: DataCentreLocation[] = [ // Americas — United States { id: "hil", city: "Seattle", area: "Hillsboro, OR", country: "United States", flag: "🇺🇸", region: "Americas" }, { id: "vhv", city: "Washington DC", area: "Vint Hill, VA", country: "United States", flag: "🇺🇸", region: "Americas" }, + { id: "newy", city: "New York", area: "Secaucus, NJ", country: "United States", flag: "🇺🇸", region: "Americas" }, // Asia-Pacific { id: "sgp", city: "Singapore", country: "Singapore", flag: "🇸🇬", region: "Asia-Pacific" }, { id: "syd", city: "Sydney", country: "Australia", flag: "🇦🇺", region: "Asia-Pacific" }, @@ -351,7 +361,7 @@ export function NodesClient() { { label: "Total Nodes", value: nodes.length }, { label: "Online", value: onlineCount, color: "text-green-400" }, { label: "In Maintenance", value: maintenanceCount, color: "text-amber-400" }, - { label: "Data Center Partners", value: "3+" }, + { label: "Data Center Partners", value: "10+" }, ].map((stat) => (
    {stat.value}
    diff --git a/packages/ui/components/Layouts/VPS/vps-hub.tsx b/packages/ui/components/Layouts/VPS/vps-hub.tsx index 47c09fe..85610e9 100644 --- a/packages/ui/components/Layouts/VPS/vps-hub.tsx +++ b/packages/ui/components/Layouts/VPS/vps-hub.tsx @@ -13,6 +13,7 @@ import { X, ArrowRight, Star, + PackageX, } from "lucide-react" import { Button } from "@/packages/ui/components/ui/button" import { Badge } from "@/packages/ui/components/ui/badge" @@ -378,7 +379,13 @@ export function VpsHub({ plans }: VpsHubProps) { {/* ── Plan grid ────────────────────────────────────────────────────── */}
    - {filtered.length === 0 ? ( + {plans.length === 0 ? ( +
    + +

    No VPS plans in stock right now

    +

    Check back soon, or get in touch for a custom configuration.

    +
    + ) : filtered.length === 0 ? (

    No plans match your filters

    diff --git a/packages/ui/components/Static/navigation.tsx b/packages/ui/components/Static/navigation.tsx index 524b7f0..439c5bf 100644 --- a/packages/ui/components/Static/navigation.tsx +++ b/packages/ui/components/Static/navigation.tsx @@ -8,7 +8,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/packages/ui/components/ui/dropdown-menu" -import { Server, Gamepad2, Blocks, ExternalLink, ChevronRight, ChevronDown, Book, Mail, Users, Sparkles, Cpu, Network } from "lucide-react" +import { Server, Gamepad2, ExternalLink, ChevronRight, ChevronDown, Book, Mail, Users, Sparkles, Cpu, Network } from "lucide-react" import { ThemeToggle } from "@/packages/ui/components/theme-toggle" import { CurrencySelector } from "@/packages/ui/components/ui/price" import { LanguageSelector } from "@/packages/ui/components/ui/language-selector" @@ -20,7 +20,12 @@ import { useTranslations } from "next-intl" import { LINKS } from "@/packages/core/constants/links" import { SiDiscord } from "react-icons/si" -export function Navigation() { +interface NavigationProps { + /** Live game categories discovered from the billing panel, fetched server-side in app/layout.tsx. */ + gamesNav?: { slug: string; name: string }[] +} + +export function Navigation({ gamesNav = [] }: NavigationProps) { const t = useTranslations() const mountedRef = useRef(false) @@ -53,27 +58,13 @@ export function Navigation() { ] const services = [ - { - title: t("services.minecraft.title"), - href: "/games/minecraft", - description: t("services.minecraft.description"), - icon: Blocks, - section: "game", - }, - { - title: t("services.rust.title"), - href: "/games/rust", - description: t("services.rust.description"), + ...gamesNav.map((game) => ({ + title: game.name, + href: `/games/${game.slug}`, + description: `${game.name} server hosting`, icon: Gamepad2, - section: "game", - }, - { - title: t("services.hytale.title"), - href: "/games/hytale", - description: t("services.hytale.description"), - icon: Gamepad2, - section: "game", - }, + section: "game" as const, + })), { title: t("services.gameServers.title"), href: "/games", diff --git a/packages/ui/components/layout-chrome.tsx b/packages/ui/components/layout-chrome.tsx index 1d18874..8d3e1c3 100644 --- a/packages/ui/components/layout-chrome.tsx +++ b/packages/ui/components/layout-chrome.tsx @@ -5,15 +5,16 @@ import { Footer } from "@/packages/ui/components/Static/footer" interface LayoutChromeProps { children: React.ReactNode + gamesNav?: { slug: string; name: string }[] } /** * Client component that wraps pages with navigation and footer. */ -export function LayoutChrome({ children }: LayoutChromeProps) { +export function LayoutChrome({ children, gamesNav }: LayoutChromeProps) { return ( <> - +
    {children}
    diff --git a/translations b/translations index 8059fbd..01c38f0 160000 --- a/translations +++ b/translations @@ -1 +1 @@ -Subproject commit 8059fbdb3a00abe975a08ddb568de30393f0335f +Subproject commit 01c38f0e9689fa9c0591ac55937e2ff20e1ea8b9