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..6d70838 100644 --- a/README.md +++ b/README.md @@ -1,63 +1,11 @@ # NodeByte Hosting Platform -A modern hosting management platform built with **Next.js** and a **Go (Fiber) backend**. Manage game servers (Minecraft, Rust, Hytale), VPS nodes, and billing from a unified admin dashboard. +A modern hosting website built with **Next.js**. [![License: AGPL-3.0-only](https://img.shields.io/badge/License-AGPL%203.0%20only-blue.svg)](LICENSE) [![Next.js](https://img.shields.io/badge/Next.js-16+-black?logo=next.js)](https://nextjs.org/) [![TypeScript](https://img.shields.io/badge/TypeScript-5+-blue?logo=typescript)](https://www.typescriptlang.org/) -[![Go](https://img.shields.io/badge/Go-Fiber_Backend-00ADD8?logo=go)](https://gofiber.io/) -## Features - -### Multi-Panel Game Server Hosting -- **Pterodactyl Panel** support for game servers (Minecraft, Rust, Hytale, etc.) -- **Virtfusion Panel** support for VPS management -- Multi-panel architecture -- manage multiple panels from a single dashboard -- Real-time server status monitoring across all panels -- Automatic panel API integration and connection testing -- Resource allocation and limits management - -### Admin Dashboard -- **User Management** -- user listing with pagination, filtering, sorting, and role management -- **Server Management** -- browse, filter, and manage all hosted servers -- **Node Management** -- monitor and configure hosting nodes -- **Location Management** -- view and sync Pterodactyl panel locations -- **Allocation Management** -- view allocations across nodes with server assignments -- **Egg Management** -- browse synced server types and configurations -- **Sync Operations** -- real-time sync logs with terminal-style output, cancellation support, and auto-sync scheduling -- **Settings Management** -- system configuration with connection testing across four tabs (Connections, Features, Notifications, Advanced) -- **Panel Configuration** -- built-in setup wizard for connecting multiple game panels - -### Webhook Notification System -Automatic Discord webhook notifications for system events, server state changes, sync operations, billing events, security alerts, and support tickets. Webhooks are managed through the admin settings panel with per-webhook type and scope configuration. - -### Internationalization -- 30+ languages supported -- Translation management via Crowdin -- Modular translation file structure (`templates/en/*.json`) -- Language and currency selectors in navigation -- Region-specific pricing with multi-currency support (GBP, USD, EUR, CAD, AUD) - -### Knowledge Base -- Markdown-based documentation system -- Full-text search across articles -- Category organization with sidebar navigation -- Table of contents with scroll spy -- Syntax highlighting for code blocks - -### Authentication -- JWT-based authentication via Go backend -- Email/password registration and login -- Forgot password and email verification flows -- Session management with secure token handling -- Admin-only access control via middleware -- User profile management with email change and verification - -### Billing -- Multi-currency support (GBP, USD, EUR, CAD, AUD) -- Pricing configuration -- Invoice management (WHMCS integration) -- Subscription tracking ## Quick Start @@ -65,153 +13,8 @@ Automatic Discord webhook notifications for system events, server state changes, - Node.js 22+ or Bun - Go backend service (see backend repository) - Pterodactyl Game Panel (for game server hosting) -- Discord Server (for webhooks, optional) - -### Installation - -1. **Clone the repository** - ```bash - git clone --recursive https://github.com/NodeByteHosting/website.git - cd website - ``` - - Alternatively, if you've already cloned without `--recursive`, initialize the submodule: - ```bash - git submodule update --init --recursive --remote - ``` - -2. **Install dependencies** - ```bash - npm install - # or - bun install - ``` - -3. **Set up environment variables** - ```bash - cp .env.example .env.local - ``` - Configure the following variables: - ```bash - NEXT_PUBLIC_GO_API_URL="http://localhost:8080" # Go backend URL - BACKEND_API_KEY="" # API key for backend communication - JWT_SECRET="" # JWT signing secret - ``` - -4. **Start development server** - ```bash - npm run dev - # or - bun dev - ``` - - Open [http://localhost:3000](http://localhost:3000) in your browser. - -> **Note:** The Go backend must be running for authentication, admin operations, and panel sync to function. The Next.js frontend communicates with the backend via the `NEXT_PUBLIC_GO_API_URL`. - -## Architecture - -This repository contains the **Next.js frontend**. All business logic, database access, authentication, and panel sync operations are handled by a separate **Go (Fiber) backend**. The frontend acts as a client, making API calls to the backend for all data operations. - -- **Frontend (this repo):** Next.js 16 with App Router, React 19, TanStack React Query for data fetching, shadcn/ui components, and Tailwind CSS v4 -- **Backend (separate service):** Go Fiber API server handling database, auth, panel integrations, sync, webhooks, and admin operations - -### API Communication - -The frontend uses a centralized API client (`packages/core/lib/api.ts`) that routes all requests to the Go backend. Admin hooks in `packages/core/hooks/use-admin-api.ts` provide React Query wrappers for admin operations. Public data hooks are in `packages/core/hooks/use-public-api.ts`. - -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/panel/*` -- Public panel data (counts, nodes, servers, stats, users) -- `/api/trustpilot` -- Trustpilot review data +- Discord Server (for webhooks, optional. -## Project Structure - -``` -. -├── app/ # Next.js App Router -│ ├── admin/ # Admin dashboard pages -│ │ ├── allocations/ # Allocation management -│ │ ├── eggs/ # Egg management -│ │ ├── locations/ # Location management -│ │ ├── nodes/ # Node management -│ │ ├── servers/ # Server management -│ │ ├── settings/ # System settings -│ │ ├── sync/ # Sync operations and logs -│ │ └── users/ # User management -│ ├── api/ # Lightweight proxy routes -│ │ ├── github/releases/ # GitHub releases proxy -│ │ ├── instatus/ # Status page proxy -│ │ ├── panel/ # Public panel data -│ │ └── trustpilot/ # Trustpilot proxy -│ ├── auth/ # Authentication pages -│ ├── changelog/ # Changelog page -│ ├── contact/ # Contact page -│ ├── dashboard/ # User dashboard -│ │ ├── account/ # Profile and security settings -│ │ └── servers/ # User server management -│ ├── games/ # Game-specific pages -│ ├── kb/ # Knowledge base -│ ├── maintenance/ # Maintenance mode page -│ ├── setup/ # Initial setup wizard -│ └── layout.tsx # Root layout -├── packages/ # Shared packages -│ ├── auth/ # Auth components and utilities -│ │ ├── components/ # Login, register, forgot-password forms -│ │ └── lib/ # Auth client, context, server helpers -│ ├── core/ # Core logic and hooks -│ │ ├── constants/ # Game-specific constants -│ │ ├── hooks/ # React hooks (admin API, public API, currency, locale, etc.) -│ │ ├── lib/ # API client, currency, query client, translations, utilities -│ │ └── middleware/ # Route middleware -│ ├── changelog/ # Changelog components and hooks -│ ├── i18n/ # Internationalization (next-intl config) -│ ├── kb/ # Knowledge base components, content, and utilities -│ └── ui/ # UI components (layouts, shadcn/ui primitives) -├── public/ # Static assets -├── translations/ # Localization -│ ├── messages/ # 30+ language files -│ └── templates/ # Source translation templates (modular) -└── [config files] # next.config.mjs, tsconfig.json, etc. -``` - -## Panel Integration - -NodeByte supports multiple hosting control panels, allowing you to manage different types of infrastructure from a single dashboard. - -### Supported Panels - -**Pterodactyl Panel** -- Game server management (Minecraft, Rust, Hytale, etc.) -- API-based authentication via token -- Sync support for nodes, locations, allocations, eggs, servers, and users - -**Virtfusion Panel** -- VPS and virtual machine management -- API key-based authentication -- Infrastructure provisioning and monitoring - -### Setup Wizard - -The platform includes an interactive setup wizard at `/setup` that guides you through: - -1. **Site Information** -- site name, URL, and optional favicon -2. **Game Panels** -- Pterodactyl panel credentials with connection testing -3. **Infrastructure Panels** -- Virtfusion panel credentials with connection testing - -The setup supports incremental configuration. You can configure components in any order and add panels later from the admin settings. - -### Panel Data Synchronization - -The backend automatically syncs data from configured panels: - -- **Nodes** -- hosting nodes and their resources -- **Locations** -- geographic regions -- **Eggs** -- server types and configurations -- **Servers** -- active server instances -- **Allocations** -- IP/port assignments -- **Users** -- panel user accounts for registration verification - -Sync frequency is configurable in admin settings. Auto-sync can be enabled or disabled, and manual sync can be triggered from the admin panel. Sync operations support cancellation and emit real-time progress logs. ## Development @@ -266,30 +69,9 @@ You are free to use, modify, and distribute this software, provided that: - [Knowledge Base](https://nodebyte.host/kb) ### Community -- [Discord Server](https://discord.gg/wN58bTzzpW) +- [Discord Server](https://discord.gg/nodebyte) - [Twitter/X](https://x.com/NodeByteHosting) - [Email Support](mailto:support@nodebyte.host) ### Issue Tracking Report bugs and feature requests on [GitHub Issues](https://github.com/NodeByteHosting/website/issues). - -## Authors - -**NodeByte Hosting Team** -- Website: https://nodebyte.host -- Email: hello@nodebyte.host - -## Built With - -- [Next.js](https://nextjs.org/) -- React framework with App Router -- [React](https://react.dev/) -- UI library (v19) -- [TypeScript](https://www.typescriptlang.org/) -- Type-safe JavaScript -- [Tailwind CSS](https://tailwindcss.com/) -- Utility-first CSS (v4) -- [shadcn/ui](https://ui.shadcn.com/) -- Radix-based UI components -- [TanStack React Query](https://tanstack.com/query) -- Data fetching and caching -- [next-intl](https://next-intl.dev/) -- Internationalization -- [Go Fiber](https://gofiber.io/) -- Backend API framework (separate service) - ---- - -**Last Updated:** March 1, 2026 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/github/releases/route.ts b/app/api/github/releases/route.ts index f6fa8c8..46de7b2 100644 --- a/app/api/github/releases/route.ts +++ b/app/api/github/releases/route.ts @@ -2,11 +2,8 @@ import { NextResponse } from 'next/server' const DEFAULT_REPOSITORIES = [ 'NodeByteHosting/website', - 'NodeByteHosting/backend', 'NodeByteHosting/Game-Panel', - 'NodeByteLTD/ByteSend', - 'NodeByteLTD/ByteProxy', - 'NodeByteLTD/bytesend-go' + 'NodeByteHosting/BytePay' ]; export interface GitHubRelease { 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/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..59d75c4 100644 --- a/app/games/page.tsx +++ b/app/games/page.tsx @@ -1,164 +1,21 @@ -import { Card } from "@/packages/ui/components/ui/card" -import { Button } from "@/packages/ui/components/ui/button" -import { GamePrice } from "@/packages/ui/components/ui/game-price" -import { Gamepad2, ArrowRight, Check, Blocks, Sparkles, Leaf, Pickaxe, Wrench } from "lucide-react" -import type { LucideIcon } from "lucide-react" -import Image from "next/image" -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" - -const ICON_MAP: Record = { Blocks, Gamepad2, Sparkles, Leaf, Pickaxe, Wrench } - -export async function generateMetadata(): Promise { - const t = await getTranslations() - return { - title: `${t("gamesPage.title")}`, - description: t("gamesPage.description"), - } +import { GameHub } from "@/packages/ui/components/Layouts/Games/game-hub" +import { getGamePlans } from "@/packages/core/products/billing-service" +import { getCategoryHub } from "@/packages/core/lib/bytepay" +import { GAME_HUB_SLUGS } from "@/packages/core/constants/catalog-hubs" + +export const metadata: Metadata = { + title: "Game Server Hosting", + description: + "One set of plans for every game we support. Instant setup, enterprise DDoS protection, NVMe SSD storage, and 24/7 support — pick your game at checkout.", } export default async function GamesPage() { - const t = await getTranslations() - - 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`), - ], - })) - - return ( -
- {/* Background */} -
-
- - {/* Animated orbs */} -
-
-
-
- -
- {/* Header */} -
-
- - {t("games.badge")} -
-

- {t("games.title")}{" "} - - {t("games.titleHighlight")} - -

-

- {t("games.description")} -

-
- - {/* Games Grid */} -
- {games.map((game) => ( - - {/* Banner */} -
- {game.name} -
- - {/* Tag */} -
- {game.tag} -
- - {/* Icon */} -
- -
-
- -
- {/* Title & Price */} -
-

{game.name}

- {game.comingSoon ? ( - - Coming Soon - - ) : game.startingPriceGBP ? ( - - ) : null} -
- -

{game.description}

- - {/* Features */} -
    - {game.features.map((feature, i) => ( -
  • - - {feature} -
  • - ))} -
+ const hub = await getCategoryHub(GAME_HUB_SLUGS) + const children = hub?.children ?? [] - {/* CTA */} - -
- - ))} -
+ const plansByCategory = await Promise.all(children.map((c) => getGamePlans(c.slug))) + const plans = plansByCategory.flat() - {/* Bottom CTA */} -
-

- {t("games.customSolution")}{" "} - - {t("games.contactUs")} - {" "} - {t("games.forCustom")} -

