diff --git a/.env.example b/.env.example index e19b392..5f0af76 100644 --- a/.env.example +++ b/.env.example @@ -6,4 +6,9 @@ JWT_SECRET="" # DeepL API key for automated translation (https://www.deepl.com/pro-api) # Free tier: use a key ending in :fx | Paid tier: standard key -DEEPL_API_KEY="" \ No newline at end of file +DEEPL_API_KEY="" + +# status.nodebyte.host public status API — admin token unlocks hidden monitors +# and bypasses the shared CDN cache for fresher data. +STATUS_API_URL="https://status.nodebyte.host" +STATUS_TOKEN="" \ No newline at end of file diff --git a/README.md b/README.md index c5aa232..0a952b2 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ The frontend uses a centralized API client (`packages/core/lib/api.ts`) that rou A small set of lightweight API routes remain in the Next.js app for public-facing proxy endpoints: - `/api/github/releases` -- GitHub release data -- `/api/instatus` -- Status page integration +- `/api/status` -- status.nodebyte.host integration (node/service monitor status, uptime, latency) - `/api/panel/*` -- Public panel data (counts, nodes, servers, stats, users) - `/api/trustpilot` -- Trustpilot review data @@ -142,7 +142,7 @@ A small set of lightweight API routes remain in the Next.js app for public-facin │ │ └── users/ # User management │ ├── api/ # Lightweight proxy routes │ │ ├── github/releases/ # GitHub releases proxy -│ │ ├── instatus/ # Status page proxy +│ │ ├── status/ # status.nodebyte.host proxy │ │ ├── panel/ # Public panel data │ │ └── trustpilot/ # Trustpilot proxy │ ├── auth/ # Authentication pages diff --git a/app/api/billing-debug/route.ts b/app/api/billing-debug/route.ts index 674dcba..b2ddfaf 100644 --- a/app/api/billing-debug/route.ts +++ b/app/api/billing-debug/route.ts @@ -1,4 +1,5 @@ import { fetchAllBillingProducts } from "@/packages/core/lib/bytepay" +import { parseDescriptionSpecs } from "@/packages/core/lib/spec-parser" export const dynamic = "force-dynamic" @@ -27,5 +28,20 @@ export async function GET(request: Request) { description: p.description, })) - return Response.json({ count: summary.length, products: summary }) + // Live products whose description fails to parse cpu/ramGB/storageGB — a + // subset (or all) of these fields are required by getGamePlans/getVpsPlans/ + // getDedicatedPlans, so a missing one here means the product silently + // disappears from its billing page. + const dropped = filtered.flatMap((p) => { + const parsed = parseDescriptionSpecs(p.description) + const missing = [ + !parsed.cpu && "cpu", + !parsed.ramGB && "ramGB", + !parsed.storageGB && "storageGB", + ].filter((v): v is string => Boolean(v)) + if (missing.length === 0) return [] + return [{ id: p.id, name: p.name, slug: p.slug, categorySlug: p.categorySlug, missing }] + }) + + return Response.json({ count: summary.length, products: summary, dropped }) } diff --git a/app/api/instatus/route.ts b/app/api/instatus/route.ts deleted file mode 100644 index 21369c1..0000000 --- a/app/api/instatus/route.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { NextResponse } from "next/server" - -export const revalidate = 60 // Cache for 60 seconds - -interface InstatusPage { - name: string - url: string - status: "UP" | "HASISSUES" | "UNDERMAINTENANCE" -} - -interface InstatusIncident { - id: string - name: string - started: string - status: string - impact: string - url: string - updatedAt: string -} - -interface InstatusMaintenance { - id: string - name: string - start: string - status: string - duration: string - url: string - updatedAt: string -} - -interface InstatusResponse { - page: InstatusPage - activeIncidents: InstatusIncident[] - activeMaintenances: InstatusMaintenance[] -} - -export async function GET() { - try { - const response = await fetch("https://nodebytestat.us/summary.json", { - next: { revalidate: 60 }, - headers: { - "Accept": "application/json", - }, - }) - - if (!response.ok) { - throw new Error(`Instatus API returned ${response.status}`) - } - - const data: InstatusResponse = await response.json() - - // Safely handle potentially undefined arrays - const activeIncidents = data.activeIncidents || [] - const activeMaintenances = data.activeMaintenances || [] - - return NextResponse.json({ - status: data.page?.status || "UP", - url: data.page?.url || "https://nodebytestat.us", - hasIncidents: activeIncidents.length > 0, - hasMaintenance: activeMaintenances.length > 0, - incidents: activeIncidents.map((incident) => ({ - id: incident.id, - name: incident.name, - status: incident.status, - impact: incident.impact, - url: incident.url, - })), - maintenances: activeMaintenances.map((maintenance) => ({ - id: maintenance.id, - name: maintenance.name, - status: maintenance.status, - url: maintenance.url, - })), - }) - } catch (error) { - console.error("Failed to fetch Instatus data:", error) - - // Return a fallback response - return NextResponse.json( - { - status: "UP", - url: "https://nodebytestat.us", - hasIncidents: false, - hasMaintenance: false, - incidents: [], - maintenances: [], - error: "Failed to fetch status", - }, - { status: 200 } // Still return 200 to not break the UI - ) - } -} 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/api/status/route.ts b/app/api/status/route.ts new file mode 100644 index 0000000..36ba89d --- /dev/null +++ b/app/api/status/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from "next/server" +import { computeLatencyStats, fetchStatusSnapshot, type MonitorStatus, type MonitorType } from "@/packages/core/lib/status" + +export const revalidate = 30 + +export interface StatusApiMonitor { + name: string + type: MonitorType + groupName: string | null + subgroupName: string | null + status: MonitorStatus + uptime30dPct: number | null + latency: { fast: number; avg: number; slow: number } | null +} + +export interface StatusApiResponse { + available: boolean + generatedAt: number | null + overallStatus: MonitorStatus | null + monitors: StatusApiMonitor[] +} + +export async function GET() { + const snapshot = await fetchStatusSnapshot() + + if (!snapshot) { + return NextResponse.json( + { available: false, generatedAt: null, overallStatus: null, monitors: [] }, + { status: 200 }, + ) + } + + const monitors: StatusApiMonitor[] = snapshot.monitors.map((m) => ({ + name: m.name, + type: m.type, + groupName: m.group_name, + subgroupName: m.subgroup_name, + status: m.status, + uptime30dPct: m.uptime_30d_pct, + latency: computeLatencyStats(m), + })) + + return NextResponse.json( + { available: true, generatedAt: snapshot.generated_at, overallStatus: snapshot.overall_status, monitors }, + { + status: 200, + headers: { + "Cache-Control": "public, s-maxage=30, stale-while-revalidate=60", + }, + }, + ) +} 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..dd40358 --- /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", "vps-servers"] +export const DEDICATED_HUB_SLUGS = ["dedicated-servers", "dedicated", "dedi"] 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 2a8b2ab..a4a7f67 100644 --- a/packages/core/constants/services.ts +++ b/packages/core/constants/services.ts @@ -65,7 +65,7 @@ export const SERVICE_CATEGORIES: ServiceCategory[] = [ highlights: [ "Enterprise-grade processors", "Full root / SSH access", - "NVMe SSD storage", + "Lightning fast networks", "Enterprise DDoS protection", ], enabled: true, diff --git a/packages/core/constants/status-mapping.ts b/packages/core/constants/status-mapping.ts new file mode 100644 index 0000000..7e6418f --- /dev/null +++ b/packages/core/constants/status-mapping.ts @@ -0,0 +1,33 @@ +/** + * Bridges the two independent naming schemes between status.nodebyte.host + * (monitor names) and this site's node/location identifiers. Update these + * maps whenever a node is renamed or a new Region ping monitor is added + * upstream — unmapped entries simply render without live data. + */ + +/** Website node `name` (STATIC_NODES) → status.nodebyte.host monitor `name`. */ +export const NODE_MONITOR_MAP: Record = { + "NEWC-GAME1": "NEWC-GAME1", + "NEWY-GAME1": "NEWY-GAME1", + "FLUXRP-FIVEM": "FLUXRP-FIVEM", + "HEL-VPS1": "HEL-VPS1", +} + +/** + * Website location `id` (LOCATIONS) → status.nodebyte.host "Regions" monitor + * `name`. Only locations with a confirmed matching ping monitor are listed; + * the Regions group also includes PoPs (e.g. Ashburn VA, Atlanta GA) that + * don't correspond to an actual NodeByte data centre location. + */ +export const LOCATION_MONITOR_MAP: Record = { + lon: "London, UK", + fal: "Falkenstein, DE", + fra: "Frankfurt, DE", + hel: "Helsinki, FI", + tor: "Toronto, ON", + vhv: "Ashburn, VA", + newy: "New York, USA", + sgp: "Singapore, Singapore", + syd: "Sydney, Australia", + mum: "Mumbai, India", +} diff --git a/packages/core/hooks/use-node-status.ts b/packages/core/hooks/use-node-status.ts new file mode 100644 index 0000000..1c5b6c4 --- /dev/null +++ b/packages/core/hooks/use-node-status.ts @@ -0,0 +1,41 @@ +"use client" + +import { useEffect, useState } from "react" +import type { StatusApiMonitor, StatusApiResponse } from "@/app/api/status/route" +import type { MonitorStatus } from "@/packages/core/lib/status" + +const REFRESH_INTERVAL_MS = 60_000 + +export function useNodeStatus() { + const [monitors, setMonitors] = useState([]) + const [available, setAvailable] = useState(false) + const [overallStatus, setOverallStatus] = useState(null) + + useEffect(() => { + let cancelled = false + + const load = () => { + fetch("/api/status") + .then((r) => r.json()) + .then((data: StatusApiResponse) => { + if (cancelled) return + setAvailable(data.available) + setMonitors(data.monitors) + setOverallStatus(data.overallStatus) + }) + .catch(() => {}) + } + + load() + const interval = setInterval(load, REFRESH_INTERVAL_MS) + return () => { + cancelled = true + clearInterval(interval) + } + }, []) + + const findMonitor = (name: string) => + monitors.find((m) => m.name.trim().toLowerCase() === name.trim().toLowerCase()) ?? null + + return { available, overallStatus, monitors, findMonitor } +} diff --git a/packages/core/lib/bytepay.ts b/packages/core/lib/bytepay.ts index 6221cb1..7a60723 100644 --- a/packages/core/lib/bytepay.ts +++ b/packages/core/lib/bytepay.ts @@ -5,8 +5,54 @@ * JSON:API response into a flat, typed structure the website can consume. */ -const BYTEPAY_HOST = process.env.BYTEPAY_HOST! -const BYTEPAY_TOKEN = process.env.BYTEPAY_TOKEN! +import { unstable_cache } from "next/cache" + +function getConfig(): { host: string; token: string } { + const host = process.env.BYTEPAY_HOST + const token = process.env.BYTEPAY_TOKEN + if (!host || !token) { + throw new Error( + "Billing API misconfigured: BYTEPAY_HOST and BYTEPAY_TOKEN must both be set.", + ) + } + return { host, token } +} + +const REQUEST_TIMEOUT_MS = 10_000 +const MAX_ATTEMPTS = 3 +const PAGE_SIZE = 100 + +/** Fetch with a timeout and retries for transient failures (429/5xx/network errors). */ +async function fetchWithRetry(url: string, token: string): Promise { + let lastError: unknown + + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + try { + const res = await fetch(url, { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + next: { revalidate: 300 }, + }) + + if (res.ok) return res + + const isRetryable = res.status === 429 || res.status >= 500 + if (!isRetryable || attempt === MAX_ATTEMPTS) return res + + const retryAfter = Number(res.headers.get("Retry-After")) + const delayMs = Number.isFinite(retryAfter) && retryAfter > 0 + ? retryAfter * 1000 + : 250 * 2 ** (attempt - 1) + await new Promise((resolve) => setTimeout(resolve, delayMs)) + } catch (err) { + lastError = err + if (attempt === MAX_ATTEMPTS) throw err + await new Promise((resolve) => setTimeout(resolve, 250 * 2 ** (attempt - 1))) + } + } + + throw lastError +} // ─── JSON:API wire types ─────────────────────────────────────────────────── @@ -80,28 +126,145 @@ function nameToSlug(name: string): string { .replace(/^-|-$/g, "") } +export interface CategoryInfo { + id: string + name: string + slug: string + description: string | null + parentId: string | null +} + +/** + * Fetch every category from the admin Categories endpoint (authoritative — + * independent of whatever a given products page happens to `include`). + * Paymenter doesn't expose a `slug` attribute on categories, only `name` + * and `parent_id`, so slugs are still derived by slugifying the name. + */ +async function fetchAllCategories(): Promise { + const { host, token } = getConfig() + const all: CategoryInfo[] = [] + let page = 1 + + while (true) { + const url = new URL(`${host}/api/v1/admin/categories`) + url.searchParams.set("per_page", String(PAGE_SIZE)) + url.searchParams.set("page", String(page)) + + const res = await fetchWithRetry(url.toString(), token) + if (!res.ok) { + throw new Error(`Billing API error ${res.status}: ${res.statusText}`) + } + + 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, + slug: nameToSlug(name), + description: (cat.attributes.description as string | null) ?? null, + parentId: cat.attributes.parent_id != null ? String(cat.attributes.parent_id) : null, + }) + } + + if (!json.links?.next) break + page++ + } + + return all +} + +const getCachedCategories = unstable_cache(fetchAllCategories, ["billing-categories"], { + 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, +}) + /** - * Derive the category slug from the included map. - * Paymenter's API exposes `name` on categories but not a `slug` attribute. - * Uses `full_slug` if the version of Paymenter provides it, otherwise falls - * back to slugifying the category name. + * 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. */ -function resolveCategorySlug(catId: string, map: Map): string { - const cat = map.get(`categories:${catId}`) - if (!cat) return "" - if (typeof cat.attributes.full_slug === "string") return cat.attributes.full_slug - return nameToSlug((cat.attributes.name as string) ?? "") +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 + * key off the flat leaf slug, a collision would silently merge two categories' + * products together). + */ +function buildCategorySlugMap(categories: CategoryInfo[]): Map { + const slugMap = new Map() + const ownerOfSlug = new Map() + + for (const cat of categories) { + const slug = cat.slug + slugMap.set(cat.id, slug) + + const existingOwner = ownerOfSlug.get(slug) + if (existingOwner && existingOwner !== cat.id) { + console.warn( + `[bytepay] Category slug collision: "${cat.name}" (id ${cat.id}) and category id ${existingOwner} both slugify to "${slug}" — products in one category may shadow the other on the site.`, + ) + } else { + ownerOfSlug.set(slug, cat.id) + } + } + + return slugMap } function normalisePage( data: JsonApiResource[], map: Map, + categorySlugMap: Map, ): BillingProduct[] { return data.map((product): BillingProduct => { const attr = product.attributes const catRef = product.relationships?.category?.data as JsonApiRelRef | null | undefined - const categorySlug = catRef ? resolveCategorySlug(catRef.id, map) : "" + const categorySlug = catRef ? (categorySlugMap.get(catRef.id) ?? "") : "" const planRefs = (product.relationships?.plans?.data ?? []) as JsonApiRelRef[] const plans = planRefs @@ -153,16 +316,17 @@ function normalisePage( async function fetchPage( page: number, + categorySlugMap: Map, ): Promise<{ products: BillingProduct[]; hasNext: boolean }> { - const url = new URL(`${BYTEPAY_HOST}/api/v1/admin/products`) + const { host, token } = getConfig() + + const url = new URL(`${host}/api/v1/admin/products`) url.searchParams.set("include", "category,plans,plans.prices") url.searchParams.set("filter[hidden]", "0") + url.searchParams.set("per_page", String(PAGE_SIZE)) url.searchParams.set("page", String(page)) - const res = await fetch(url.toString(), { - headers: { Authorization: `Bearer ${BYTEPAY_TOKEN}` }, - next: { revalidate: 300 }, - }) + const res = await fetchWithRetry(url.toString(), token) if (!res.ok) { throw new Error(`Billing API error ${res.status}: ${res.statusText}`) @@ -172,7 +336,7 @@ async function fetchPage( const map = buildIncludedMap(json.included) return { - products: normalisePage(json.data, map), + products: normalisePage(json.data, map, categorySlugMap), hasNext: Boolean(json.links?.next), } } @@ -181,11 +345,14 @@ async function fetchPage( /** Fetch every non-hidden product across all pagination pages. */ export async function fetchAllBillingProducts(): Promise { + const categories = await getCachedCategories() + const categorySlugMap = buildCategorySlugMap(categories) + const all: BillingProduct[] = [] let page = 1 while (true) { - const { products, hasNext } = await fetchPage(page) + const { products, hasNext } = await fetchPage(page, categorySlugMap) all.push(...products) if (!hasNext) break page++ diff --git a/packages/core/lib/spec-parser.ts b/packages/core/lib/spec-parser.ts index 75383d6..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,16 +70,25 @@ 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" + // "80 GB Local NVMe Storage" (no SSD/HDD/Disk keyword) // Also handles TB drives: "2 x 1 TB NVMe SSD", "4 x 16 TB SATA HDD" - const storageMatchGB = text.match(/(\d+)\s*GB\s+(?:NVMe\s+)?(?:SSD|Disk|HDD)(?:\s+Storage)?/i) + const storageMatchGB = text.match(/(\d+)\s*GB\s+(?:Local\s+)?(?:NVMe\s+)?(?:SSD|Disk|HDD|Storage)\b/i) const storageMatchTB = !storageMatchGB ? text.match(/(\d+)\s*TB\s+(?:NVMe\s+|Enterprise\s+|SATA\s+)?(?:SSD|HDD|Disk)/i) : null @@ -85,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) { @@ -144,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/lib/status.ts b/packages/core/lib/status.ts new file mode 100644 index 0000000..bea5ca8 --- /dev/null +++ b/packages/core/lib/status.ts @@ -0,0 +1,123 @@ +/** + * Server-side only — STATUS_TOKEN is never sent to the browser. + * + * Fetches monitor data from the status.nodebyte.host public status API + * and normalises it into the flat, typed shape the website needs. + */ + +export type MonitorStatus = "up" | "down" | "degraded" | "maintenance" | "paused" | "unknown" +export type MonitorType = "http" | "tcp" | "smtp" | "ping" | "group" + +export interface StatusHeartbeat { + checked_at: number + status: "up" | "down" | "maintenance" | "unknown" + latency_ms: number | null +} + +export interface StatusMonitor { + id: number + name: string + type: MonitorType + group_name: string | null + subgroup_name: string | null + status: MonitorStatus + last_checked_at: number | null + last_latency_ms: number | null + heartbeats: StatusHeartbeat[] + uptime_30d_pct: number | null +} + +export interface StatusSnapshot { + generated_at: number + overall_status: MonitorStatus + monitors: StatusMonitor[] +} + +function getConfig(): { host: string; token: string | undefined } { + const host = process.env.STATUS_API_URL + if (!host) { + throw new Error("Status API misconfigured: STATUS_API_URL must be set.") + } + return { host, token: process.env.STATUS_TOKEN } +} + +async function fetchStatusJson(host: string, token: string | undefined): Promise> { + const res = await fetch(`${host}/api/v1/public/status`, { + headers: { + Accept: "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + next: { revalidate: 30 }, + }) + + if (res.status === 503 && token) { + // The status app's edge proxy refuses to forward the Authorization header + // upstream until its own UPTIMER_API_SENSITIVE_ORIGIN is configured. Fall + // back to the unauthenticated public payload rather than failing outright. + const body = await res.clone().json().catch(() => null) + if (body?.error?.code === "API_ORIGIN_UNTRUSTED_FOR_SENSITIVE_HEADERS") { + return fetchStatusJson(host, undefined) + } + } + + if (!res.ok) { + throw new Error(`Status API returned ${res.status}`) + } + + return res.json() +} + +/** Fetch the current status snapshot. Returns null on any failure so callers can fall back to static data. */ +export async function fetchStatusSnapshot(): Promise { + try { + const { host, token } = getConfig() + const data = await fetchStatusJson(host, token) + const rawMonitors = Array.isArray(data.monitors) ? (data.monitors as Record[]) : [] + + const monitors: StatusMonitor[] = rawMonitors.map((m) => ({ + id: m.id as number, + name: m.name as string, + type: m.type as MonitorType, + group_name: (m.group_name as string | null) ?? null, + subgroup_name: (m.subgroup_name as string | null) ?? null, + status: m.status as MonitorStatus, + last_checked_at: (m.last_checked_at as number | null) ?? null, + last_latency_ms: (m.last_latency_ms as number | null) ?? null, + heartbeats: Array.isArray(m.heartbeats) ? (m.heartbeats as StatusHeartbeat[]) : [], + uptime_30d_pct: + m.uptime_30d && typeof (m.uptime_30d as Record).uptime_pct === "number" + ? ((m.uptime_30d as Record).uptime_pct as number) + : null, + })) + + return { + generated_at: data.generated_at as number, + overall_status: data.overall_status as MonitorStatus, + monitors, + } + } catch (error) { + console.error("Failed to fetch status snapshot:", error) + return null + } +} + +/** Find a monitor by exact name (case-insensitive). */ +export function findMonitor(snapshot: StatusSnapshot | null, name: string): StatusMonitor | null { + if (!snapshot) return null + const target = name.trim().toLowerCase() + return snapshot.monitors.find((m) => m.name.trim().toLowerCase() === target) ?? null +} + +/** Compute fast/avg/slow latency (ms) from a monitor's recent heartbeats. */ +export function computeLatencyStats(monitor: StatusMonitor): { fast: number; avg: number; slow: number } | null { + const samples = monitor.heartbeats + .map((h) => h.latency_ms) + .filter((ms): ms is number => typeof ms === "number") + + if (samples.length === 0) return null + + const fast = Math.min(...samples) + const slow = Math.max(...samples) + const avg = Math.round(samples.reduce((sum, ms) => sum + ms, 0) / samples.length) + return { fast, avg, slow } +} diff --git a/packages/core/products/billing-service.ts b/packages/core/products/billing-service.ts index 4bfb52b..a6d0064 100644 --- a/packages/core/products/billing-service.ts +++ b/packages/core/products/billing-service.ts @@ -10,8 +10,9 @@ 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" const getCachedProducts = unstable_cache( fetchAllBillingProducts, @@ -19,6 +20,22 @@ const getCachedProducts = unstable_cache( { revalidate: 300 }, ) +/** + * A live, non-hidden product is visible in Paymenter but got filtered out + * because its description didn't parse into the specs the site needs. + * Logged so a wording change (e.g. a new storage phrasing) surfaces + * immediately instead of being discovered by a customer. + */ +function warnDroppedProduct( + product: BillingProduct, + categorySlug: string, + missingFields: string[], +): void { + console.warn( + `[billing-service] Product "${product.name}" (id ${product.id}, slug ${product.slug}) in category "${categorySlug}" is live but missing parsed spec(s): ${missingFields.join(", ")}. Check its description formatting.`, + ) +} + /** * Returns live-priced game plans for the given billing category slug. * RAM and storage are parsed from the billing panel description automatically. @@ -29,13 +46,23 @@ export async function getGamePlans(categorySlug: string): Promise { const parsed = parseDescriptionSpecs(product.description) - if (!parsed.ramGB || !parsed.storageGB) return [] + if (!parsed.ramGB || !parsed.storageGB) { + const missing = [!parsed.ramGB && "ramGB", !parsed.storageGB && "storageGB"].filter( + (v): v is string => Boolean(v), + ) + warnDroppedProduct(product, categorySlug, missing) + return [] + } return [ { id: product.slug, + name: product.name, + description: parsed.description, ramGB: parsed.ramGB, + ramType: parsed.ramType, storageGB: parsed.storageGB, + storageLabel: formatStorageType(parsed.storageType), bandwidth: parsed.bandwidth ?? null, popular: POPULAR_SLUGS.has(`${categorySlug}/${product.slug}`), priceGBP: getGbpPrice(product), @@ -57,7 +84,10 @@ export async function getDedicatedPlans(categorySlug: string): Promise { const parsed = parseDescriptionSpecs(product.description) - if (!parsed.ramGB) return [] + if (!parsed.ramGB) { + warnDroppedProduct(product, categorySlug, ["ramGB"]) + return [] + } return [ { @@ -93,7 +123,15 @@ export async function getVpsPlans(categorySlug: string): Promise const parsed = parseDescriptionSpecs(product.description) const { sku, lineup, series } = parseProductName(product.name) - if (!parsed.cpu || !parsed.ramGB || !parsed.storageGB) return [] + if (!parsed.cpu || !parsed.ramGB || !parsed.storageGB) { + const missing = [ + !parsed.cpu && "cpu", + !parsed.ramGB && "ramGB", + !parsed.storageGB && "storageGB", + ].filter((v): v is string => Boolean(v)) + warnDroppedProduct(product, categorySlug, missing) + return [] + } return [ { diff --git a/packages/core/products/catalog-config.ts b/packages/core/products/catalog-config.ts new file mode 100644 index 0000000..a9e34a1 --- /dev/null +++ b/packages/core/products/catalog-config.ts @@ -0,0 +1,247 @@ +/** + * Resolves the display config + plan-card content for a game category page. + * + * Every game category discovered from Paymenter gets a working page for + * free (name/description from the category, specs parsed from the product + * description, generic marketing copy). The handful of games we've actually + * curated (banner art, hand-picked feature lists) override that generic + * output — see GAME_OVERRIDES below. Plan-level content (name/description) + * always prefers live billing data first; curated per-plan copy is only a + * fallback for the originally-curated plan ids. + */ + +import type { GamePlanSpec } from "@/packages/core/types/servers/game" +import type { CategoryInfo } from "@/packages/core/lib/bytepay" +import { LINKS } from "@/packages/core/constants/links" +import { + MINECRAFT_CONFIG, + MINECRAFT_PLAN_DISPLAY, + MINECRAFT_PLAN_STATIC_FEATURES, + MINECRAFT_FEATURES, + MINECRAFT_FAQS, + MINECRAFT_HERO_FEATURES, +} from "@/packages/core/constants/game/minecraft" +import { + RUST_CONFIG, + RUST_PLAN_DISPLAY, + RUST_PLAN_STATIC_FEATURES, + RUST_FEATURES, + RUST_FAQS, + RUST_HERO_FEATURES, +} from "@/packages/core/constants/game/rust" +import { + HYTALE_CONFIG, + HYTALE_PLAN_DISPLAY, + HYTALE_PLAN_STATIC_FEATURES, + HYTALE_FEATURES, + HYTALE_FAQS, + HYTALE_HERO_FEATURES, +} from "@/packages/core/constants/game/hytale" +import { + TERRARIA_CONFIG, + TERRARIA_FEATURES, + TERRARIA_FAQS, + TERRARIA_HERO_FEATURES, +} from "@/packages/core/constants/game/terraria" +import { + GMOD_CONFIG, + GMOD_FEATURES, + GMOD_FAQS, + GMOD_HERO_FEATURES, +} from "@/packages/core/constants/game/gmod" +import { + PALWORLD_CONFIG, + PALWORLD_FEATURES, + PALWORLD_FAQS, + PALWORLD_HERO_FEATURES, +} from "@/packages/core/constants/game/palworld" + +export type GameIconName = "Blocks" | "Gamepad2" | "Sparkles" | "Leaf" | "Pickaxe" | "Wrench" +export type FeatureIconName = + | "Settings" | "Cpu" | "Shield" | "Zap" | "HardDrive" | "Users" | "Server" | "Map" | "Globe" | "Sparkles" | "Gamepad2" + +export interface GamePageFeature { + title: string + description: string + icon: FeatureIconName + highlights: string[] +} + +export interface GameFaq { + question: string + answer: string +} + +export interface GamePlanDisplay { + name: string + description: string + features: string[] +} + +export interface GameDisplayConfig { + name: string + description: string + banner: string + iconName: GameIconName + tag: string + tagColor: string + headerGradient: string + headerIconBg: string + billingUrl: string + heroFeatures: string[] + pageFeatures: GamePageFeature[] + faqs: GameFaq[] + resolvePlanDisplay: (plan: GamePlanSpec) => 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 b147267..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 (
    @@ -37,12 +67,12 @@ export function Services() { {/* Service Hub Cards */}
    {activeServices.map((service) => ( @@ -59,23 +89,23 @@ export function Services() { {/* Card header — gradient visual */}
    {/* Large faded background icon */} - + {/* Centred icon badge */}
    -
    +
    {/* Title + starting price */} -
    -

    {service.name}

    - +
    +

    {service.name}

    + {t("servicesHome.startingFrom")} /mo
    diff --git a/packages/ui/components/Layouts/Nodes/nodes-client.tsx b/packages/ui/components/Layouts/Nodes/nodes-client.tsx index 7d8e37a..e7d8ecf 100644 --- a/packages/ui/components/Layouts/Nodes/nodes-client.tsx +++ b/packages/ui/components/Layouts/Nodes/nodes-client.tsx @@ -23,6 +23,9 @@ import { } from "@/packages/ui/components/ui/accordion" import { Alert, AlertDescription } from "@/packages/ui/components/ui/alert" import type { PublicNode } from "@/packages/core/constants/node-types" +import { NODE_MONITOR_MAP, LOCATION_MONITOR_MAP } from "@/packages/core/constants/status-mapping" +import { useNodeStatus } from "@/packages/core/hooks/use-node-status" +import type { StatusApiMonitor } from "@/app/api/status/route" import { cn } from "@/lib/utils" import Link from "next/link" import { LINKS } from "@/packages/core/constants/links" @@ -36,24 +39,29 @@ interface ExtendedNode extends PublicNode { const STATIC_NODES: ExtendedNode[] = [ { id: 1, - name: "NB-GNODE-NC1", + name: "NEWC-GAME1", locationCode: "Newcastle, UK", isMaintenanceMode: false, memory: 1310089, disk: 1811000, - cpu: "AMD Ryzen™ 9 5900X", - ramType: "DDR4 ECC", uptime: 99.9, }, { id: 2, - name: "NB-VNODE-HEL1", + 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, memory: 65104, disk: 512000, - cpu: "AMD Ryzen™ 7 1700X", - ramType: "DDR4 ECC", uptime: 99.8, }, ] @@ -98,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" }, @@ -112,6 +121,22 @@ function formatSize(mib: number): string { return `${mib} MiB` } +const LIVE_STATE_STYLES: Record = { + up: { label: "Online", dot: "bg-green-400 animate-pulse", border: "hover:border-green-500/30 hover:shadow-xl hover:shadow-green-500/5", badge: "border-green-500/30 text-green-400 bg-green-500/5" }, + degraded: { label: "Degraded", dot: "bg-amber-400", border: "hover:border-amber-500/30 hover:shadow-xl hover:shadow-amber-500/5", badge: "border-amber-500/30 text-amber-400 bg-amber-500/5" }, + maintenance: { label: "Maintenance", dot: "bg-amber-400", border: "hover:border-amber-500/30 hover:shadow-xl hover:shadow-amber-500/5", badge: "border-amber-500/30 text-amber-400 bg-amber-500/5" }, + down: { label: "Offline", dot: "bg-red-400", border: "hover:border-red-500/30 hover:shadow-xl hover:shadow-red-500/5", badge: "border-red-500/30 text-red-400 bg-red-500/5" }, +} + +function resolveNodeState(node: ExtendedNode, live: StatusApiMonitor | null) { + if (live && live.status in LIVE_STATE_STYLES) { + return LIVE_STATE_STYLES[live.status] + } + return node.isMaintenanceMode + ? { label: "Maintenance", dot: "bg-amber-400", border: "hover:border-amber-500/30 hover:shadow-xl hover:shadow-amber-500/5", badge: "border-amber-500/30 text-amber-400 bg-amber-500/5" } + : { label: "Online", dot: "bg-green-400 animate-pulse", border: "hover:border-green-500/30 hover:shadow-xl hover:shadow-green-500/5", badge: "border-green-500/30 text-green-400 bg-green-500/5" } +} + function groupByCountry(locations: DataCentreLocation[]) { const map = new Map() for (const loc of locations) { @@ -126,25 +151,26 @@ function groupByCountry(locations: DataCentreLocation[]) { } -function NodeCard({ node }: { node: ExtendedNode }) { - const online = !node.isMaintenanceMode +function NodeCard({ node, live }: { node: ExtendedNode; live: StatusApiMonitor | null }) { + const state = resolveNodeState(node, live) + const uptime = live?.uptime30dPct ?? node.uptime + return ( -
    +
    @@ -158,17 +184,9 @@ function NodeCard({ node }: { node: ExtendedNode }) { )}
    - - - {online ? "Online" : "Maintenance"} + + + {state.label}
    @@ -190,13 +208,21 @@ function NodeCard({ node }: { node: ExtendedNode }) { {node.ramType}
    )} - {node.uptime !== undefined && ( + {live?.latency && ( +
    + + Ping + + {live.latency.avg}ms avg +
    + )} + {uptime !== undefined && uptime !== null && (
    Uptime - = 99.9 ? "text-green-400" : "text-amber-400")}> - {node.uptime.toFixed(1)}% + = 99.9 ? "text-green-400" : "text-amber-400")}> + {uptime.toFixed(1)}%
    )} @@ -211,10 +237,12 @@ function LocationCountryRow({ country, flag, locations, + findMonitor, }: { country: string flag: string locations: DataCentreLocation[] + findMonitor: (name: string) => StatusApiMonitor | null }) { return (
    @@ -222,21 +250,27 @@ function LocationCountryRow({

    {country}

    - {locations.map((loc) => ( - - {loc.city} - {loc.area && · {loc.area}} - {loc.primary && } - - ))} + {locations.map((loc) => { + const monitorName = LOCATION_MONITOR_MAP[loc.id] + const live = monitorName ? findMonitor(monitorName) : null + const liveState = live ? LIVE_STATE_STYLES[live.status] : null + return ( + + {liveState && } + {loc.city} + {loc.area && · {loc.area}} + {loc.primary && } + + ) + })}
    @@ -282,8 +316,15 @@ const FAQS = [ export function NodesClient() { const nodes = STATIC_NODES - const onlineCount = nodes.filter((n) => !n.isMaintenanceMode).length - const maintenanceCount = nodes.filter((n) => n.isMaintenanceMode).length + const { findMonitor } = useNodeStatus() + const nodeLiveStates = nodes.map((node) => { + const monitorName = NODE_MONITOR_MAP[node.name] + return monitorName ? findMonitor(monitorName) : null + }) + const onlineCount = nodeLiveStates.filter( + (live, i) => (live ? live.status === "up" : !nodes[i].isMaintenanceMode), + ).length + const maintenanceCount = nodes.length - onlineCount const hydrated = useSyncExternalStore(() => () => {}, () => true, () => false) return ( @@ -320,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}
    @@ -341,8 +382,8 @@ export function NodesClient() {
    - {nodes.map((node) => ( - + {nodes.map((node, i) => ( + ))}
    @@ -384,6 +425,7 @@ export function NodesClient() { country={country} flag={flag} locations={locations} + findMonitor={findMonitor} /> ))}
    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/footer.tsx b/packages/ui/components/Static/footer.tsx index d02e72b..fb9e16c 100644 --- a/packages/ui/components/Static/footer.tsx +++ b/packages/ui/components/Static/footer.tsx @@ -10,6 +10,7 @@ import { Logo } from "@/packages/ui/components/logo" import Image from "next/image" import { useTranslations } from "next-intl" import { LINKS } from "@/packages/core/constants/links" +import { StatusBadge } from "@/packages/ui/components/Static/status-badge" export function Footer() { const t = useTranslations() @@ -262,6 +263,7 @@ export function Footer() { © {new Date().getFullYear()} NodeByte LTD. {t("footer.copyright")}

    + Company No. 15432941
    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/Static/status-badge.tsx b/packages/ui/components/Static/status-badge.tsx new file mode 100644 index 0000000..1d5cc76 --- /dev/null +++ b/packages/ui/components/Static/status-badge.tsx @@ -0,0 +1,44 @@ +"use client" + +import { useTranslations } from "next-intl" +import { cn } from "@/lib/utils" +import { useNodeStatus } from "@/packages/core/hooks/use-node-status" +import { LINKS } from "@/packages/core/constants/links" + +const STATUS_DOT: Record = { + up: "bg-green-400 animate-pulse", + degraded: "bg-amber-400", + maintenance: "bg-amber-400", + paused: "bg-amber-400", + down: "bg-red-400", + unknown: "bg-muted-foreground", +} + +const STATUS_LABEL_KEY: Record = { + up: "operational", + degraded: "degraded", + maintenance: "maintenance", + paused: "maintenance", + down: "down", + unknown: "unavailable", +} + +export function StatusBadge() { + const t = useTranslations() + const { available, overallStatus } = useNodeStatus() + + const labelKey = available && overallStatus ? STATUS_LABEL_KEY[overallStatus] : "unavailable" + const dotClass = available && overallStatus ? STATUS_DOT[overallStatus] : "bg-muted-foreground" + + return ( + + + {t(`footer.statusLabels.${labelKey}`)} + + ) +} 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 67a79ea..01c38f0 160000 --- a/translations +++ b/translations @@ -1 +1 @@ -Subproject commit 67a79ea5ff53a62e9c7c8595861c6f200c56128f +Subproject commit 01c38f0e9689fa9c0591ac55937e2ff20e1ea8b9