-
-
-
- ) + return } 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/nodes/page.tsx b/app/nodes/page.tsx index a24114c..bf7c396 100644 --- a/app/nodes/page.tsx +++ b/app/nodes/page.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next" import { NodesClient } from "@/packages/ui/components/Layouts/Nodes/nodes-client" +import { fetchStatusSnapshot, getNodeMonitorNames } from "@/packages/core/lib/status" export const metadata: Metadata = { title: "Our Network & Nodes", @@ -7,6 +8,8 @@ export const metadata: Metadata = { "Live status and information about NodeByte Hosting's network. See where our infrastructure is located and what powers our game server and VPS hosting.", } -export default function NodesPage() { - return +export default async function NodesPage() { + const snapshot = await fetchStatusSnapshot() + const nodeNames = getNodeMonitorNames(snapshot) + return } diff --git a/app/partners/page.tsx b/app/partners/page.tsx new file mode 100644 index 0000000..cc76176 --- /dev/null +++ b/app/partners/page.tsx @@ -0,0 +1,11 @@ +import { PartnersPage } from "@/packages/ui/components/Layouts/Partners" +import type { Metadata } from "next" + +export const metadata: Metadata = { + title: "Partners & Sponsors", + description: "Meet the communities, creators, and projects we partner with, and learn how to join the NodeByte Partnership Program.", +} + +export default function Partners() { + return +} diff --git a/app/sitemap.ts b/app/sitemap.ts index b0c3e7d..59431ac 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -10,13 +10,11 @@ const staticRoutes: Array<{ }> = [ { path: "", priority: 1.0, changeFrequency: "weekly" }, { path: "/games", priority: 0.9, changeFrequency: "weekly" }, - { path: "/games/minecraft", priority: 0.9, changeFrequency: "weekly" }, - { path: "/games/rust", priority: 0.9, changeFrequency: "weekly" }, - { path: "/games/hytale", priority: 0.8, changeFrequency: "monthly" }, { path: "/vps", priority: 0.9, changeFrequency: "weekly" }, { path: "/vps/amd", priority: 0.9, changeFrequency: "weekly" }, { path: "/vps/intel", priority: 0.8, changeFrequency: "monthly" }, { path: "/about", priority: 0.6, changeFrequency: "monthly" }, + { path: "/partners", priority: 0.5, changeFrequency: "monthly" }, { path: "/contact", priority: 0.7, changeFrequency: "monthly" }, { path: "/changelog", priority: 0.5, changeFrequency: "weekly" }, { path: "/kb", priority: 0.7, changeFrequency: "weekly" }, 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 deleted file mode 100644 index 82f92d4..0000000 --- a/packages/core/constants/game/gmod.ts +++ /dev/null @@ -1,139 +0,0 @@ -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", - "DDoS Protection", - "BytePanel", - "99.9% Uptime SLA", -] as const - -/** Inline feature data — maps directly to GameFeatures props. */ -export const GMOD_FEATURES = [ - { - title: "Workshop & Addon Support", - description: - "Full Steam Workshop integration lets you install and auto-download addons for your players at server start, keeping everyone in sync.", - icon: "Settings" as const, - highlights: [ - "Steam Workshop support", - "Addon auto-download", - "Collection support", - "Resource manager", - ], - }, - { - title: "Gamemode Flexibility", - description: - "Run any gamemode — DarkRP, TTT, Prophunt, Murder, Sandbox, and more. Switch gamemodes without reinstalling or rebuilding your server.", - icon: "Gamepad2" as const, - highlights: [ - "DarkRP & TTT ready", - "Any custom gamemode", - "Sandbox support", - "Easy config switching", - ], - }, - { - title: "MySQL Integration", - description: - "Integrated MySQL databases for data-heavy gamemodes like DarkRP. Store player data, inventories, and economy without needing a separate host.", - icon: "Server" as const, - highlights: [ - "Bundled MySQL databases", - "DarkRP optimised", - "Easy database access", - "Regular backups", - ], - }, - { - title: "DDoS Protection", - description: - "Enterprise-grade DDoS mitigation keeps your Garry's Mod server online and your community protected at all times.", - icon: "Shield" as const, - highlights: [ - "Always-on protection", - "Layer 3/4/7 filtering", - "Zero downtime", - "Global POPs", - ], - }, - { - title: "High Performance", - description: - "Low latency hardware chosen for the demanding addon and workshop workloads that Garry's Mod servers generate.", - icon: "Cpu" as const, - highlights: [ - "High-clock AMD CPUs", - "NVMe SSD storage", - "Low latency networking", - "DDR4 ECC memory", - ], - }, - { - title: "Full File Access", - description: - "Complete FTP and web-based file manager access. Upload custom addons, Lua scripts, sounds, and models without restrictions.", - icon: "Users" as const, - highlights: [ - "Web file manager", - "FTP access", - "Lua script support", - "No upload restrictions", - ], - }, -] as const - -/** Inline FAQ data — maps directly to GameFAQ props. */ -export const GMOD_FAQS = [ - { - question: "Can I install addons and gamemodes from the Steam Workshop?", - answer: - "Yes! Full Steam Workshop integration is included. You can add Workshop collections to your server config and addons will download automatically for your players on join.", - }, - { - question: "Which gamemodes are supported?", - answer: - "All gamemodes are supported — DarkRP, TTT, Prophunt, Murder, Sandbox, and any custom ones. You have full file access to install and configure whatever your community needs.", - }, - { - question: "Do you provide MySQL databases for DarkRP?", - answer: - "Yes. MySQL databases are included with our Garry's Mod plans so you can run DarkRP and other database-backed gamemodes without needing an external database host.", - }, - { - question: "Can I run multiple gamemodes or server instances?", - answer: - "Each plan covers one server instance. To run multiple gamemodes simultaneously, you would need separate plans for each server.", - }, - { - question: "Can I choose my server location?", - answer: - "Yes! You can select your preferred data centre location at checkout. We offer multiple locations across Europe and the Americas for the best latency.", - }, -] as const - -/** Static hero feature pills. */ -export const GMOD_HERO_FEATURES = [ - "Workshop Addons", - "DarkRP Ready", - "MySQL Included", - "DDoS Protection", -] as const - -/** Visual + display config for the Garry's Mod hosting page. */ -export const GMOD_CONFIG = { - name: "Garry's Mod", - description: - "High-performance Garry's Mod server hosting with full Steam Workshop support, MySQL integration, and enterprise DDoS protection. Built for any gamemode.", - banner: "/games/gmod.png", - tag: "Coming Soon", - /** String name matching a lucide-react icon — instantiate in the page component. */ - 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", -} as const diff --git a/packages/core/constants/game/hytale.ts b/packages/core/constants/game/hytale.ts deleted file mode 100644 index f5e0e95..0000000 --- a/packages/core/constants/game/hytale.ts +++ /dev/null @@ -1,178 +0,0 @@ -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. - */ -export const HYTALE_PLAN_DISPLAY = { - "hytale-starter": { - name: "Starter", - description: "Perfect for small communities and testing.", - }, - "hytale-standard": { - name: "Standard", - description: "Perfect for growing communities and performance.", - }, - "hytale-performance": { - name: "Performance", - description: "Perfect for large communities and high performance.", - }, -} as const satisfies Record - -/** - * Static features shared across all Hytale plans. - * The page appends the parametric RAM/storage strings built from each plan's spec. - */ -export const HYTALE_PLAN_STATIC_FEATURES = [ - "AMD Ryzen™ 9 5900X", - "10 MySQL Databases", - "DDoS Protection", - "BytePanel", - "99.9% Uptime SLA", -] as const - -/** Hytale doesn't have translation keys yet — feature data is stored inline. */ -export const HYTALE_FEATURES = [ - { - title: "Instant Setup", - description: - "Once purchased, the server will install instantly with a performance plugin for optimal performance.", - icon: "Zap" as const, - highlights: [ - "Fast install", - "Optimized configurations", - "Pre-built templates", - "Quick deployment", - ], - }, - { - title: "Mod Support", - description: - "Full support for Hytale's modding capabilities. Create and host your custom experiences.", - icon: "Settings" as const, - highlights: [ - "Custom mod support", - "Easy mod management", - "Auto-updates", - "Full file access", - ], - }, - { - title: "High Performance", - description: - "Enterprise grade hardware ready to deliver smooth gameplay for your Hytale community.", - icon: "Cpu" as const, - highlights: [ - "Latest gen CPUs", - "NVMe SSD storage", - "High-speed networking", - "Low latency", - ], - }, - { - title: "DDoS Protection", - description: - "Your server will be protected by enterprise-grade DDoS mitigation from day one.", - icon: "Shield" as const, - highlights: [ - "Always-on protection", - "Automated network filtering", - "Zero downtime", - "Global POPs", - ], - }, - { - title: "Global Data Centers", - description: - "Servers hosted in strategically located data centers for excellent latency wherever your players are.", - icon: "Globe" as const, - highlights: [ - "Multiple locations", - "Low latency routing", - "Premium network", - "Global coverage", - ], - }, - { - title: "24/7 Support", - description: - "Our expert support team will be ready to help you with any Hytale hosting questions.", - icon: "Server" as const, - highlights: [ - "24/7 availability", - "Game experts", - "Fast response times", - "Discord support", - ], - }, -] as const - -/** Hytale doesn't have translation keys yet — FAQ data is stored inline. */ -export const HYTALE_FAQS = [ - { - question: "What features are supported?", - answer: - "We support all Hytale server features including mods, custom worlds, and multiplayer.", - }, - { - question: "Will there be mod support?", - answer: "Yes, we support Hytale's modding capabilities.", - }, - { - question: "What regions will be available?", - answer: - "We'll offer Hytale hosting across multiple data center locations for low latency and great coverage wherever your players are.", - }, - { - 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. */ -export const HYTALE_HERO_FEATURES = [ - "Mod Support", - "Custom Maps", - "DDoS Protection", - "24/7 Support", -] as const - -export const HYTALE_CONFIG = { - name: "Hytale", - description: - "Set out on an adventure built for both creation and play. Hytale blends the freedom of a sandbox with the momentum of an RPG: explore a procedurally generated world full of dungeons, secrets, and a variety of creatures, then shape it block by block.", - banner: "/games/hytale.png", - tag: "Early Access Game", - /** String name matching a lucide-react icon — instantiate in the page component */ - iconName: "Sparkles", - 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", -} as const diff --git a/packages/core/constants/game/index.ts b/packages/core/constants/game/index.ts deleted file mode 100644 index 796df27..0000000 --- a/packages/core/constants/game/index.ts +++ /dev/null @@ -1,84 +0,0 @@ -export * from "./minecraft" -export * from "./rust" -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 deleted file mode 100644 index aa5aae4..0000000 --- a/packages/core/constants/game/minecraft.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { GamePlanSpec } from "@/packages/core/types/servers/game"; - -/** - * MINECRAFT PLAN LIST - * @type {GamePlanSpec} The Gameplan Typing Spec - */ -export const MINECRAFT_PLANS: GamePlanSpec[] = [ - { - id: "ember", - priceGBP: 4, - ramGB: 4, - storageGB: 40, - url: "https://billing.nodebyte.host/products/minecraft-server-hosting/ember", - }, - { - id: "blaze", - priceGBP: 6, - ramGB: 6, - storageGB: 60, - url: "https://billing.nodebyte.host/products/minecraft-server-hosting/blaze", - }, - { - id: "inferno", - priceGBP: 7.5, - ramGB: 8, - storageGB: 80, - popular: true, - url: "https://billing.nodebyte.host/products/minecraft-server-hosting/inferno", - }, - { - id: "firestorm", - priceGBP: 15, - ramGB: 16, - storageGB: 160, - popular: true, - url: "https://billing.nodebyte.host/products/minecraft-server-hosting/firestorm", - }, - { - id: "supernova", - priceGBP: 30, - ramGB: 32, - storageGB: 320, - popular: true, - url: "https://billing.nodebyte.host/products/minecraft-server-hosting/supernova", - }, -] - -/** - * 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", -] as const - -/** Static hero feature pills. */ -export const MINECRAFT_HERO_FEATURES = [ - "Forge & Fabric", - "Unlimited Players", - "DDoS Protection", - "24/7 Support", -] as const - -/** Visual + display config for the Minecraft hosting pages */ -export const MINECRAFT_CONFIG = { - name: "Minecraft", - 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", - /** String name matching a lucide-react icon — instantiate in the page component */ - iconName: "Blocks", - 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", -} as const diff --git a/packages/core/constants/game/palworld.ts b/packages/core/constants/game/palworld.ts deleted file mode 100644 index e232222..0000000 --- a/packages/core/constants/game/palworld.ts +++ /dev/null @@ -1,150 +0,0 @@ -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", - "10 MySQL Databases", - "DDoS Protection", - "BytePanel", - "99.9% Uptime SLA", -] as const - -/** Inline feature data — maps directly to GameFeatures props. */ -export const PALWORLD_FEATURES = [ - { - title: "Custom World Configuration", - description: - "Take full control of your Palworld server settings — adjust spawn rates, difficulty, XP multipliers, and more to suit your community.", - icon: "Settings" as const, - highlights: [ - "Difficulty settings", - "Spawn rate control", - "XP & drop multipliers", - "Time & weather config", - ], - }, - { - title: "Pal Data Persistence", - description: - "Your Pals, bases, and world progress are kept safe with reliable save management and automated backups.", - icon: "HardDrive" as const, - highlights: [ - "Reliable save management", - "Automated backups", - "Easy save restores", - "World seed support", - ], - }, - { - title: "Mod Support", - description: - "Install community mods to expand your Palworld experience with new Pals, items, maps, and gameplay tweaks.", - icon: "Zap" as const, - highlights: [ - "Community mod support", - "Easy mod installation", - "Full file access", - "FTP & file manager", - ], - }, - { - title: "DDoS Protection", - description: - "Enterprise-grade DDoS mitigation keeps your Palworld server online and your community protected around the clock.", - icon: "Shield" as const, - highlights: [ - "Always-on protection", - "Layer 3/4/7 filtering", - "Zero downtime", - "Global POPs", - ], - }, - { - title: "High Performance", - description: - "Powerful hardware ensures smooth gameplay for all players, even during intense base raids or large Pal battles.", - icon: "Cpu" as const, - highlights: [ - "High-clock AMD CPUs", - "NVMe SSD storage", - "Low latency networking", - "DDR4 ECC memory", - ], - }, - { - title: "Auto Backups", - description: - "Automated world backups protect your Palworld progress. Restore to any backup point with a single click from the control panel.", - icon: "Server" as const, - highlights: [ - "Scheduled backups", - "One-click restore", - "Multiple restore points", - "Safe world management", - ], - }, -] as const - -/** Inline FAQ data — maps directly to GameFAQ props. */ -export const PALWORLD_FAQS = [ - { - question: "How many players can join my Palworld server?", - answer: - "Palworld dedicated servers support up to 32 players by default. Our plans are provisioned with enough resources to keep gameplay smooth for all your players.", - }, - { - question: "Can I install mods on my Palworld server?", - answer: - "Yes! You can install community mods via FTP or the file manager. We provide full file access so you can customise your Palworld experience however you like.", - }, - { - question: "Is my Pal and world data backed up automatically?", - answer: - "Absolutely. We run automated backups of your world saves on a regular schedule. You can restore to any backup point from within the control panel.", - }, - { - question: "Can I customise world settings like spawn rates and difficulty?", - answer: - "Yes! All Palworld server configuration options are available to you, including difficulty levels, Pal spawn rates, XP multipliers, drop rates, and more.", - }, - { - question: "Can I transfer my single-player save to my dedicated server?", - answer: - "Yes, it is possible to transfer a single-player world save to a dedicated server. Our support team can assist you with the process if needed.", - }, - { - question: "What version of Palworld will you support?", - answer: - "We will keep servers updated with the latest stable Palworld dedicated server releases and will notify you of any updates that require server restarts.", - }, - { - 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. */ -export const PALWORLD_HERO_FEATURES = [ - "Custom World", - "Mod Support", - "Auto Backups", - "DDoS Protection", -] as const - -/** Visual + display config for the Palworld hosting page. */ -export const PALWORLD_CONFIG = { - name: "Palworld", - description: - "High-performance Palworld server hosting with full world customisation, mod support, automatic backups, and enterprise DDoS protection. The perfect home for your Pal-catching community.", - banner: "/games/palworld.png", - tag: "Coming Soon", - /** String name matching a lucide-react icon — instantiate in the page component. */ - 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", -} as const diff --git a/packages/core/constants/game/rust.ts b/packages/core/constants/game/rust.ts deleted file mode 100644 index 3fe8779..0000000 --- a/packages/core/constants/game/rust.ts +++ /dev/null @@ -1,94 +0,0 @@ -import type { GamePlanSpec } from "@/packages/core/types/servers/game"; - -export const RUST_PLANS: GamePlanSpec[] = [ - { - id: "starter", - priceGBP: 5.75, - ramGB: 8, - storageGB: 150, - url: "https://billing.nodebyte.host/products/rust-hosting/starter" - }, - { - id: "standard", - priceGBP: 8.95, - ramGB: 12, - storageGB: 200, - popular: true, - url: "https://billing.nodebyte.host/products/rust-hosting/standard" - }, - { - id: "performance", - priceGBP: 12.75, - ramGB: 16, - storageGB: 250, - url: "https://billing.nodebyte.host/products/rust-hosting/performance" - }, - { - id: "premium", - priceGBP: 18.99, - ramGB: 32, - storageGB: 350, - url: "https://billing.nodebyte.host/products/rust-hosting/premium" - }, -] - -/** - * 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", -] as const - -/** Static hero feature pills. */ -export const RUST_HERO_FEATURES = [ - "Oxide/uMod", - "Custom Maps", - "Wipe Scheduler", - "RCON Access", -] as const - -/** Visual + display config for the Rust hosting pages */ -export const RUST_CONFIG = { - name: "Rust", - description: - "High-performance Rust server hosting with Oxide/uMod support, custom maps, wipe scheduling, and enterprise-grade DDoS protection.", - banner: "/games/rust.png", - /** String name matching a lucide-react icon — instantiate in the page component */ - iconName: "Gamepad2", - tagColor: "bg-accent/10 border border-accent/20 text-accent", - headerGradient: "from-accent/20 via-accent/10 to-primary/5", - headerIconBg: "bg-accent/10 text-accent", -} as const diff --git a/packages/core/constants/game/terraria.ts b/packages/core/constants/game/terraria.ts deleted file mode 100644 index 9c89bfc..0000000 --- a/packages/core/constants/game/terraria.ts +++ /dev/null @@ -1,139 +0,0 @@ -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", - "DDoS Protection", - "BytePanel", - "99.9% Uptime SLA", -] as const - -/** Inline feature data — maps directly to GameFeatures props. */ -export const TERRARIA_FEATURES = [ - { - title: "Mod Support", - description: - "Full tModLoader support so you and your players can install and manage mods directly from the control panel with zero hassle.", - icon: "Settings" as const, - highlights: [ - "tModLoader ready", - "Mod manager", - "Auto-restart on crash", - "Easy mod updates", - ], - }, - { - title: "Custom World Generation", - description: - "Configure world size, difficulty, seed, and game mode at creation. Reset or create multiple worlds at any time with a single click.", - icon: "Map" as const, - highlights: [ - "Small / Medium / Large worlds", - "Custom seeds", - "Multiple worlds", - "Journey, Classic & Expert modes", - ], - }, - { - title: "Automatic Backups", - description: - "Scheduled world and character backups protect your progress. Restore to any snapshot with one click in case of accidents or corruption.", - icon: "Server" as const, - highlights: [ - "Scheduled backups", - "One-click restore", - "Multiple restore points", - "World file download", - ], - }, - { - title: "DDoS Protection", - description: - "Enterprise-grade DDoS mitigation keeps your Terraria server online and your adventurers safe at all times.", - icon: "Shield" as const, - highlights: [ - "Always-on protection", - "Layer 3/4/7 filtering", - "Zero downtime", - "Global POPs", - ], - }, - { - title: "High Performance", - description: - "Low latency hardware chosen for smooth Terraria workloads, keeping your world responsive even during boss events and large build projects.", - icon: "Cpu" as const, - highlights: [ - "High-clock AMD CPUs", - "NVMe SSD storage", - "Low latency networking", - "DDR4 ECC memory", - ], - }, - { - title: "Simple Management", - description: - "BytePanel gives you full control — start, stop, restart, manage files, view console output, and configure your server all in one place.", - icon: "Users" as const, - highlights: [ - "Web-based control panel", - "Live console", - "File manager", - "Player & ban management", - ], - }, -] as const - -/** Inline FAQ data — maps directly to GameFAQ props. */ -export const TERRARIA_FAQS = [ - { - question: "Does my server support tModLoader?", - answer: - "Yes! We fully support tModLoader so you can install and run mods directly. You can switch between vanilla and tModLoader from your control panel.", - }, - { - question: "How many players can join my Terraria server?", - answer: - "Terraria officially supports up to 255 simultaneous players. Our plans are sized to handle typical community servers comfortably.", - }, - { - question: "Can I create multiple worlds on one server?", - answer: - "Yes, you can store and switch between multiple world files. Simply upload your worlds via FTP or the file manager and select the active world in the config.", - }, - { - question: "Will my world be backed up automatically?", - answer: - "Yes. Automatic scheduled backups are included. You can restore to any snapshot from your control panel in case of corruption or accidental changes.", - }, - { - question: "Can I choose my server location?", - answer: - "Yes! You can select your preferred data centre location at checkout. We offer multiple locations across Europe and the Americas for the best latency.", - }, -] as const - -/** Static hero feature pills. */ -export const TERRARIA_HERO_FEATURES = [ - "tModLoader", - "Custom Worlds", - "Auto Backups", - "DDoS Protection", -] as const - -/** Visual + display config for the Terraria hosting page. */ -export const TERRARIA_CONFIG = { - name: "Terraria", - description: - "High-performance Terraria server hosting with full tModLoader support, custom world configuration, automatic backups, and enterprise DDoS protection. Built for adventurers.", - banner: "/games/terraria.png", - tag: "Coming Soon", - /** String name matching a lucide-react icon — instantiate in the page component. */ - 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", -} as const diff --git a/packages/core/constants/node-types.ts b/packages/core/constants/node-types.ts index 7b52be5..1307482 100644 --- a/packages/core/constants/node-types.ts +++ b/packages/core/constants/node-types.ts @@ -3,10 +3,10 @@ export interface PublicNode { name: string locationCode: string isMaintenanceMode: boolean - /** Allocated memory in MiB */ - memory: number - /** Allocated disk in MiB */ - disk: number + /** Allocated memory in MiB — not known for nodes without a display override. */ + memory?: number + /** Allocated disk in MiB — not known for nodes without a display override. */ + disk?: number /** Number of server instances currently provisioned on this node */ serverCount?: number } diff --git a/packages/core/constants/partners.ts b/packages/core/constants/partners.ts new file mode 100644 index 0000000..c262da6 --- /dev/null +++ b/packages/core/constants/partners.ts @@ -0,0 +1,106 @@ +/** + * Partners, sponsors, and communities shown on /partners. + * + * Add or remove entries here — the page groups them by `tier` into their own + * section automatically, hiding any tier with zero entries, so this file can + * be edited freely without touching the page component. + * + * - "sponsor" / "partner": companies and projects we have a business + * relationship with. + * - "community": servers/communities we host or sponsor for free or at a + * discount (Minecraft servers, Discord communities, etc). Set `category` + * to a short label like "Minecraft Server" or "Discord Community" — it + * renders as a badge on the card. + */ + +export type PartnerTier = "sponsor" | "partner" | "community" + +export interface PartnerEntry { + id: string + name: string + tier: PartnerTier + /** Short badge label, e.g. "Minecraft Server", "Discord Community". Optional — mainly useful for the community tier. */ + category?: string + /** Path under /public, e.g. "/partners/example.svg" */ + logo: string + url: string + description: string +} + +export const PARTNERS: PartnerEntry[] = [ + { + id: "fyfeweb", + name: "FyfeWeb", + tier: "partner", + logo: "/partners/fyfeweb.png", + url: "https://fyfeweb.com", + description: "From a single website to racks of your own hardware: one provider, one network, one support team.", + }, + { + id: "poliberry", + name: "Poliberry", + tier: "partner", + logo: "/partners/poliberry.png", + url: "https://poliberry.com", + description: "Poliberry is a technology company building tools and services to better connect people online and empowering developers to build the next big thing.", + }, + { + id: "embrly", + name: "Emberly", + tier: "partner", + logo: "/partners/emberly.svg", + url: "https://embrly.ca", + description: "The open-source platform for secure file sharing and team collaboration. Upload, manage, and share content with custom domains, rich embeds, and built-in talent discovery.", + }, + { + id: "clovrme", + name: "Clover", + tier: "partner", + logo: "/partners/clovrme.svg", + url: "https://clovr.me", + description: "A profile page that's fully yours custom themes, music, animated backgrounds, and all your links in one place.", + }, + { + id: "octo", + name: "Octoflow", + tier: "partner", + logo: "/partners/octoflow.png", + url: "https://octoflow.ca", + description: "Keep your team connected to every commit, pull request, and deployment without ever leaving your Discord server.", + }, + { + id: "antiraid", + name: "AntiRaid", + tier: "partner", + logo: "/partners/antiraid.webp", + url: "https://antiraid.xyz", + description: "From basic moderation to advanced threat protection, AntiRaid handles it all so you can focus on growing your community.", + }, + { + id: "fluxrp", + name: "Flux.LT", + tier: "community", + category: "FiveM Community", + logo: "/partners/fluxrp.png", + url: "https://discord.gg/XAKudt7C82", + description: "Lithuania's premier FiveM roleplay community, bringing immersive story-driven RP and a tight-knit Baltic player base together.", + }, + { + id: "smphub", + name: "SMP Hub", + tier: "community", + category: "Minecraft Community", + logo: "/partners/smphub.webp", + url: "https://discord.gg/d6sXpA7gXJ", + description: "The premier directory designed to connect the Minecraft community.", + }, + { + id: "blizzardsmp", + name: "Blizzard SMP", + tier: "community", + category: "Minecraft Community", + logo: "/partners/blizzardsmp.webp", + url: "https://discord.gg/mvQ9VqZ4D", + description: "A warm, active community with a cool name and even cooler players.", + }, +] 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..06318bf --- /dev/null +++ b/packages/core/constants/status-mapping.ts @@ -0,0 +1,37 @@ +/** + * The node list on /nodes is discovered live from status.nodebyte.host's + * "Nodes" group (see getNodeMonitorNames in lib/status.ts) — adding a node + * there is all that's needed for it to appear on the site. + * + * status.nodebyte.host doesn't carry location/hardware details, so those are + * filled in here as optional per-node overrides, keyed by the exact monitor + * name. A node with no entry here still shows up, just without these extras. + */ +export const NODE_DISPLAY_OVERRIDES: Record = { + "NEWC-GAME1": { locationCode: "Newcastle, UK" }, + "NEWY-GAME1": { locationCode: "New York, USA" }, + "HEL-VPS1": { locationCode: "Helsinki, FI" }, + // Inferred from the "FSN" prefix (Falkenstein) — confirm/correct if wrong. + "FSN-VPS1": { locationCode: "Falkenstein, DE" }, +} + +/** + * 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. Update + * whenever a new Region ping monitor is added upstream — unmapped locations + * simply render without live data. + */ +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/constants/supported-games.ts b/packages/core/constants/supported-games.ts new file mode 100644 index 0000000..ad17d0f --- /dev/null +++ b/packages/core/constants/supported-games.ts @@ -0,0 +1,12 @@ +/** + * Games listed on /games as supported at checkout via Paymenter's product + * config option — there's no API to read this list live (the admin API's + * `include` allowlist has no config-options relationship), so it's + * maintained by hand here. Keep in sync with the checkout dropdown. + */ +export const SUPPORTED_GAMES = [ + "Minecraft", + "Hytale", + "Palworld", + "Rust", +] as const diff --git a/packages/core/constants/vps/amd.ts b/packages/core/constants/vps/amd.ts index ffcedec..85eec72 100644 --- a/packages/core/constants/vps/amd.ts +++ b/packages/core/constants/vps/amd.ts @@ -14,6 +14,7 @@ export const AMD_PLANS: VpsPlanSpec[] = [ description: "Perfect for small projects, dev environments, and personal sites.", cpuModel: "AMD Ryzen™ 7 1700X", priceGBP: 4.50, + setupFeeGBP: 0, cpu: 1, ramGB: 2, storageGB: 25, @@ -31,6 +32,7 @@ export const AMD_PLANS: VpsPlanSpec[] = [ description: "Great for growing web apps, APIs, and small databases.", cpuModel: "AMD Ryzen™ 7 1700X", priceGBP: 9, + setupFeeGBP: 0, cpu: 2, ramGB: 4, storageGB: 50, @@ -48,6 +50,7 @@ export const AMD_PLANS: VpsPlanSpec[] = [ description: "Ideal for production workloads, game backends, and high-traffic sites.", cpuModel: "AMD Ryzen™ 7 1700X", priceGBP: 18, + setupFeeGBP: 0, cpu: 4, ramGB: 8, storageGB: 100, @@ -66,6 +69,7 @@ export const AMD_PLANS: VpsPlanSpec[] = [ description: "Maximum power for demanding applications and resource-heavy services.", cpuModel: "AMD Ryzen™ 7 1700X", priceGBP: 40, + setupFeeGBP: 0, cpu: 4, ramGB: 16, storageGB: 200, @@ -84,6 +88,7 @@ export const AMD_PLANS: VpsPlanSpec[] = [ description: "Maximum power for demanding applications and resource-heavy services.", cpuModel: "AMD Ryzen™ 7 1700X", priceGBP: 45, + setupFeeGBP: 0, cpu: 8, ramGB: 32, storageGB: 500, @@ -102,6 +107,7 @@ export const AMD_PLANS: VpsPlanSpec[] = [ description: "Maximum power for demanding applications and resource-heavy services.", cpuModel: "AMD Ryzen™ 7 1700X", priceGBP: 60, + setupFeeGBP: 0, cpu: 8, ramGB: 64, storageGB: 1000, 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..d94fa62 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,177 @@ 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[] +} + /** - * 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. + * 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. */ -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) ?? "") +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) => { + const children = byParent.get(hub.id) ?? [] + return { + id: hub.id, + name: hub.name, + slug: hub.slug, + description: hub.description, + // Products can be filed directly on the hub category itself — either + // because it has no sub-categories at all yet (e.g. bare-metal + // servers filed straight under "Dedicated Servers"), or because it + // has real sub-categories but *also* some generic products of its + // own (e.g. unified "GAME-BASE"/"GAME-PLUS" tiers filed directly on + // "Game Servers" alongside its existing Minecraft/Rust/Hytale + // children). Always include the hub itself as a leaf, in addition to + // any real children, so pages that iterate `hub.children` never miss + // hub-level products. + children: [ + { id: hub.id, name: hub.name, slug: hub.slug, description: hub.description, parentId: null }, + ...children, + ], + } + }) +} + +export const getCachedCategoryTree = unstable_cache(fetchCategoryTree, ["billing-category-tree"], { + revalidate: 600, +}) + +/** + * Find a hub by its slugified name — accepts one or more acceptable aliases + * (e.g. "vps-hosting" or "vps") since the exact parent category name is + * whatever's configured in Paymenter. + * + * Falls back to a substring match (a hub whose slug *contains* one of the + * aliases) if no exact match is found, so a rename like "VPS Servers" → + * "VPS Plans 2026" doesn't silently empty out the whole section — it just + * warns, since the fallback match is a guess rather than a guarantee. + */ +export async function getCategoryHub(hubSlugOrAliases: string | string[]): Promise { + const aliases = Array.isArray(hubSlugOrAliases) ? hubSlugOrAliases : [hubSlugOrAliases] + const tree = await getCachedCategoryTree() + + const exact = tree.find((hub) => aliases.includes(hub.slug)) + if (exact) return exact + + const fuzzy = tree.find((hub) => aliases.some((alias) => hub.slug.includes(alias))) + if (fuzzy) { + console.warn( + `[bytepay] No category exactly matched hub aliases [${aliases.join(", ")}] — falling back to "${fuzzy.name}" (slug "${fuzzy.slug}") via substring match. Rename the Paymenter category, or add its slug to the alias list, to make this exact.`, + ) + return fuzzy + } + + return 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 +348,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 +368,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 +377,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++ @@ -237,6 +436,30 @@ export function getPricesMap(product: BillingProduct): Record { return map } +/** Resolve the GBP one-time setup fee from the product's recurring plan (0 if none). */ +export function getSetupFeeGBP(product: BillingProduct): number { + const plan = + product.plans.find((p) => p.type === "recurring" && p.billingPeriod === 1 && p.billingUnit === "month") + ?? product.plans.find((p) => p.type === "recurring") + ?? product.plans[0] + if (!plan) return 0 + return plan.prices.find((p) => p.currencyCode === "GBP")?.setupFee ?? 0 +} + +/** Return a map of currency code → one-time setup fee from the product's recurring plan. */ +export function getSetupFeesMap(product: BillingProduct): Record { + const plan = + product.plans.find((p) => p.type === "recurring" && p.billingPeriod === 1 && p.billingUnit === "month") + ?? product.plans.find((p) => p.type === "recurring") + ?? product.plans[0] + if (!plan) return {} + const map: Record = {} + for (const price of plan.prices) { + map[price.currencyCode] = price.setupFee + } + return map +} + /** Map billing stock to the website StockStatus type. */ export function getStockStatus( product: BillingProduct, diff --git a/packages/core/lib/spec-parser.ts b/packages/core/lib/spec-parser.ts index 75383d6..d19f54b 100644 --- a/packages/core/lib/spec-parser.ts +++ b/packages/core/lib/spec-parser.ts @@ -14,38 +14,82 @@ 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 hardware?: "amd" | "intel" | "arm" /** Short description extracted from the first bullet's subtitle. */ description?: string + /** Location(s) named in a "location"/"region" bullet, e.g. "Newcastle, UK / New York, US" — omitted if none named. */ + location?: string + /** Number of databases included, e.g. "3x MySQL Databases" → 3. */ + databases?: number + /** Whether the description mentions automatic/included backups. */ + backups?: boolean } function stripHtml(html: string): string { return html.replace(/<[^>]*>/g, " ").replace(/&/g, "&").replace(/\s+/g, " ").trim() } -/** Split stripped text into individual bullet lines for more reliable matching. */ -function bulletLines(text: string): string[] { +/** + * Split into individual bullet lines. Paymenter descriptions are usually a + * `
  • ` list (one bullet per `
  • `); older ones instead separate + * bullets with a "•" character or bare newlines. Insert a line break at each + * list-item/paragraph/`
    ` boundary before stripping tags so line-based + * extraction below sees one bullet per line regardless of which format the + * description actually uses. + */ +function bulletLines(html: string): string[] { + const withBreaks = html + .replace(/<\/(li|p|div|h[1-6])>/gi, "\n") + .replace(//gi, "\n") + const text = withBreaks + .replace(/<[^>]*>/g, " ") + .replace(/&/g, "&") + .replace(/>/g, ">") + .replace(/</g, "<") + .replace(/"/g, '"') + .replace(/�?39;/g, "'") + .replace(/ /g, " ") + .replace(/[ \t]+/g, " ") return text .split(/[•\n]/) - .map((s) => s.trim()) + .map((s) => s.trim().replace(/^>\s*/, "")) .filter(Boolean) } +/** First " GB|TB" in a line, converted to GB (TB × 1024). Returns undefined if the line has none. */ +function firstSizeGB(line: string): number | undefined { + const m = line.match(/(\d+)\s*(GB|TB)\b/i) + if (!m) return undefined + const amount = parseInt(m[1]) + return /tb/i.test(m[2]) ? amount * 1024 : amount +} + export function parseDescriptionSpecs(html: string | null): ParsedSpecs { if (!html) return {} const text = stripHtml(html) - const lines = bulletLines(text) + const lines = bulletLines(html) // ── CPU cores ────────────────────────────────────────────────────────────── // "1 Core of Ryzen 7 Power", "2 Cores of Ryzen 7", "2 Ampere® Altra® ARM64 Cores" - // Fallback: "8 cores and 16 threads", "Octa-Core", "Quad-Core" + // "ELITE Resource Allocation: 8 Dedicated/Pinned Physical Cores and 16 threads" + // Fallback: "8 cores and 16 threads", "Octa-Core", "Quad-Core", "2 vCPU" + // + // Deliberately NOT a "grab any number near the word core/cpu" fallback — + // descriptions also state the CPU *model* number in the same breath + // ("Ryzen™ 9 5900X", "Core Ultra 7 265"), and a wrong core count is worse + // than none: it ships incorrect specs instead of just dropping the plan + // (which surfaces via warnDroppedProduct so it gets fixed at the source). const NAMED_CORES: Record = { mono: 1, dual: 2, quad: 4, hexa: 6, octa: 8, deca: 10, dodeca: 12 } let cpu: number | undefined for (const line of lines) { @@ -56,9 +100,18 @@ export function parseDescriptionSpecs(html: string | null): ParsedSpecs { } } if (!cpu) { - // "8 cores and 16 threads" — number before "cores" anywhere in text - const m = text.match(/\b(\d+)\s+[Cc]ores?\b/) - if (m) cpu = parseInt(m[1]) + // Number and "Cores" in the same bullet but not at the start of it — e.g. + // "ELITE Resource Allocation: 8 Dedicated/Pinned Physical Cores and 16 + // threads". Allow up to 4 filler words between the number and "Cores", + // bounded per-line so it can't reach into an unrelated bullet. + // Requires the plural "Cores", not "Core" — singular "Core" shows up in + // non-count marketing phrases too ("Ryzen™ 9 5900X Core Processing", + // "Core Ultra 7 265"), where the preceding number is a CPU model number, + // not a core count, and guessing wrong is worse than leaving it unset. + for (const line of lines) { + const m = line.match(/(\d+)\s+(?:[\w/.-]+\s+){0,4}?[Cc]ores\b/) + if (m) { cpu = parseInt(m[1]); break } + } } if (!cpu) { // "Octa-Core", "Quad-Core" etc @@ -66,34 +119,46 @@ 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) - const ramGB = ramMatch ? parseInt(ramMatch[1]) : undefined + // Any bullet mentioning "RAM" or "memory" is classified as the RAM line; + // the first GB figure in that line is the amount, wherever it sits. + // "2 GB DDR4 RAM", "4 GB ECC RAM", "RAM: 16GB of fast memory" + const ramLine = lines.find((line) => /\bram\b|\bmemory\b/i.test(line)) + const ramGB = ramLine ? firstSizeGB(ramLine) : undefined + const ramTypeMatch = ramLine ? ramLine.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" - // 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 storageMatchTB = !storageMatchGB - ? text.match(/(\d+)\s*TB\s+(?:NVMe\s+|Enterprise\s+|SATA\s+)?(?:SSD|HDD|Disk)/i) - : null - const storageGB = storageMatchGB - ? parseInt(storageMatchGB[1]) - : storageMatchTB - ? parseInt(storageMatchTB[1]) * 1024 - : undefined + // Any bullet mentioning a storage-ish keyword is classified as the storage + // line; the first GB/TB figure in it is the amount. Tolerates arbitrary + // wording/ordering — "80 GB Local NVMe Storage", "Storage: 80GB of blazing + // SSD space", "2 x 1 TB NVMe SSD (RAID 1)" all resolve the same way. + const storageLine = lines.find((line) => /\b(?:ssd|hdd|nvme|storage|disk|drive)\b/i.test(line)) + const storageGB = storageLine ? firstSizeGB(storageLine) : undefined + + const storageType: ParsedSpecs["storageType"] = storageLine + ? /nvme/i.test(storageLine) + ? "nvme" + : /ssd/i.test(storageLine) + ? "ssd" + : /hdd/i.test(storageLine) + ? "hdd" + : "generic" + : undefined // Raw storage label for multi-drive dedicated configs - let storageDescription: string | undefined - for (const line of lines) { - if (/\b(?:NVMe|SSD|HDD)\b/i.test(line)) { - const beforeColon = line.split(':')[0].trim() - if (beforeColon.length > 4 && beforeColon.length < 80) storageDescription = beforeColon - break - } - } + const storageDescription = storageLine + ? (() => { + const beforeColon = storageLine.split(':')[0].trim() + return beforeColon.length > 4 && beforeColon.length < 80 ? beforeColon : undefined + })() + : undefined // ── Uplink ───────────────────────────────────────────────────────────────── // "1 Gbps Network Port", "4 Gbps" @@ -122,7 +187,9 @@ export function parseDescriptionSpecs(html: string | null): ParsedSpecs { let cpuModel: string | undefined let hardware: ParsedSpecs["hardware"] - const ryzenMatch = text.match(/AMD\s+Ryzen[™™]?\s+\d+(?:\s+(?:PRO\s+)?\d+\w*)?/i) + // "AMD Ryzen™ 9 5900X", "Ryzen 3700X" (single model token, no series digit, + // "AMD" prefix not always stated — "Ryzen" alone is unambiguously AMD) + const ryzenMatch = text.match(/(?:AMD\s+)?Ryzen[™™]?\s+(?:\d+\s+)?(?:PRO\s+)?\d+\w*/i) const ampereMatch = text.match(/Ampere[®®]?\s+Altra[®®]?(?:\s+ARM64)?/i) // Intel: Xeon, Core Ultra, Core i-series const intelMatch = text.match(/Intel[®®]?\s+(?:Core[™™]?\s+Ultra\s+\d+(?:\s+\d+)?|Core[™™]?\s+i\d+[- ]\d+\w*|Xeon[®®]?(?:\s+\w+)*)/i) @@ -144,7 +211,37 @@ export function parseDescriptionSpecs(html: string | null): ParsedSpecs { if (m) { description = m[1].trim(); break } } - return { cpu, ramGB, storageGB, storageDescription, bandwidth, uplink, cpuModel, hardware, description } + // ── Location ─────────────────────────────────────────────────────────────── + // "Flexible Regional Hosting: ... choose between Newcastle, UK or New York, + // US ..." — pull "City, XX" style place names out of any bullet mentioning + // location/region, joining multiple choices with " / ". + let location: string | undefined + const locationLine = lines.find((line) => /\blocation|\bregion/i.test(line)) + if (locationLine) { + const places = locationLine.match(/\b[A-Z][a-zA-Z]+(?:\s[A-Z][a-zA-Z]+)*,\s*[A-Z]{2,3}\b/g) + if (places && places.length > 0) location = places.join(" / ") + } + + // ── Databases ────────────────────────────────────────────────────────────── + // "3x MySQL Databases", "5x MySQL Databases Included", "10 isolated MySQL databases" + let databases: number | undefined + const dbMatch = text.match(/\b(\d+)x?\s+(?:isolated\s+)?(?:MySQL\s+|PostgreSQL\s+|SQL\s+)?[Dd]atabases?\b/) + if (dbMatch) databases = parseInt(dbMatch[1]) + + // ── Backups ──────────────────────────────────────────────────────────────── + const backups = /\bbackups?\b/i.test(text) || undefined + + return { cpu, ramGB, ramType, storageGB, storageDescription, storageType, bandwidth, uplink, cpuModel, hardware, description, location, databases, backups } +} + +/** 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..ed2016b --- /dev/null +++ b/packages/core/lib/status.ts @@ -0,0 +1,138 @@ +/** + * 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 +} + +/** + * Names of individual node monitors under the "Nodes" status group — this is + * the live source of truth for which nodes exist on /nodes. Excludes the + * "group"-type aggregate rollups (e.g. "Game Servers", "VPS Servers") that + * summarise the individual node monitors rather than representing one. + * Add a node on status.nodebyte.host under the "Nodes" group and it appears + * here automatically — no website code change needed. + */ +export function getNodeMonitorNames(snapshot: StatusSnapshot | null): string[] { + if (!snapshot) return [] + return snapshot.monitors + .filter((m) => m.group_name === "Nodes" && m.type !== "group") + .map((m) => m.name) +} + +/** 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..6ca3075 100644 --- a/packages/core/products/billing-service.ts +++ b/packages/core/products/billing-service.ts @@ -7,11 +7,14 @@ import { getProductsByCategory, getGbpPrice, getPricesMap, + getSetupFeeGBP, + getSetupFeesMap, 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 +22,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,17 +48,33 @@ 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, + category: categorySlug, + location: parsed.location, + description: parsed.description, + databases: parsed.databases, + backups: parsed.backups, 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), prices: getPricesMap(product), + setupFeeGBP: getSetupFeeGBP(product), + setupFees: getSetupFeesMap(product), stock: getStockStatus(product), url: getBillingUrl(categorySlug, product.slug), } satisfies GamePlanSpec, @@ -57,14 +92,20 @@ 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 [ { id: product.slug, hardware: parsed.hardware as DedicatedPlanSpec["hardware"], cpuModel: parsed.cpuModel, + location: parsed.location, description: parsed.description, + databases: parsed.databases, + backups: parsed.backups, cores: parsed.cpu, ramGB: parsed.ramGB, storageGB: parsed.storageGB, @@ -74,6 +115,8 @@ export async function getDedicatedPlans(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 [ { @@ -103,8 +154,10 @@ export async function getVpsPlans(categorySlug: string): Promise series: series as VpsPlanSpec["series"], hardware: parsed.hardware, cpuModel: parsed.cpuModel, - location: undefined, + location: parsed.location, description: parsed.description, + databases: parsed.databases, + backups: parsed.backups, cpu: parsed.cpu, ramGB: parsed.ramGB, storageGB: parsed.storageGB, @@ -114,6 +167,8 @@ export async function getVpsPlans(categorySlug: string): Promise popular: POPULAR_SLUGS.has(`${categorySlug}/${product.slug}`), priceGBP: getGbpPrice(product), prices: getPricesMap(product), + setupFeeGBP: getSetupFeeGBP(product), + setupFees: getSetupFeesMap(product), stock: getStockStatus(product), url: getBillingUrl(categorySlug, product.slug), } satisfies VpsPlanSpec, 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/dedicated.ts b/packages/core/types/servers/dedicated.ts index af15269..37e63ce 100644 --- a/packages/core/types/servers/dedicated.ts +++ b/packages/core/types/servers/dedicated.ts @@ -16,9 +16,17 @@ export interface DedicatedPlanSpec { id: string description?: string + /** Number of databases included, e.g. "3x MySQL Databases" → 3. */ + databases?: number + /** Whether the description mentions automatic/included backups. */ + backups?: boolean cpuModel?: string hardware?: "amd" | "intel" priceGBP: number + /** One-time setup fee in GBP (0 if none). */ + setupFeeGBP: number + /** Native one-time setup fees per currency code. */ + setupFees?: Record /** Physical CPU cores. May be undefined if not listed in the product description. */ cores?: number ramGB: number diff --git a/packages/core/types/servers/game.ts b/packages/core/types/servers/game.ts index dc99167..46f9a66 100644 --- a/packages/core/types/servers/game.ts +++ b/packages/core/types/servers/game.ts @@ -16,11 +16,27 @@ */ 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 + /** Paymenter category slug this plan was fetched from, e.g. "minecraft" — plans are shown in one unified grid regardless, this is just for search/de-duplication. */ + category: string description?: string + /** Number of databases included, e.g. "3x MySQL Databases" → 3. */ + databases?: number + /** Whether the description mentions automatic/included backups. */ + backups?: boolean cpuModel?: string priceGBP: number + /** One-time setup fee in GBP (0 if none). */ + setupFeeGBP: number + /** Native one-time setup fees per currency code. */ + setupFees?: Record 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/core/types/servers/vps.ts b/packages/core/types/servers/vps.ts index d57769b..a2c06e8 100644 --- a/packages/core/types/servers/vps.ts +++ b/packages/core/types/servers/vps.ts @@ -17,8 +17,16 @@ export interface VpsPlanSpec { id: string description?: string + /** Number of databases included, e.g. "3x MySQL Databases" → 3. */ + databases?: number + /** Whether the description mentions automatic/included backups. */ + backups?: boolean cpuModel?: string priceGBP: number + /** One-time setup fee in GBP (0 if none). */ + setupFeeGBP: number + /** Native one-time setup fees per currency code. */ + setupFees?: Record cpu: number ramGB: number storageGB: number diff --git a/packages/kb/content/billing/account-credits.md b/packages/kb/content/billing/account-credits.md new file mode 100644 index 0000000..1a0e298 --- /dev/null +++ b/packages/kb/content/billing/account-credits.md @@ -0,0 +1,32 @@ +--- +title: Managing Pre-Funded Account Credit +description: How to add and utilize pre-funded account credits to streamline automated renewals and protect nodes from bank drops. +tags: [billing, wallet, credit, payments] +author: NodeByte Team +lastUpdated: 2026-07-08 +order: 3 +--- + +# Managing Pre-Funded Account Credit + +NodeByte supports a pre-funded wallet system within the client portal. This feature allows developers, systems administrators, and community managers to maintain an active cash balance on their profiles, protecting continuous infrastructure workloads against sudden banking flags, card expirations, or transaction declines. + +When a renewal invoice is generated, the system will automatically exhaust any available account credit before attempting to process your primary on-file payment method. + +## Depositing Funds to Your Account + +1. Authenticate your session on the Client Portal: **[billing.nodebyte.host](https://billing.nodebyte.host)**. +2. Navigate to [**Account** → **Credits**](https://billing.nodebyte.host/account/credits) via the interface menu. +3. Input the exact currency amount you wish to add to your balance. +4. Select your preferred deposit gateway: **Stripe** (for credit/debit cards) or **PayPal**. +5. Finalize the transaction through the secure routing window. + +Once confirmed by the payment gateway, the funds will immediately populate as an active balance on your account dashboard. + +## System Credit Mechanics + +* **Automated Settlement Priority:** When a service renewal invoice generates (typically 5 to 7 days prior to your service expiration date), our billing engine automatically checks your profile balance. If credit is present, it is applied immediately to settle or reduce the invoice. +* **Partial Invoice Mitigation:** If an invoice total exceeds your available credit, the billing system will exhaust your wallet balance first, then issue a revised invoice showing only the remaining unpaid balance. +* **Non-Refundable Parameters:** Deposited account credits are strictly non-refundable and hold zero cash-out value. Deposited balances are securely locked to your NodeByte profile to cover future hosting fees, hardware upgrades, or addon assignments. + +> **Recommendation:** If you host production-grade API systems, active storage nodes, or large multiplayer communities, keeping a one-to-two month runtime buffer in your account credit balance is an effective strategy to insulate your services from abrupt merchant-side processing interruptions. diff --git a/packages/kb/content/billing/client-portal.md b/packages/kb/content/billing/client-portal.md index e5f9fb4..57591ec 100644 --- a/packages/kb/content/billing/client-portal.md +++ b/packages/kb/content/billing/client-portal.md @@ -1,259 +1,90 @@ --- title: Client Portal Overview -description: Learn how to navigate and use the NodeByte client portal for managing your account and services. -tags: [billing, whmcs, account, portal] +description: A complete guide to navigating the NodeByte client portal, managing active services, and handling core account settings. +tags: [billing, account, client portal, navigation] author: NodeByte Team -lastUpdated: 2025-12-21 +lastUpdated: 2026-07-08 order: 1 --- # Client Portal Overview -The NodeByte Client Portal is where you'll manage your account, services, invoices, and support tickets. +The NodeByte Client Portal is the central management dashboard for your infrastructure. Within this panel, you can monitor your active hosting deployments, process invoices, adjust account profiles, and open technical support tickets. -## Accessing the Portal +## Portal Access -Visit [billing.nodebyte.host](https://billing.nodebyte.host) to access the client portal. +You can access the dashboard securely at **[billing.nodebyte.host](https://billing.nodebyte.host)**. -### First Time Login +### First-Time Authentication +If you are logging in for the first time following a purchase: +1. Navigate to the portal landing page. +2. Click **Login**. to start the authorization flow +3. Enter the email address and password credentials specified during checkout. +4. Complete any required multi-factor verification steps. -If you've just created an account: +### Password Recovery +If you lose your login credentials: +1. Click the **Forgot Password?** link on the authentication block. +2. Input your registered administrative email address. +3. Check your inbox for an automated reset token. +4. Click the link within the email to set a new secure password. -1. Go to [billing.nodebyte.host](https://billing.nodebyte.host) -2. Click **Login** -3. Enter the email and password you used during registration -4. Complete any verification if prompted +## Dashboard Layout -### Forgot Password? +Upon logging in, your main overview screen maps account data into four primary sections: -1. Click **Forgot Password?** on the login page -2. Enter your registered email address -3. Check your inbox for the reset link -4. Create a new secure password - -## Dashboard Overview - -After logging in, you'll see your main dashboard: - -| Section | Description | -|---------|-------------| -| **Your Active Products/Services** | All your current services | -| **Recent Invoices** | Latest billing information | -| **Recent Support Tickets** | Your support history | -| **Announcements** | Important updates from NodeByte | +| Section | Functional Scope | +| :--- | :--- | +| **Active Products & Services** | Displays your currently provisioned virtual environments and hardware nodes. | +| **Recent Invoices** | Lists pending renewals, outstanding bills, and completed transactions. | +| **Recent Support Tickets** | Tracks open, answered, or closed technical escalation logs. | +| **System Announcements** | Houses critical platform notifications, network updates, and maintenance schedules. (**NOTE**: there is no designated section for these they will be displayed in plain view on the dashboard home) | ## Managing Services -### Viewing Your Services - -1. Click **Services** → **My Services** -2. You'll see all your active, pending, and cancelled services -3. Click on any service for details - -### Service Details - -Each service page shows: - -- **Status** - Active, Suspended, Pending, etc. -- **Billing Cycle** - Monthly, Quarterly, Annually -- **Next Due Date** - When payment is due -- **Product/Service** - Your plan details -- **Quick Actions** - Manage, Upgrade, Request Cancellation - -### Accessing the Game Panel - -From your service page: - -1. Look for **Login to Game Panel** or similar button -2. This will redirect you to [panel.nodebyte.host](https://panel.nodebyte.host) -3. Use the same credentials or the ones provided in your welcome email - -## Invoices & Payments - -### Viewing Invoices - -1. Click **Billing** → **My Invoices** -2. See all invoices: Paid, Unpaid, Cancelled - -### Invoice Statuses - -| Status | Meaning | -|--------|---------| -| **Paid** | Payment received | -| **Unpaid** | Awaiting payment | -| **Overdue** | Payment past due date | -| **Cancelled** | Invoice voided | - -### Payment Methods - -We accept various payment methods: - -- 💳 Credit/Debit Cards (Visa, Mastercard, Amex) -- 🅿️ PayPal -- ₿ Cryptocurrency (Bitcoin, Ethereum, etc.) - -### Adding Funds - -You can add funds to your account balance: - -1. Go to **Billing** → **Add Funds** -2. Enter the amount -3. Choose payment method -4. Complete the transaction - -Account credit is automatically applied to future invoices. - -## Support Tickets - -### Opening a Ticket - -1. Click **Support** → **Open Ticket** -2. Choose a department: - - **General Support** - General questions - - **Technical Support** - Server issues - - **Billing** - Payment/invoice issues - - **Sales** - Pre-sales questions -3. Enter a descriptive subject -4. Provide detailed information -5. Attach files if needed -6. Click **Submit** - -### Ticket Best Practices - -For faster resolution: - -- ✅ Include your server name/IP -- ✅ Describe the issue clearly -- ✅ Include error messages or screenshots -- ✅ List steps you've already tried -- ❌ Don't open duplicate tickets -- ❌ Don't include sensitive passwords in tickets - -### Viewing Ticket History - -1. Click **Support** → **Tickets** -2. View all Open, Answered, and Closed tickets -3. Click any ticket to view the full conversation +### Inspecting Your Deployments +To view the status of your infrastructure allocations, navigate to **Services** in the sidebar and select **My Services**. This ledger tracks the lifecycle state of each deployment, including `Active`, `Suspended`, `Pending`, or `Cancelled`. Clicking on an individual service open its granular management area. -## Account Settings +### Accessing the Game Control Panel +While billing and account modifications occur inside the Client Portal, your server files, terminal console, and application configurations are housed inside our isolated game panel. -### Profile Information +1. Locate the **View Server** button within your active service page. +2. The portal will securely redirect your session to **[panel.nodebyte.host](https://panel.nodebyte.host)**. +3. Authenticate using the single-sign-on link or the standalone panel credentials provided in your initial deployment welcome email. -1. Click on your name → **Edit Account Details** -2. Update: - - Name and Company - - Email Address - - Phone Number - - Billing Address +## Invoices and Payment Processing -### Changing Password +### Auditing Financial Records +To view your complete transaction history, navigate to **Billing** → **My Invoices**. Invoices will show one of the following statuses: -1. Go to **Hello, Name!** → **Change Password** -2. Enter your current password -3. Enter and confirm your new password -4. Click **Save Changes** +* **Paid:** Payment has been processed and verified. The service remains online. +* **Unpaid:** An invoice has generated and is awaiting manual payment or an automated card debit. +* **Overdue:** The payment deadline has passed. The attached service is nearing automated suspension. +* **Cancelled:** The invoice has been voided or resolved by the billing team. -### Security Settings +### Supported Settlement Gateways +NodeByte utilizes two primary payment processors for all store transactions: +* **Stripe Engine:** Handles secure credit and debit card processing (Visa, Mastercard, American Express, Discover) along with supported digital wallets. +* **PayPal:** Supports manual one-time express checkouts or automatic recurring billing agreements. -We recommend enabling additional security: +## Technical Support Tickets -1. Go to **Security Settings** -2. Enable **Two-Factor Authentication** (2FA) -3. Download backup codes -4. Store them safely +### Submitting an Escalation +If you encounter a platform anomaly, infrastructure fault, or billing question, you can open a direct communication lane with our team: -## Service Management - -### Upgrading Your Plan - -Need more resources? - -1. Go to **Services** → **My Services** -2. Select your service -3. Click **Upgrade/Downgrade** -4. Choose your new plan -5. Pay the prorated difference - -### Requesting Cancellation - -To cancel a service: - -1. Go to **Services** → **My Services** -2. Select the service -3. Click **Request Cancellation** -4. Choose: - - **Immediate** - Cancel now - - **End of Billing Period** - Cancel when paid period ends -5. Provide a reason (optional) -6. Confirm cancellation - -> **Note:** Cancellation requests are reviewed by our team. You'll receive confirmation via email. - -**For PayPal Customers:** -This is important to do as failing to do this will result in you still being charged. Our Finance Team check the cancellions and cancel them on your behalf, but this may not always be the case. - -1. Log into your payal -2. Click on the settings icon (Gear) at the top right of the page -3. Go to the "Payments" tab -4. Search for "NodeByte LTD" -5. Cancel the pre-approved service. - -Once done you will no longer be charged for any services. - -### Service Renewal - -Services renew automatically if: - -- You have a payment method on file -- You have sufficient account credit - -To ensure uninterrupted service: -- Keep payment details updated -- Pay invoices before the due date -- Consider adding account credit - -## Affiliate Program - -Earn money by referring others: - -1. Go to **Affiliates** -2. Get your unique referral link -3. Share with friends and communities -4. Earn commission on successful referrals - -## Email Notifications - -Control what emails you receive: - -1. Go to **Email History** to see past emails -2. Important emails include: - - Invoice notifications - - Service status changes - - Support ticket updates - - Promotional offers - -## Common Questions - -### Why is my service suspended? - -Common reasons: -- Overdue invoice -- Terms of Service violation -- Abuse report - -**Solution:** Check your invoices and pay any outstanding balances, or open a support ticket. - -### How do I get my Game Panel password? - -Your panel credentials were sent in your welcome email. If lost: -1. Open a support ticket -2. Request a password reset -3. We'll verify your identity and assist - -### Can I change my billing email? - -Yes! Update it in Account Details. Verification may be required. - ---- +1. Navigate to **Support** → **Open Ticket**. +2. Select the department best suited to your request: + * **Technical Support:** Server crashes, network routing anomalies, panel errors, or hardware issues. + * **Billing:** Invoice adjustments, payment clearance issues, or subscription modifications. + * **Sales:** Custom resource configurations and deployment pre-sales questions. +3. Provide a clear, concise subject line. +4. Detail the operational issue inside the message body, attaching logs or error traces where applicable. +5. Click **Submit**. -Need help with billing? Open a [support ticket](https://billing.nodebyte.host/submitticket.php) or email [billing@nodebyte.host](mailto:billing@nodebyte.host). +### Support Guidelines +To ensure a rapid resolution time, please observe the following technical best practices: +* Include the specific server ID or assigned IP address in your message body. +* List the exact diagnostic steps you have already performed. +* Paste raw console errors or log blocks instead of paraphrasing the issue. +* Avoid opening multiple tickets for a same running issue, as doing so fragments our engineering queue. +* Do not paste administrative root passwords in plain text inside a ticket body. diff --git a/packages/kb/content/billing/payment-methods.md b/packages/kb/content/billing/payment-methods.md new file mode 100644 index 0000000..d864c3a --- /dev/null +++ b/packages/kb/content/billing/payment-methods.md @@ -0,0 +1,47 @@ +--- +title: Managing Saved Payment Methods +description: How to safely attach, update, or remove credit cards and digital wallets within your billing profile. +tags: [billing, payments, stripe, credit card] +author: NodeByte Team +lastUpdated: 2026-07-08 +order: 5 +--- + +# Managing Saved Payment Methods + +NodeByte utilizes the secure Stripe gateway engine to handle all direct credit card, debit card, and digital wallet transactions. When you opt to save a payment method during checkout for faster renewals, your structural financial data is never stored on NodeByte infrastructure. Instead, it is tokenized and stored securely within Stripe's encrypted vault. + +You can manage, rotate, or delete your saved cards directly through your client dashboard at any time. + +## Adding a New Card or Payment Method + +To attach a new payment method to your profile prior to an invoice generating: + +1. Authenticate your session at **[billing.nodebyte.host](https://billing.nodebyte.host)**. +2. Select **Account Settings** or click your profile username in the upper navigation zone. +3. Click into the **Payment Methods** management tab. +4. Select **Add New Payment Method**. +5. Input your card parameters securely via the tokenized Stripe window frame. +6. Confirm the assignment. The system will perform a zero-fiat validation check to verify the card's authenticity and attach it to your profile ledger. + +## Rotating Cards on Unpaid Invoices + +If a recurring renewal invoice fails due to an expired or declined card on file: + +1. Open your specific **Unpaid Invoice** from the primary dashboard ledger. +2. Locate the gateway toggle dropdown menu on the upper invoice layout and ensure **Stripe** is active. +3. Click **Pay Now**. +4. Rather than using the card currently assigned, select **Use a new payment method**. +5. Enter your updated billing information and execute the settlement. +6. Checking the **Save for future renewals** box will automatically set this fresh token as your primary payment instrument, displacing the expired card. + +## Removing Payment Tokens + +If you wish to completely wipe a saved card from our platform: + +1. Go to your profile settings menu and enter the **Payment Methods** sub-panel. +2. Locate the specific card token you want to remove. +3. Click the **Delete** or **Remove** action node next to the card identifier. +4. Confirm the prompt. The tokenized link to Stripe is severed instantly, ensuring no future automated draws can be attempted against that instrument. + +> **Note:** If you destroy your only saved payment method while running an active infrastructure deployment, your upcoming renewal invoice will generate as a manual `Unpaid` invoice. You must settle it manually before the end of the billing grace window to prevent automated container suspension. diff --git a/packages/kb/content/billing/paypal-cancellations.md b/packages/kb/content/billing/paypal-cancellations.md new file mode 100644 index 0000000..5143831 --- /dev/null +++ b/packages/kb/content/billing/paypal-cancellations.md @@ -0,0 +1,39 @@ +--- +title: Cancelling PayPal Subscriptions +description: Instructions for requesting a plan cancellation and properly revoking automatic PayPal billing agreements. +tags: [billing, paypal, cancellation, account] +author: NodeByte Team +lastUpdated: 2026-07-08 +order: 2 +--- + +# PayPal Cancelations + +When you submit a cancellation request for a NodeByte service, our system immediately flags the instance to stop future invoice generation. However, if you originally checked out using an automated **PayPal Billing Agreement**, PayPal’s internal subscription system will continue pushing automated renewals until the token is explicitly revoked within your personal wallet. + +Please follow these steps to cleanly decommission a service and prevent accidental gateway charges. + +## Step 1: Submit the Infrastructure Cancellation Request + +Before removing payment tokens, you must instruct our backend to release your hardware resource allocations. + +1. Log into the Client Portal at **[billing.nodebyte.host](https://billing.nodebyte.host)**. +2. Go to **Services** → **My Services** and select the specific node you wish to terminate. +3. On the management sidebar, select **Request Cancellation**. +4. Choose your preferred decommissioning timeline: + * **Immediate:** The container or virtual machine is powered down and sent to a secure data wiping queue within 24 hours. + * **End of Billing Period:** The server remains active and accessible until your current paid cycle concludes, at which point it gracefully deprovisions. +5. Provide brief feedback regarding your cancellation (optional) and confirm the request. + +## Step 2: Revoke the PayPal Pre-Authorized Token + +Once the portal records your cancellation, you must manually break the billing link on the payment processor's end: + +1. Log into your account at **[PayPal.com](https://www.paypal.com)**. +2. Click the **Settings** gear icon in the top right corner of your account navigation bar. +3. Select the **Payments** tab. +4. Click on **Manage Automatic Payments** or locate your active automatic merchant list. +5. Select **NodeByte LTD** from your list of authorized merchants. +6. Click **Cancel Automatic Payments** and confirm your decision on the prompt. + +> **Administrative Note:** Revoking your billing agreement is a mandatory security step. While our finance team conducts regular manual audits to catch and reverse stray incoming funds, manually terminating the token on the PayPal platform is the only way to ensure their system does not push an unassigned payment. diff --git a/packages/kb/content/billing/service-suspensions.md b/packages/kb/content/billing/service-suspensions.md new file mode 100644 index 0000000..f3d477e --- /dev/null +++ b/packages/kb/content/billing/service-suspensions.md @@ -0,0 +1,41 @@ +--- +title: Resolving Service Suspensions & AUP Infractions +description: Actions required to restore a suspended NodeByte server due to billing lapses or Acceptable Use Policy violations. +tags: [support, billing, suspension, compliance] +author: NodeByte Team +lastUpdated: 2026-07-08 +order: 4 +--- + +# Resolving Service Suspensions + +If your instance shifts into a **Suspended** state, network connectivity to that specific deployment is immediately paused. Your data remains intact and secure on the underlying hypervisor array, but the container or virtual machine will refuse all incoming connections until the underlying restriction block is systematically removed. + +Suspensions occur under two distinct scenarios: **Billing Delinquency** or **Compliance Actions**. + +## Scenario A: Suspensions for Non-Payment + +This is the most common cause of service interruption. It occurs when a billing cycle concludes and the corresponding renewal invoice remains unpaid past our standard system grace period. + +### Resolution Steps: +1. Log into the client area at **[billing.nodebyte.host](https://billing.nodebyte.host)**. +2. Review your dashboard alerts or navigate to **Account** → **Invoices**. +3. Select the **Unpaid** or **Overdue** invoice tied to your suspended infrastructure node. +4. Complete checkout using either Stripe or PayPal. +5. *The moment our payment gateway registers the transaction, our backend pushes an automated power-on command to the hypervisor. Your instance and panel access will restore completely within 60 to 180 seconds.* + +## Scenario B: Suspensions for Compliance or Abuse Flags + +If your invoices are fully settled but your infrastructure is offline, our automated network filtering layers or security operations team has flagged your instance for an Acceptable Use Policy (AUP) violation. + +Common compliance triggers include: +* **Outbound Network Anomalies:** Participating in outbound denial-of-service (DDoS) traffic loops, running unauthorized vulnerability scanning scripts, or hosting brute-force matrices. +* **Hypervisor Resource Depletion:** Executing unauthorized cryptocurrency mining logic or locking sustained, non-standard I/O or CPU spikes that degrade performance for neighboring instances on the host node. +* **Abuse Documentation:** External DMCA takedown requests or verified malicious software distribution reports sent to our network abuse handling desk. + +### Resolution Steps: +1. Navigate to **Support** → **Tickets** within the portal. +2. Locate the active compliance ticket. Our operations team automatically opens an investigation log whenever an administrative suspension block is executed. +3. Review the diagnostic data, logs, or external complaint text provided by our staff. +4. Reply directly to the ticket detailing your precise remediation strategy (such as purging compromised system directories or removing the offending application strings). +5. *Compliance locks require a manual review by our network engineering team. We will restore your hypervisor container to an active status as soon as the core policy infraction or system vulnerability is confirmed resolved.* diff --git a/packages/kb/content/billing/subscriptions.md b/packages/kb/content/billing/subscriptions.md new file mode 100644 index 0000000..979e7e8 --- /dev/null +++ b/packages/kb/content/billing/subscriptions.md @@ -0,0 +1,31 @@ +--- +title: Understanding the Invoice & Subscription Lifecycle +description: A breakdown of the chronological timeline for automated invoice generation, payment processing, and grace windows. +tags: [billing, invoices, subscriptions, lifecycle] +author: NodeByte Team +lastUpdated: 2026-07-08 +order: 6 +--- + +# Understanding the Invoice & Subscription Lifecycle + +NodeByte's billing infrastructure runs on a strict automated task loop that handles the lifecycle of your active deployments. To help you plan your community operations and corporate expense reporting, this article outlines the exact chronology of automated billing routines from creation to decommissioning. + +## The Billing Timeline + +All hosting services are tracked via independent subscription blocks tied to your unique provision date. The timeline below highlights how an active monthly billing cycle operates from invoice generation through to service deprovisioning: + +* **Day -7 (Invoice Generation):** Exactly 7 days before your current active hosting period concludes, our core database loop instantiates a fresh invoice string for the upcoming cycle. An automated email alert is pushed to your administrative address containing the line-item audit summary. If you have an active pre-funded account credit balance, the system will instantly consume those funds to settle or discount the invoice here, changing the status flag to `Paid`. +* **Day 0 (The Due Date):** The exact calendar date your current service period ends is the designated Due Date. For Stripe users, our system triggers an automated payment intent to debit your primary saved card token during the early morning execution loop. For PayPal users, if you have an active billing agreement, PayPal pushes the funds to our platform natively; if you pay manually, the invoice remains open awaiting your action. +* **Day +1 to Day +2 (The Grace Window):** If your primary payment method fails due to insufficient funds, card expiration, or false-positive flags, our platform logs the exception and keeps your game server slots or dedicated compute arrays completely online. The service state flag remains marked as `Active` during this 48-hour buffer window, and a follow-up notification is dispatched to alert your team of the payment block. +* **Day +3 (Automated Service Suspension):** If an invoice sits uncollected for 3 consecutive days past its original due date, the automated billing system communicates directly with our edge hypervisors. The container or machine instance is forcefully set into a `Suspended` execution state. Network ports are shut down, blocking all public client entries and file access. Your persistent game data, server configurations, and database volumes remain completely safe on our storage nodes during this hold phase. +* **Day +7 (Automated Deprovisioning & Wiping):** If an invoice is neglected for a full 7 days past the due date, the resource allocation is terminated. The system purges the container and entirely wipes all underlying localized files from the hardware array to free up the physical memory and NVMe blocks for new deployment slots. Data wiped at this stage is completely unrecoverable. + +## Modifying Renewal Frequencies + +If you wish to shift an active deployment from its current billing interval (such as migrating a server from a standard monthly cycle to a discounted quarterly or annual block): + +1. Do not submit a cancellation request. +2. Open a direct request to our **Billing Department** from the portal dashboard. +3. Specify your target server ID and your preferred new payment frequency. +4. Our finance desk will manually restructure your underlying plan parameters and rebuild your active invoice strings to match the chosen interval. diff --git a/packages/kb/content/vps/server-management.md b/packages/kb/content/vps/server-management.md new file mode 100644 index 0000000..7ce7777 --- /dev/null +++ b/packages/kb/content/vps/server-management.md @@ -0,0 +1,36 @@ +--- +title: VPS Server Management +description: An introduction to navigating your VPS instance controls, resource monitoring, and identifying your management interface. +tags: [vps, vds, virtfusion, api, management] +author: NodeByte Team +lastUpdated: 2026-07-09 +order: 1 +--- + +# Managing your Server + +NodeByte delivers unmanaged virtual private servers and virtual dedicated servers utilizing two distinct backend execution frameworks: native hardware nodes virtualized via **VirtFusion**, and partner configurations powered by custom **billing panel API extensions**. + +Because control placements vary depending on your specific plan deployment, identifying your instance's management lane is your first step. + +## Locating Your Control Deck + +When your server allocation is provisioned, our billing engine logs the server type and transmits an automated service activation log to your email. + +### Integrated API Management +If your plan utilizes our direct API extension hooks, your infrastructure switches are integrated into your billing profile. You do not need an external account. +1. Authenticate at **[billing.nodebyte.host](https://billing.nodebyte.host)**. +2. Navigate to **My Services** and select the node. +3. Use the embedded interface elements to issue power cycles, reboots, or monitor resource allocations natively from the page. + +### Dedicated Panel Management (VirtFusion) +If your plan is deployed on our native hardware nodes, lifecycle management requires accessing our isolated virtualization panel. +1. Locate the standalone console address and temporary login credentials inside your service welcome email. +2. Navigate to **[vps.nodebyte.host](https://vps.nodebyte.host)** or click the single-sign-on redirect inside your client portal. +3. Access your instance card to manage low-level kernel properties, out-of-band console access, and operating system reinstalls. + +## Standard Unmanaged Responsibilities +Regardless of whether your instance is managed via direct API or the external VirtFusion console, all VPS allocations are fundamentally unmanaged. The NodeByte operations team maintains network routes to your node and guarantees hardware uptime, but configuration tasks remain the client's responsibility: +* Operating system configuration and package updates. +* Firewall optimization and security posture hardening. +* Database management and external application backups. diff --git a/packages/kb/content/vps/server-types.md b/packages/kb/content/vps/server-types.md new file mode 100644 index 0000000..182a94f --- /dev/null +++ b/packages/kb/content/vps/server-types.md @@ -0,0 +1,32 @@ +--- +title: Managed Services vs. External Panels +description: Understand where your server's control deck is located based on whether your instance is hosted natively or via an upstream API integration. +tags: [vps, vds, management, virtfusion, api] +author: NodeByte Team +lastUpdated: 2026-07-09 +order: 2 +--- + +NodeByte operates a hybrid infrastructure model. Depending on the specific service lineup, tier, or region you deploy, your server will either be managed directly within the NodeByte Client Portal using custom API extensions or routed to an external dedicated virtualization panel. + +Review the structural differences below to locate your instance's management controls. + +## 1. Direct Portal Management +Certain virtual private servers and specialized retail instances utilize custom system extensions developed by NodeByte. These extensions hook directly into upstream provider APIs, keeping your infrastructure controls inside your primary dashboard. + +* **Where to Manage:** + * **[billing.nodebyte.host](https://billing.nodebyte.host)** -> **Services**. +* **Control Location:** All power states, reboots, and core metrics are embedded directly inside the service details page within your client account. +* **Authentication:** No secondary login or external panel accounts are required. Your master NodeByte portal account handles all deployment logic. + +## 2. External Panel Management +Our core native virtual environments (`BASE`, `COMP`, `GAME`, and `ELITE` tiers) run on our isolated hardware arrays and utilize a standalone virtualization controller. + +* **Where to Manage:** **[vps.nodebyte.host](https://vps.nodebyte.host)** (or the unique endpoint provided in your service activation email). +* **Control Location:** Advanced hypervisor tasks, out-of-band NoVNC console access, image rebuilding, and hardware-edge firewall configuration require logging into this standalone console. +* **Authentication:** Handled via distinct console credentials transmitted automatically upon service verification. + +## How to Determine Your Service Type +If you are unsure where your server's control deck resides, check your active service page within the billing portal: +* If you see embedded power switches (`Start`, `Stop`, `Reboot`) and status bars natively inside the billing interface, your plan is **API-Managed**. +* If you see a prominent **Login to Control Panel** button or reference to external console links, your plan is **Panel-Managed**. diff --git a/packages/kb/content/vps/using-novnc.md b/packages/kb/content/vps/using-novnc.md new file mode 100644 index 0000000..80bc788 --- /dev/null +++ b/packages/kb/content/vps/using-novnc.md @@ -0,0 +1,33 @@ +--- +title: Accessing Your Server via NoVNC +description: How to establish a direct visual connection to your VPS using VirtFusion's out-of-band NoVNC console when network access is lost. +tags: [vps, vds, virtfusion, novnc, console, recovery] +author: NodeByte Team +lastUpdated: 2026-07-09 +order: 4 +--- + +If a firewall configuration script errors, an SSH service port drops, or a network interface modification disconnects your server from the internet, traditional SSH or SFTP access will fail. + +To resolve this on panel-managed instances without data loss, VirtFusion includes an out-of-band **NoVNC Console** link. This tool establishes a direct visual stream to your instance's virtual monitor from within your web browser, mimicking a physical keyboard and screen plugged into a bare-metal rack. + +## Launching the Console Terminal + +1. Login to the [VPS Panel](https://vps.nodebyte.host). +2. Select your instance and locate the **Console** button in the upper management action bar. +3. Click the button to launch an isolated secure visual connection frame. +4. Press Enter to awaken the instance screen layer. + +## Authenticating via NoVNC + +Because the NoVNC console acts as a direct hardware monitor, it does not accept SSH key tokens. + +1. When the system displays your distribution’s local login prompt, type `root` and hit Enter. +2. Input your administrative root password string and confirm. *Note that characters will remain hidden as you type for security.* +3. You are now inside a secure shell terminal operating entirely outside your server's standard public network interface. + +## Resolving Local Lockouts + +Once your session initializes, you can troubleshoot the underlying network block natively: +* **Check Local Firewall Status:** If an active Uncomplicated Firewall (UFW) block locked your connection, you can temporarily suspend it via `ufw disable`. +* **Inspect Active Port Bindings:** Verify that the system's SSH daemon is active and listening cleanly on your intended ports by executing `systemctl status ssh` or `ss -tulpn`. diff --git a/packages/kb/content/vps/virtfusion.md b/packages/kb/content/vps/virtfusion.md new file mode 100644 index 0000000..b6ee088 --- /dev/null +++ b/packages/kb/content/vps/virtfusion.md @@ -0,0 +1,30 @@ +--- +title: VirtFusion Management Console +description: An introduction to navigating your VPS instance controls, resource monitoring, and power management flags inside the VirtFusion panel. +tags: [vps, vds, virtfusion, console, management] +author: NodeByte Team +lastUpdated: 2026-07-09 +order: 3 +--- + +For services designated as panel-managed, your virtual environment runs on top of our isolated VirtFusion virtualization layer. The management panel allows you to interact directly with your instance kernel, audit resource metrics, and perform hardware-level power operations without relying on technical support intervention. + +## Accessing the Control Panel + +When your server allocation invoice is processed, our provisioning system automatically spins up an instance container and dispatches an authentication email containing your management link. + +1. Navigate to the address provided in your welcome email, or click the direct control link inside the Client Portal. +2. Provide your assigned panel username and credentials. +3. Select your instance from the primary dashboard asset tree to enter the core management zone. + +## Core Management Elements + +The VirtFusion panel separates your infrastructure controls into clean, functional frames: + +* **Power Operations:** Located at the top of the instance dashboard, these keys push hard system flags directly to the hypervisor kernel: + * **Start:** Powers on a stopped instance. + * **Stop:** Sends an instantaneous ACPI shutdown signal to gracefully halt the OS. + * **Force Stop:** Cuts physical power to the virtual instance instantly. Use this only if the operating system kernel is completely frozen. + * **Reboot:** Performs a clean hardware reset cycle. +* **Resource Monitoring Frames:** Real-time visual meters track your usage allocations against your plan parameters (`BASE`, `COMP`, `GAME`, or `ELITE`). Use these to check for CPU bottlenecks, active RAM ceilings, and storage drive space constraints. +* **Network Tab:** Contains your assigned IPv4 addresses, configuration parameters, and public routing gateways. 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..4adc058 100644 --- a/packages/ui/components/Layouts/Dedicated/dedicated-hub.tsx +++ b/packages/ui/components/Layouts/Dedicated/dedicated-hub.tsx @@ -11,8 +11,10 @@ import { Zap, X, ArrowRight, + ChevronDown, Star, Lock, + PackageX, } from "lucide-react" import { Button } from "@/packages/ui/components/ui/button" import { Input } from "@/packages/ui/components/ui/input" @@ -23,6 +25,8 @@ import { SelectTrigger, SelectValue, } from "@/packages/ui/components/ui/select" +import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/packages/ui/components/ui/collapsible" +import { PlanInfoRow } from "@/packages/ui/components/ui/plan-info-row" import Link from "next/link" import { cn } from "@/lib/utils" import type { DedicatedPlanSpec } from "@/packages/core/types/servers/dedicated" @@ -47,11 +51,12 @@ function formatStorage(plan: DedicatedPlanSpec): string { function PlanCard({ plan }: { plan: DedicatedPlanSpec }) { const outOfStock = plan.stock === "out_of_stock" + const [infoOpen, setInfoOpen] = useState(false) return (
    )} -
    +
    {/* Header row */}
    @@ -145,10 +150,35 @@ function PlanCard({ plan }: { plan: DedicatedPlanSpec }) { ))}
    + {/* Server info */} + + + + + + 0 ? : "None"} + /> + {plan.uplink && ( + + )} + {plan.location && } + {plan.databases != null && } + {plan.backups && } + + + - -
    -
    - -
    - - ) -} diff --git a/packages/ui/components/Layouts/Games/game-features.tsx b/packages/ui/components/Layouts/Games/game-features.tsx deleted file mode 100644 index 9403f36..0000000 --- a/packages/ui/components/Layouts/Games/game-features.tsx +++ /dev/null @@ -1,111 +0,0 @@ -"use client" - -import { Card } from "@/packages/ui/components/ui/card" -import { - CheckCircle2, - Sparkles, - Settings, - Cpu, - Shield, - Zap, - HardDrive, - Users, - Server, - Map, - Globe -} from "lucide-react" -import { cn } from "@/lib/utils" -import { useTranslations } from "next-intl" - -const iconMap = { - Settings: Settings, - Cpu: Cpu, - Shield: Shield, - Zap: Zap, - HardDrive: HardDrive, - Users: Users, - Server: Server, - Map: Map, - Globe: Globe, - Sparkles: Sparkles, -} - -interface Feature { - title: string - description: string - icon: keyof typeof iconMap - highlights: string[] -} - -interface GameFeaturesProps { - gameName: string - features: Feature[] -} - -export function GameFeatures({ gameName, features }: GameFeaturesProps) { - const t = useTranslations() - - return ( -
    - {/* Background */} -
    - -
    - {/* Header */} -
    -
    - - {t("gamePage.features.badge")} -
    -

    - {t("gamePage.features.title")}{" "} - - {gameName} {t("gamePage.features.titleSuffix")} - -

    -

    - {t("gamePage.features.description", { game: gameName })} -

    -
    - - {/* Features Grid */} -
    - {features.map((feature, index) => ( - -
    - {/* Icon */} -
    - {(() => { - const IconComponent = iconMap[feature.icon] - return IconComponent ? : null - })()} -
    - - {/* Content */} -

    {feature.title}

    -

    {feature.description}

    - - {/* Highlights */} -
      - {feature.highlights.map((highlight, i) => ( -
    • - - {highlight} -
    • - ))} -
    -
    -
    - ))} -
    -
    -
    - ) -} diff --git a/packages/ui/components/Layouts/Games/game-hero.tsx b/packages/ui/components/Layouts/Games/game-hero.tsx deleted file mode 100644 index d4cd368..0000000 --- a/packages/ui/components/Layouts/Games/game-hero.tsx +++ /dev/null @@ -1,157 +0,0 @@ -"use client" - -import { Button } from "@/packages/ui/components/ui/button" -import { ArrowRight, ExternalLink, Star, Blocks, Gamepad2, Sparkles, Leaf, Pickaxe, Wrench } from "lucide-react" -import Image from "next/image" -import Link from "next/link" -import { cn } from "@/lib/utils" -import { useTranslations } from "next-intl" -import { LINKS } from "@/packages/core/constants/links" - -const iconMap = { - Blocks: Blocks, - Gamepad2: Gamepad2, - Sparkles: Sparkles, - Leaf: Leaf, - Pickaxe: Pickaxe, - Wrench: Wrench, -} - -interface GameHeroProps { - name: string - description: string - banner: string - icon: keyof typeof iconMap - tag: string - tagColor: string - billingUrl: string - features: string[] - comingSoon?: boolean -} - -export function GameHero({ - name, - description, - banner, - icon, - tag, - tagColor, - billingUrl, - features, - comingSoon, -}: GameHeroProps) { - const t = useTranslations() - const Icon = iconMap[icon] - - return ( -
    - {/* Background */} -
    - - {/* Banner overlay */} -
    - {name} -
    -
    - - {/* Grid pattern */} -
    - -
    -
    - {/* Left Content */} -
    - {/* Tag */} -
    - - {tag} -
    - - {/* Heading */} -

    - {name}{" "} - - {t("games.hosting")} - -

    - -

    - {description} -

    - - {/* Quick Features */} -
    - {features.slice(0, 4).map((feature) => ( -
    - - {feature} -
    - ))} -
    - - {/* CTAs */} -
    - {comingSoon ? ( - - ) : ( - <> - - - - )} -
    -
    - - {/* Right - Banner Image */} -
    -
    - {name} - {comingSoon && ( -
    -
    -

    {t("gamePage.hero.comingSoon")}

    -

    {t("gamePage.hero.joinDiscordForUpdates")}

    -
    -
    - )} -
    -
    -
    -
    -
    - ) -} diff --git a/packages/ui/components/Layouts/Games/game-hub.tsx b/packages/ui/components/Layouts/Games/game-hub.tsx new file mode 100644 index 0000000..49c03c9 --- /dev/null +++ b/packages/ui/components/Layouts/Games/game-hub.tsx @@ -0,0 +1,323 @@ +"use client" + +import { useState } from "react" +import { + Server, + Search, + MemoryStick, + HardDrive, + Network, + Shield, + Zap, + X, + ArrowRight, + ChevronDown, + Star, + Gamepad2, + PackageX, +} from "lucide-react" +import { Button } from "@/packages/ui/components/ui/button" +import { Input } from "@/packages/ui/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/packages/ui/components/ui/select" +import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/packages/ui/components/ui/collapsible" +import { PlanInfoRow } from "@/packages/ui/components/ui/plan-info-row" +import Link from "next/link" +import { cn } from "@/lib/utils" +import type { GamePlanSpec } from "@/packages/core/types/servers/game" +import { Price } from "@/packages/ui/components/ui/price" +import { SUPPORTED_GAMES } from "@/packages/core/constants/supported-games" + +type SortKey = "default" | "asc" | "desc" + +function formatBandwidth(plan: GamePlanSpec): string { + if (!plan.bandwidth) return "Unmetered" + return `${plan.bandwidth.amount} ${plan.bandwidth.unit}` +} + +// ─── PlanCard ───────────────────────────────────────────────────────────────── + +function PlanCard({ plan }: { plan: GamePlanSpec }) { + const outOfStock = plan.stock === "out_of_stock" + const [infoOpen, setInfoOpen] = useState(false) + + return ( +
    + {plan.popular && ( +
    + + Most Popular +
    + )} + +
    + {/* Header row */} +
    +
    +

    + {plan.name ?? plan.id} +

    + {plan.description && ( +

    {plan.description}

    + )} +
    +
    + +

    /month

    +
    +
    + + {outOfStock && ( +
    + + Out of Stock + +
    + )} + +
    + + {/* Specs grid */} +
    +
    + + + {plan.ramGB} GB {plan.ramType ? plan.ramType : ""} RAM + +
    +
    + + {plan.storageGB} GB {plan.storageLabel ?? "Storage"} +
    +
    + + {formatBandwidth(plan)} +
    +
    + + {/* Included features */} +
    + {[ + { icon: Shield, text: "DDoS Protection" }, + { icon: Zap, text: "Instant Setup" }, + { icon: Server, text: "Control Panel" }, + ].map(({ icon: Icon, text }) => ( + + + {text} + + ))} +
    + + {/* Server info */} + + + + + + 0 ? : "None"} + /> + {plan.cpuModel && } + {plan.uplink && ( + + )} + {plan.location && } + {plan.databases != null && } + {plan.backups && } + + + + +
    +
    + ) +} + +// ─── Hub ───────────────────────────────────────────────────────────────────── + +interface GameHubProps { + plans: GamePlanSpec[] +} + +export function GameHub({ plans }: GameHubProps) { + const [search, setSearch] = useState("") + const [sort, setSort] = useState("default") + + const filtered = (() => { + let result = [...plans] + const q = search.trim().toLowerCase() + if (q) { + result = result.filter( + (p) => + (p.name ?? p.id).toLowerCase().includes(q) || + p.description?.toLowerCase().includes(q), + ) + } + if (sort === "asc") result.sort((a, b) => a.priceGBP - b.priceGBP) + if (sort === "desc") result.sort((a, b) => b.priceGBP - a.priceGBP) + return result + })() + + const hasActiveFilters = search !== "" + + function clearFilters() { + setSearch("") + setSort("default") + } + + return ( +
    + {/* Background */} +
    +
    +
    + +
    + + {/* ── Hero ─────────────────────────────────────────────────────────── */} +
    +
    + + Game Server Hosting +
    +

    + Choose Your{" "} + + Game Server + +

    +

    + One set of plans for every game we support. Pick a tier below, then choose which game to deploy at checkout. +

    +
    + {SUPPORTED_GAMES.map((game) => ( + + {game} + + ))} +
    +
    + + {/* ── Filter bar ───────────────────────────────────────────────────── */} +
    +
    +
    + + setSearch(e.target.value)} + className="pl-9 bg-card/30 border-border/50" + /> +
    + + + {hasActiveFilters && ( + + )} +
    +
    + + {/* ── Plan grid ────────────────────────────────────────────────────── */} +
    + {plans.length === 0 ? ( +
    + +

    No game server plans in stock right now

    +

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

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

    No plans match your search

    +

    Try adjusting or clearing your search.

    + +
    + ) : ( + <> +

    + Showing {filtered.length} of {plans.length} plan{plans.length !== 1 ? "s" : ""} +

    +
    + {filtered.map((plan) => ( + + ))} +
    + + )} +
    + + {/* ── Custom / Enterprise CTA ───────────────────────────────────────── */} +
    +
    +
    +
    +

    Don't see the game you're after?

    +

    + Let us know what you're looking to run and we'll help get it set up. +

    +
    + +
    +
    +
    + +
    +
    + ) +} diff --git a/packages/ui/components/Layouts/Games/game-pricing.tsx b/packages/ui/components/Layouts/Games/game-pricing.tsx deleted file mode 100644 index c2c644b..0000000 --- a/packages/ui/components/Layouts/Games/game-pricing.tsx +++ /dev/null @@ -1,327 +0,0 @@ -"use client" - -import { useState } from "react" -import { Button } from "@/packages/ui/components/ui/button" -import { Card } from "@/packages/ui/components/ui/card" -import { Input } from "@/packages/ui/components/ui/input" -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/packages/ui/components/ui/select" -import { Zap, ArrowRight, ExternalLink, PackageX, Search, X, Check, Cpu, HardDrive, MemoryStick, Shield, Database, Monitor, Package, Activity, MapPin } from "lucide-react" -import Link from "next/link" -import { cn } from "@/lib/utils" -import { Price } from "@/packages/ui/components/ui/price" -import { useTranslations } from "next-intl" -import { LINKS } from "@/packages/core/constants/links" -import type { ReactNode } from "react" - -const DEFAULT_HEADER_ICON = - -function getFeatureIcon(feature: string) { - const f = feature.toLowerCase() - if (f.includes("ram") || f.includes("ddr") || f.includes("memory")) return - if (f.includes("storage") || f.includes("ssd") || f.includes("nvme") || f.includes("disk")) return - if (f.includes("ryzen") || f.includes("intel") || f.includes("cpu") || f.includes("processor")) return - if (f.includes("ddos") || f.includes("protection") || f.includes("firewall")) return - if (f.includes("database") || f.includes("mysql") || f.includes("mariadb")) return - if (f.includes("panel") || f.includes("control") || f.includes("dashboard")) return - if (f.includes("jar") || f.includes("plugin") || f.includes("mod") || f.includes("oxide") || f.includes("umod")) return - if (f.includes("uptime") || f.includes("sla")) return - return -} - -interface PricingPlan { - name: string - description: string - /** Price in GBP (base currency) - just the number */ - priceGBP: number - period: string - features: string[] - /** Data centre location e.g. "Newcastle, United Kingdom" */ - location?: string - popular?: boolean - url?: string - /** Availability status — defaults to in_stock */ - stock?: "in_stock" | "out_of_stock" | "coming_soon" - /** Native billing prices per currency code; when present, used instead of converting priceGBP */ - prices?: Record -} - -interface GamePricingProps { - gameName: string - billingUrl: string - plans: PricingPlan[] - comingSoon?: boolean - /** Show an out-of-stock state instead of pricing cards (also triggered when plans is empty) */ - outOfStock?: boolean - /** Icon node rendered in the card gradient header */ - headerIcon?: ReactNode - /** Tailwind gradient classes for the card header background */ - headerGradient?: string - /** Tailwind classes for the icon background/colour inside the header */ - headerIconBg?: string -} - -export function GamePricing({ - gameName, - billingUrl, - plans, - comingSoon, - outOfStock, - headerIcon = DEFAULT_HEADER_ICON, - headerGradient = "from-primary/20 via-primary/10 to-accent/5", - headerIconBg = "bg-primary/10 text-primary", -}: GamePricingProps) { - const isOutOfStock = outOfStock || plans.length === 0 - const t = useTranslations() - - const [search, setSearch] = useState("") - const [sortOrder, setSortOrder] = useState<"default" | "asc" | "desc">("default") - - const hasActiveFilters = search !== "" || sortOrder !== "default" - - const filteredPlans = [...plans] - .filter((p) => !search || p.name.toLowerCase().includes(search.toLowerCase())) - .sort((a, b) => { - if (sortOrder === "asc") return a.priceGBP - b.priceGBP - if (sortOrder === "desc") return b.priceGBP - a.priceGBP - return 0 - }) - - function clearFilters() { - setSearch("") - setSortOrder("default") - } - - return ( -
    - {/* Background */} -
    - -
    - {/* Header */} -
    -
    - - {t("gamePage.pricing.badge")} -
    -

    - {gameName}{" "} - - {t("gamePage.pricing.title")} - -

    -

    - {t("gamePage.pricing.description")} -

    -
    - - {comingSoon ? ( - -
    -
    - -
    -

    {t("gamePage.pricing.comingSoon")}

    -

    - {t("gamePage.pricing.comingSoonDesc", { game: gameName })} -

    - -
    -
    - ) : isOutOfStock ? ( -
    -
    - -
    -

    {t("gamePage.pricing.outOfStock")}

    -

    - {t("gamePage.pricing.outOfStockDesc", { game: gameName })} -

    -

    - {t("gamePage.pricing.outOfStockNote")} -

    - -
    -
    - ) : ( - <> - {/* Filter Bar */} -
    -
    - - setSearch(e.target.value)} - placeholder={t("gamePage.pricing.filters.search")} - className="pl-9 h-9 bg-background/50" - /> -
    - - - - {hasActiveFilters && ( - - )} -
    - - {/* Pricing Grid */} - {filteredPlans.length === 0 ? ( - -
    -
    - -
    -

    {t("gamePage.pricing.filters.noResults")}

    -

    {t("gamePage.pricing.filters.noResultsDesc")}

    - -
    -
    - ) : ( -
    - {filteredPlans.map((plan) => ( - - {/* Gradient header with icon */} -
    -
    -
    - {headerIcon} -
    -
    - - {plan.stock === "out_of_stock" ? ( -
    - {t("pricing.outOfStock")} -
    - ) : plan.stock === "coming_soon" ? ( -
    - {t("pricing.comingSoon")} -
    - ) : plan.popular ? ( -
    - {t("gamePage.pricing.mostPopular")} -
    - ) : null} - -
    -
    - -
    - {/* Name + price row */} -
    -

    {plan.name}

    -
    -
    - - /{plan.period} -
    -
    -
    - - {/* Description */} -

    {plan.description}

    - - {/* Location */} - {plan.location && ( -
    - - {plan.location} -
    - )} - - {/* Features */} -
      - {plan.features.map((feature, i) => ( -
    • - {getFeatureIcon(feature)} - {feature} -
    • - ))} -
    - - {plan.stock === "out_of_stock" ? ( - - ) : plan.stock === "coming_soon" ? ( - - ) : ( - - )} -
    - - ))} -
    - )} - - {/* CTA */} -
    -

    - {t("gamePage.pricing.customSolution")}{" "} - - {t("gamePage.pricing.contactUs")} - -

    - -
    - - )} -
    -
    - ) -} 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..0771fb6 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_DISPLAY_OVERRIDES, 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" @@ -33,30 +36,20 @@ interface ExtendedNode extends PublicNode { uptime?: number } -const STATIC_NODES: ExtendedNode[] = [ - { - id: 1, - name: "NB-GNODE-NC1", - 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", - locationCode: "Helsinki, FI", - isMaintenanceMode: false, - memory: 65104, - disk: 512000, - cpu: "AMD Ryzen™ 7 1700X", - ramType: "DDR4 ECC", - uptime: 99.8, - }, -] +/** Build the node list from names discovered live from the status page, filling in any known display extras. */ +function buildNodes(nodeNames: string[]): ExtendedNode[] { + return nodeNames.map((name, i) => { + const overrides = NODE_DISPLAY_OVERRIDES[name] ?? {} + return { + id: i + 1, + name, + locationCode: overrides.locationCode ?? "", + isMaintenanceMode: false, + cpu: overrides.cpu, + ramType: overrides.ramType, + } + }) +} // ─── Location data ─────────────────────────────────────────────────────────── @@ -98,6 +91,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 +106,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 +136,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 +169,9 @@ function NodeCard({ node }: { node: ExtendedNode }) { )}
    - - - {online ? "Online" : "Maintenance"} + + + {state.label}
    @@ -190,13 +193,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 +222,12 @@ function LocationCountryRow({ country, flag, locations, + findMonitor, }: { country: string flag: string locations: DataCentreLocation[] + findMonitor: (name: string) => StatusApiMonitor | null }) { return (
    @@ -222,21 +235,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 && } + + ) + })}
    @@ -280,10 +299,19 @@ const FAQS = [ // ─── Main export ───────────────────────────────────────────────────────────── -export function NodesClient() { - const nodes = STATIC_NODES - const onlineCount = nodes.filter((n) => !n.isMaintenanceMode).length - const maintenanceCount = nodes.filter((n) => n.isMaintenanceMode).length +interface NodesClientProps { + /** Node monitor names discovered live from the status page's "Nodes" group. */ + nodeNames: string[] +} + +export function NodesClient({ nodeNames }: NodesClientProps) { + const nodes = buildNodes(nodeNames) + const { findMonitor } = useNodeStatus() + const nodeLiveStates = nodes.map((node) => findMonitor(node.name)) + 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 +348,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}
    @@ -340,11 +368,19 @@ export function NodesClient() {

    -
    - {nodes.map((node) => ( - - ))} -
    + {nodes.length > 0 ? ( +
    + {nodes.map((node, i) => ( + + ))} +
    + ) : ( +
    + +

    Node status is temporarily unavailable

    +

    Check the status page directly for the latest info.

    +
    + )}
    {/* ── Locations */} @@ -384,6 +420,7 @@ export function NodesClient() { country={country} flag={flag} locations={locations} + findMonitor={findMonitor} /> ))}
    diff --git a/packages/ui/components/Layouts/Partners/index.ts b/packages/ui/components/Layouts/Partners/index.ts new file mode 100644 index 0000000..1e94f86 --- /dev/null +++ b/packages/ui/components/Layouts/Partners/index.ts @@ -0,0 +1 @@ +export { PartnersPage } from "./partners-page" diff --git a/packages/ui/components/Layouts/Partners/partners-page.tsx b/packages/ui/components/Layouts/Partners/partners-page.tsx new file mode 100644 index 0000000..7abfa12 --- /dev/null +++ b/packages/ui/components/Layouts/Partners/partners-page.tsx @@ -0,0 +1,184 @@ +"use client" + +import { Card } from "@/packages/ui/components/ui/card" +import { Button } from "@/packages/ui/components/ui/button" +import { Badge } from "@/packages/ui/components/ui/badge" +import { Handshake, Award, Users, ArrowRight, ExternalLink, MessageCircle } from "lucide-react" +import Image from "next/image" +import Link from "next/link" +import { cn } from "@/lib/utils" +import { useTranslations } from "next-intl" +import { PARTNERS, type PartnerEntry, type PartnerTier } from "@/packages/core/constants/partners" + +function PartnerCard({ entry }: { entry: PartnerEntry }) { + return ( + + +
    +
    + {entry.name} +
    +
    +
    +

    {entry.name}

    + +
    + {entry.category && ( + + {entry.category} + + )} +
    +
    +

    {entry.description}

    +
    +
    + ) +} + +function TierSection({ + tier, + title, + badge, + icon: Icon, + entries, +}: { + tier: PartnerTier + title: string + badge: string + icon: typeof Handshake + entries: PartnerEntry[] +}) { + const filtered = entries.filter((e) => e.tier === tier) + if (filtered.length === 0) return null + + return ( +
    +
    +
    +
    + + {badge} +
    +

    {title}

    +
    + +
    + {filtered.map((entry) => ( + + ))} +
    +
    +
    + ) +} + +export function PartnersPage() { + const t = useTranslations() + + return ( +
    + {/* Hero */} +
    +
    +
    +
    +
    +
    + + {t("partnersPage.badge")} +
    +

    + {t("partnersPage.hero.title")}{" "} + + {t("partnersPage.hero.titleHighlight")} + +

    +

    + {t("partnersPage.hero.description")} +

    +
    + + +
    +
    +
    +
    + + + + + + + + {/* CTA */} +
    +
    +
    + +
    +
    + +

    {t("partnersPage.cta.title")}

    +

    {t("partnersPage.cta.description")}

    +
    + + +
    +
    + +
    +
    +
    + ) +} diff --git a/packages/ui/components/Layouts/VPS/vps-hub.tsx b/packages/ui/components/Layouts/VPS/vps-hub.tsx index 47c09fe..5a5c06c 100644 --- a/packages/ui/components/Layouts/VPS/vps-hub.tsx +++ b/packages/ui/components/Layouts/VPS/vps-hub.tsx @@ -12,7 +12,9 @@ import { SlidersHorizontal, X, ArrowRight, + ChevronDown, Star, + PackageX, } from "lucide-react" import { Button } from "@/packages/ui/components/ui/button" import { Badge } from "@/packages/ui/components/ui/badge" @@ -24,6 +26,8 @@ import { SelectTrigger, SelectValue, } from "@/packages/ui/components/ui/select" +import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/packages/ui/components/ui/collapsible" +import { PlanInfoRow } from "@/packages/ui/components/ui/plan-info-row" import Link from "next/link" import { cn } from "@/lib/utils" import type { VpsPlanSpec } from "@/packages/core/types/servers/vps" @@ -87,11 +91,12 @@ function PlanCard({ plan }: { plan: VpsPlanSpec }) { const lineup = plan.lineup ? LINEUP_META[plan.lineup] ?? null : null const series = plan.series ? SERIES_META[plan.series] ?? null : null const outOfStock = plan.stock === "out_of_stock" + const [infoOpen, setInfoOpen] = useState(false) return (
    )} -
    +
    {/* Header row */}
    @@ -181,11 +186,43 @@ function PlanCard({ plan }: { plan: VpsPlanSpec }) { ))}
    + {/* Server info */} + + + + + + 0 ? : "None"} + /> + {series && } + {plan.uplink && ( + + )} + {plan.ddos && ( + + )} + {plan.location && } + {plan.databases != null && } + {plan.backups && } + + + {/* CTA */}
  • - @@ -233,15 +234,12 @@ export function Footer() {
  • - - {t("footer.company.companyInfo")} - - + {t("footer.company.partners")} +
  • + Company No. 15432941
    @@ -269,4 +268,4 @@ export function Footer() { ) -} \ No newline at end of file +} diff --git a/packages/ui/components/Static/navigation.tsx b/packages/ui/components/Static/navigation.tsx index 524b7f0..c78529f 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, Handshake } 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" @@ -37,63 +37,38 @@ export function Navigation() { description: t("company.network.description"), icon: Network, }, + { + title: t("company.partners.title"), + href: "/partners", + description: t("company.partners.description"), + icon: Handshake, + }, { title: t("company.contact.title"), href: "/contact", description: t("company.contact.description"), icon: Mail, }, - { - title: t("company.github.title"), - href: "https://github.com/nodebyte", - description: t("company.github.description"), - icon: ExternalLink, - external: true, - }, ] 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"), - icon: Gamepad2, - section: "game", - }, - { - title: t("services.hytale.title"), - href: "/games/hytale", - description: t("services.hytale.description"), - icon: Gamepad2, - section: "game", - }, { title: t("services.gameServers.title"), href: "/games", description: t("services.gameServers.description"), - icon: Server, - section: "game", + icon: Gamepad2, }, { title: t("services.allVps.title"), href: "/vps", description: t("services.allVps.description"), icon: Server, - section: "vps", }, { title: t("services.dedicated.title"), href: "/dedicated", description: t("services.dedicated.description"), icon: Cpu, - section: "dedicated", }, ] @@ -341,62 +316,12 @@ export function Navigation() { closeDropdown(setServicesOpen)} > - {/* Game Servers section */} -
    -

    Game Servers

    -
    - {services.filter(s => s.section === "game").map((service) => ( - - -
    - -
    -
    - {service.title} -

    - {service.description} -

    -
    - -
    - ))} - {/* VPS section divider */} -
    -
    -

    VPS Servers

    -
    - {services.filter(s => s.section === "vps").map((service) => ( - - -
    - -
    -
    - {service.title} -

    - {service.description} -

    -
    - -
    - ))} - {/* Dedicated section divider */} -
    -
    -

    Dedicated Servers

    -
    - {services.filter(s => s.section === "dedicated").map((service) => ( + {services.map((service) => (
    - {/* Game Servers */} -

    Game Servers

    - {services.filter(s => s.section === "game").map((service) => ( - setIsMobileMenuOpen(false)} - > -
    - -
    -
    -
    {service.title}
    -

    - {service.description} -

    -
    - - - ))} - {/* VPS Servers */} -
    -

    VPS Servers

    - {services.filter(s => s.section === "vps").map((service) => ( - setIsMobileMenuOpen(false)} - > -
    - -
    -
    -
    {service.title}
    -

    - {service.description} -

    -
    - - - ))} - {/* Dedicated Servers */} -
    -

    Dedicated Servers

    - {services.filter(s => s.section === "dedicated").map((service) => ( + {services.map((service) => ( = { + 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/ui/plan-info-row.tsx b/packages/ui/components/ui/plan-info-row.tsx new file mode 100644 index 0000000..33426e9 --- /dev/null +++ b/packages/ui/components/ui/plan-info-row.tsx @@ -0,0 +1,11 @@ +import type { ReactNode } from "react" + +/** A label/value row inside a plan card's expandable "Server Info" panel. */ +export function PlanInfoRow({ label, value }: { label: string; value: ReactNode }) { + return ( +
    + {label} + {value} +
    + ) +} diff --git a/public/partners/antiraid.webp b/public/partners/antiraid.webp new file mode 100644 index 0000000..b2ca69b Binary files /dev/null and b/public/partners/antiraid.webp differ diff --git a/public/partners/blizzardsmp.webp b/public/partners/blizzardsmp.webp new file mode 100644 index 0000000..feb4fd5 Binary files /dev/null and b/public/partners/blizzardsmp.webp differ diff --git a/public/partners/clovrme.svg b/public/partners/clovrme.svg new file mode 100644 index 0000000..38e9d4f --- /dev/null +++ b/public/partners/clovrme.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/partners/emberly.svg b/public/partners/emberly.svg new file mode 100644 index 0000000..27ade03 --- /dev/null +++ b/public/partners/emberly.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/partners/fluxrp.png b/public/partners/fluxrp.png new file mode 100644 index 0000000..8bc1f28 Binary files /dev/null and b/public/partners/fluxrp.png differ diff --git a/public/partners/fyfeweb.png b/public/partners/fyfeweb.png new file mode 100644 index 0000000..a54ce8b Binary files /dev/null and b/public/partners/fyfeweb.png differ diff --git a/public/partners/octoflow.png b/public/partners/octoflow.png new file mode 100644 index 0000000..8069e49 Binary files /dev/null and b/public/partners/octoflow.png differ diff --git a/public/partners/placeholder.svg b/public/partners/placeholder.svg new file mode 100644 index 0000000..2e7e38f --- /dev/null +++ b/public/partners/placeholder.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/partners/poliberry.png b/public/partners/poliberry.png new file mode 100644 index 0000000..9f8cea3 Binary files /dev/null and b/public/partners/poliberry.png differ diff --git a/public/partners/smphub.webp b/public/partners/smphub.webp new file mode 100644 index 0000000..e0224a4 Binary files /dev/null and b/public/partners/smphub.webp differ diff --git a/translations b/translations index 67a79ea..d24273c 160000 --- a/translations +++ b/translations @@ -1 +1 @@ -Subproject commit 67a79ea5ff53a62e9c7c8595861c6f200c56128f +Subproject commit d24273c4b3fb45b747a249f6f4f8a2503cf2fff6