diff --git a/.env.example b/.env.example index e19b392..5f0af76 100644 --- a/.env.example +++ b/.env.example @@ -6,4 +6,9 @@ JWT_SECRET="" # DeepL API key for automated translation (https://www.deepl.com/pro-api) # Free tier: use a key ending in :fx | Paid tier: standard key -DEEPL_API_KEY="" \ No newline at end of file +DEEPL_API_KEY="" + +# status.nodebyte.host public status API — admin token unlocks hidden monitors +# and bypasses the shared CDN cache for fresher data. +STATUS_API_URL="https://status.nodebyte.host" +STATUS_TOKEN="" \ No newline at end of file diff --git a/README.md b/README.md index c5aa232..0a952b2 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ The frontend uses a centralized API client (`packages/core/lib/api.ts`) that rou A small set of lightweight API routes remain in the Next.js app for public-facing proxy endpoints: - `/api/github/releases` -- GitHub release data -- `/api/instatus` -- Status page integration +- `/api/status` -- status.nodebyte.host integration (node/service monitor status, uptime, latency) - `/api/panel/*` -- Public panel data (counts, nodes, servers, stats, users) - `/api/trustpilot` -- Trustpilot review data @@ -142,7 +142,7 @@ A small set of lightweight API routes remain in the Next.js app for public-facin │ │ └── users/ # User management │ ├── api/ # Lightweight proxy routes │ │ ├── github/releases/ # GitHub releases proxy -│ │ ├── instatus/ # Status page proxy +│ │ ├── status/ # status.nodebyte.host proxy │ │ ├── panel/ # Public panel data │ │ └── trustpilot/ # Trustpilot proxy │ ├── auth/ # Authentication pages diff --git a/app/api/billing-debug/route.ts b/app/api/billing-debug/route.ts index 674dcba..b2ddfaf 100644 --- a/app/api/billing-debug/route.ts +++ b/app/api/billing-debug/route.ts @@ -1,4 +1,5 @@ import { fetchAllBillingProducts } from "@/packages/core/lib/bytepay" +import { parseDescriptionSpecs } from "@/packages/core/lib/spec-parser" export const dynamic = "force-dynamic" @@ -27,5 +28,20 @@ export async function GET(request: Request) { description: p.description, })) - return Response.json({ count: summary.length, products: summary }) + // Live products whose description fails to parse cpu/ramGB/storageGB — a + // subset (or all) of these fields are required by getGamePlans/getVpsPlans/ + // getDedicatedPlans, so a missing one here means the product silently + // disappears from its billing page. + const dropped = filtered.flatMap((p) => { + const parsed = parseDescriptionSpecs(p.description) + const missing = [ + !parsed.cpu && "cpu", + !parsed.ramGB && "ramGB", + !parsed.storageGB && "storageGB", + ].filter((v): v is string => Boolean(v)) + if (missing.length === 0) return [] + return [{ id: p.id, name: p.name, slug: p.slug, categorySlug: p.categorySlug, missing }] + }) + + return Response.json({ count: summary.length, products: summary, dropped }) } diff --git a/app/api/instatus/route.ts b/app/api/instatus/route.ts deleted file mode 100644 index 21369c1..0000000 --- a/app/api/instatus/route.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { NextResponse } from "next/server" - -export const revalidate = 60 // Cache for 60 seconds - -interface InstatusPage { - name: string - url: string - status: "UP" | "HASISSUES" | "UNDERMAINTENANCE" -} - -interface InstatusIncident { - id: string - name: string - started: string - status: string - impact: string - url: string - updatedAt: string -} - -interface InstatusMaintenance { - id: string - name: string - start: string - status: string - duration: string - url: string - updatedAt: string -} - -interface InstatusResponse { - page: InstatusPage - activeIncidents: InstatusIncident[] - activeMaintenances: InstatusMaintenance[] -} - -export async function GET() { - try { - const response = await fetch("https://nodebytestat.us/summary.json", { - next: { revalidate: 60 }, - headers: { - "Accept": "application/json", - }, - }) - - if (!response.ok) { - throw new Error(`Instatus API returned ${response.status}`) - } - - const data: InstatusResponse = await response.json() - - // Safely handle potentially undefined arrays - const activeIncidents = data.activeIncidents || [] - const activeMaintenances = data.activeMaintenances || [] - - return NextResponse.json({ - status: data.page?.status || "UP", - url: data.page?.url || "https://nodebytestat.us", - hasIncidents: activeIncidents.length > 0, - hasMaintenance: activeMaintenances.length > 0, - incidents: activeIncidents.map((incident) => ({ - id: incident.id, - name: incident.name, - status: incident.status, - impact: incident.impact, - url: incident.url, - })), - maintenances: activeMaintenances.map((maintenance) => ({ - id: maintenance.id, - name: maintenance.name, - status: maintenance.status, - url: maintenance.url, - })), - }) - } catch (error) { - console.error("Failed to fetch Instatus data:", error) - - // Return a fallback response - return NextResponse.json( - { - status: "UP", - url: "https://nodebytestat.us", - hasIncidents: false, - hasMaintenance: false, - incidents: [], - maintenances: [], - error: "Failed to fetch status", - }, - { status: 200 } // Still return 200 to not break the UI - ) - } -} diff --git a/app/api/products/overrides/route.ts b/app/api/products/overrides/route.ts deleted file mode 100644 index 166e32d..0000000 --- a/app/api/products/overrides/route.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { NextResponse } from "next/server" -import { getAllOverrides, setOverride } from "@/packages/core/products/override-store" -import type { ProductOverride } from "@/packages/core/products/override-store" - -export async function GET() { - return NextResponse.json(getAllOverrides()) -} - -export async function PATCH(request: Request) { - let body: { id?: string } & Partial - - try { - body = await request.json() - } catch { - return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }) - } - - const { id, stock, enabled } = body - - if (!id || stock === undefined || enabled === undefined) { - return NextResponse.json( - { error: "id, stock and enabled are required" }, - { status: 400 }, - ) - } - - setOverride(id, { stock, enabled }) - return NextResponse.json({ ok: true }) -} diff --git a/app/api/status/route.ts b/app/api/status/route.ts new file mode 100644 index 0000000..36ba89d --- /dev/null +++ b/app/api/status/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from "next/server" +import { computeLatencyStats, fetchStatusSnapshot, type MonitorStatus, type MonitorType } from "@/packages/core/lib/status" + +export const revalidate = 30 + +export interface StatusApiMonitor { + name: string + type: MonitorType + groupName: string | null + subgroupName: string | null + status: MonitorStatus + uptime30dPct: number | null + latency: { fast: number; avg: number; slow: number } | null +} + +export interface StatusApiResponse { + available: boolean + generatedAt: number | null + overallStatus: MonitorStatus | null + monitors: StatusApiMonitor[] +} + +export async function GET() { + const snapshot = await fetchStatusSnapshot() + + if (!snapshot) { + return NextResponse.json( + { available: false, generatedAt: null, overallStatus: null, monitors: [] }, + { status: 200 }, + ) + } + + const monitors: StatusApiMonitor[] = snapshot.monitors.map((m) => ({ + name: m.name, + type: m.type, + groupName: m.group_name, + subgroupName: m.subgroup_name, + status: m.status, + uptime30dPct: m.uptime_30d_pct, + latency: computeLatencyStats(m), + })) + + return NextResponse.json( + { available: true, generatedAt: snapshot.generated_at, overallStatus: snapshot.overall_status, monitors }, + { + status: 200, + headers: { + "Cache-Control": "public, s-maxage=30, stale-while-revalidate=60", + }, + }, + ) +} diff --git a/app/dedicated/page.tsx b/app/dedicated/page.tsx index a12908f..cf7aae6 100644 --- a/app/dedicated/page.tsx +++ b/app/dedicated/page.tsx @@ -1,6 +1,8 @@ import type { Metadata } from "next" import { DedicatedHub } from "@/packages/ui/components/Layouts/Dedicated/dedicated-hub" import { getDedicatedPlans } from "@/packages/core/products/billing-service" +import { getCategoryHub } from "@/packages/core/lib/bytepay" +import { DEDICATED_HUB_SLUGS } from "@/packages/core/constants/catalog-hubs" export const metadata: Metadata = { title: "Dedicated Servers", @@ -9,6 +11,11 @@ export const metadata: Metadata = { } export default async function DedicatedPage() { - const plans = await getDedicatedPlans("dedicated-servers") + const hub = await getCategoryHub(DEDICATED_HUB_SLUGS) + const children = hub?.children ?? [] + + const plansByCategory = await Promise.all(children.map((c) => getDedicatedPlans(c.slug))) + const plans = plansByCategory.flat() + return } diff --git a/app/games/[slug]/page.tsx b/app/games/[slug]/page.tsx new file mode 100644 index 0000000..5b46079 --- /dev/null +++ b/app/games/[slug]/page.tsx @@ -0,0 +1,95 @@ +import type { Metadata } from "next" +import { notFound } from "next/navigation" +import { Blocks, Gamepad2, Sparkles, Leaf, Pickaxe, Wrench } from "lucide-react" +import { getTranslations } from "next-intl/server" +import { GameHero } from "@/packages/ui/components/Layouts/Games/game-hero" +import { GameFeatures } from "@/packages/ui/components/Layouts/Games/game-features" +import { GamePricing } from "@/packages/ui/components/Layouts/Games/game-pricing" +import { GameFAQ } from "@/packages/ui/components/Layouts/Games/game-faq" +import { getGamePlans } from "@/packages/core/products/billing-service" +import { resolveGameDisplayConfig } from "@/packages/core/products/catalog-config" +import { getCategoryHub } from "@/packages/core/lib/bytepay" +import { GAME_HUB_SLUGS } from "@/packages/core/constants/catalog-hubs" + +const HEADER_ICON_MAP = { Blocks, Gamepad2, Sparkles, Leaf, Pickaxe, Wrench } + +interface PageProps { + params: Promise<{ slug: string }> +} + +async function resolveCategory(slug: string) { + const hub = await getCategoryHub(GAME_HUB_SLUGS) + return hub?.children.find((c) => c.slug === slug) ?? null +} + +export async function generateStaticParams() { + const hub = await getCategoryHub(GAME_HUB_SLUGS) + return (hub?.children ?? []).map((c) => ({ slug: c.slug })) +} + +export async function generateMetadata({ params }: PageProps): Promise { + const { slug } = await params + const category = await resolveCategory(slug) + if (!category) return {} + + const config = resolveGameDisplayConfig(category) + return { + title: `${config.name} Server Hosting`, + description: config.description, + } +} + +export default async function GameCategoryPage({ params }: PageProps) { + const { slug } = await params + const category = await resolveCategory(slug) + if (!category) notFound() + + const t = await getTranslations() + const config = resolveGameDisplayConfig(category) + const rawPlans = await getGamePlans(slug) + + const plans = rawPlans.map((plan) => { + const display = config.resolvePlanDisplay(plan) + return { + name: display.name, + description: display.description, + priceGBP: plan.priceGBP, + prices: plan.prices, + period: t("pricing.perMonth"), + popular: plan.popular, + location: plan.location, + stock: plan.stock, + features: display.features, + url: plan.url, + } + }) + + const HeaderIcon = HEADER_ICON_MAP[config.iconName] + + return ( + <> + + } + headerGradient={config.headerGradient} + headerIconBg={config.headerIconBg} + /> + + + + ) +} diff --git a/app/games/gmod/page.tsx b/app/games/gmod/page.tsx deleted file mode 100644 index f0fc161..0000000 --- a/app/games/gmod/page.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import type { Metadata } from "next" -import { Wrench } from "lucide-react" -import { GameHero } from "@/packages/ui/components/Layouts/Games/game-hero" -import { GameFeatures } from "@/packages/ui/components/Layouts/Games/game-features" -import { GamePricing } from "@/packages/ui/components/Layouts/Games/game-pricing" -import { GameFAQ } from "@/packages/ui/components/Layouts/Games/game-faq" -import { LINKS } from "@/packages/core/constants/links" -import { - GMOD_FEATURES, - GMOD_FAQS, - GMOD_HERO_FEATURES, - GMOD_CONFIG, -} from "@/packages/core/constants/game" - -export const metadata: Metadata = { - title: "Garry's Mod Server Hosting", - description: - "High-performance Garry's Mod server hosting with full Steam Workshop support, MySQL integration, DarkRP-ready setup, and enterprise DDoS protection.", -} - -export default function GModPage() { - return ( - <> - - } - headerGradient={GMOD_CONFIG.headerGradient} - headerIconBg={GMOD_CONFIG.headerIconBg} - /> - - - - ) -} diff --git a/app/games/hytale/page.tsx b/app/games/hytale/page.tsx deleted file mode 100644 index 7af1cf6..0000000 --- a/app/games/hytale/page.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { GameHero } from "@/packages/ui/components/Layouts/Games/game-hero" -import { Sparkles } from "lucide-react" -import { GameFeatures } from "@/packages/ui/components/Layouts/Games/game-features" -import { GamePricing } from "@/packages/ui/components/Layouts/Games/game-pricing" -import { GameFAQ } from "@/packages/ui/components/Layouts/Games/game-faq" -import type { Metadata } from "next" -import { getTranslations } from "next-intl/server" -import { LINKS } from "@/packages/core/constants/links" -import { - HYTALE_PLAN_DISPLAY, - HYTALE_PLAN_STATIC_FEATURES, - HYTALE_FEATURES, - HYTALE_FAQS, - HYTALE_HERO_FEATURES, - HYTALE_CONFIG, -} from "@/packages/core/constants/game" -import { getGamePlans } from "@/packages/core/products/billing-service" - -export const metadata: Metadata = { - title: "Hytale Server Hosting", - description: "Be ready when Hytale launches. NodeByte Hosting will offer high-performance Hytale server hosting with mod support, custom maps, DDoS protection, and 24/7 support.", -} - -export default async function HytalePage() { - const t = await getTranslations() - - const plans = (await getGamePlans("hytale")).map((plan) => { - const display = HYTALE_PLAN_DISPLAY[plan.id as keyof typeof HYTALE_PLAN_DISPLAY] - return { - name: display.name, - description: display.description, - priceGBP: plan.priceGBP, - prices: plan.prices, - period: t("pricing.perMonth"), - popular: plan.popular, - url: plan.url, - stock: plan.stock, - features: [ - ...HYTALE_PLAN_STATIC_FEATURES, - `${plan.ramGB}GB DDR4 RAM`, - `${plan.storageGB}GB SSD Storage`, - ], - } - }) - - return ( - <> - - } - headerGradient={HYTALE_CONFIG.headerGradient} - headerIconBg={HYTALE_CONFIG.headerIconBg} - /> - - - - ) -} - diff --git a/app/games/minecraft/page.tsx b/app/games/minecraft/page.tsx deleted file mode 100644 index 0ddc6cb..0000000 --- a/app/games/minecraft/page.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import type { Metadata } from "next" -import { Blocks } from "lucide-react" -import { GameHero } from "@/packages/ui/components/Layouts/Games/game-hero" -import { GameFeatures } from "@/packages/ui/components/Layouts/Games/game-features" -import { GamePricing } from "@/packages/ui/components/Layouts/Games/game-pricing" -import { GameFAQ } from "@/packages/ui/components/Layouts/Games/game-faq" -import { getTranslations } from "next-intl/server" -import { LINKS } from "@/packages/core/constants/links" -import { - MINECRAFT_PLAN_FEATURE_KEYS, - MINECRAFT_FEATURE_KEYS, - MINECRAFT_FAQ_KEYS, - MINECRAFT_HERO_FEATURES, - MINECRAFT_CONFIG, -} from "@/packages/core/constants/game" -import { getGamePlans } from "@/packages/core/products/billing-service" - -export const metadata: Metadata = { - title: "Minecraft Server Hosting", - description: "High-performance Minecraft server hosting with instant setup, Java & Bedrock support, one-click Forge & Fabric mod loaders, and enterprise DDoS protection.", -} - -export default async function MinecraftPage() { - const t = await getTranslations() - - const plans = (await getGamePlans("minecraft")).map((plan) => ({ - name: t(`games.minecraft.plans.${plan.id}.name`), - description: t(`games.minecraft.plans.${plan.id}.description`), - priceGBP: plan.priceGBP, - prices: plan.prices, - period: t("pricing.perMonth"), - popular: plan.popular, - location: plan.location, - stock: plan.stock, - features: MINECRAFT_PLAN_FEATURE_KEYS.map((key) => { - if (key === "ram") return t("games.minecraft.planFeatures.ram", { amount: plan.ramGB }) - if (key === "storage") return t("games.minecraft.planFeatures.storage", { amount: plan.storageGB }) - return t(`games.minecraft.planFeatures.${key}`) - }), - url: plan.url, - })) - - const features = MINECRAFT_FEATURE_KEYS.map(({ key, icon }) => ({ - title: t(`games.minecraft.pageFeatures.${key}.title`), - description: t(`games.minecraft.pageFeatures.${key}.description`), - icon, - highlights: Array.from({ length: 4 }, (_, i) => - t(`games.minecraft.pageFeatures.${key}.highlights.${i}`) - ), - })) - - const faqs = MINECRAFT_FAQ_KEYS.map((key) => ({ - question: t(`games.minecraft.faqs.${key}.question`), - answer: t(`games.minecraft.faqs.${key}.answer`), - })) - - return ( - <> - - } - headerGradient={MINECRAFT_CONFIG.headerGradient} - headerIconBg={MINECRAFT_CONFIG.headerIconBg} - /> - - - - ) -} diff --git a/app/games/page.tsx b/app/games/page.tsx index 04feb13..5a5b9cc 100644 --- a/app/games/page.tsx +++ b/app/games/page.tsx @@ -8,7 +8,10 @@ import Link from "next/link" import { getTranslations } from "next-intl/server" import { cn } from "@/lib/utils" import type { Metadata } from "next" -import { GAME_OPTIONS } from "@/packages/core/constants/game" +import { getCategoryHub } from "@/packages/core/lib/bytepay" +import { GAME_HUB_SLUGS } from "@/packages/core/constants/catalog-hubs" +import { getGamePlans } from "@/packages/core/products/billing-service" +import { resolveGameDisplayConfig } from "@/packages/core/products/catalog-config" const ICON_MAP: Record = { Blocks, Gamepad2, Sparkles, Leaf, Pickaxe, Wrench } @@ -22,27 +25,36 @@ export async function generateMetadata(): Promise { export default async function GamesPage() { const t = await getTranslations() + const hub = await getCategoryHub(GAME_HUB_SLUGS) + const children = hub?.children ?? [] - const games = GAME_OPTIONS.map((g) => ({ - ...g, - comingSoon: g.comingSoon ?? false, - icon: ICON_MAP[g.iconName], - description: t(`games.${g.slug}.description`), - tag: t(`games.${g.slug}.tag`), - features: [ - t(`games.${g.slug}.features.0`), - t(`games.${g.slug}.features.1`), - t(`games.${g.slug}.features.2`), - t(`games.${g.slug}.features.3`), - ], - })) + const games = await Promise.all( + children.map(async (category) => { + const config = resolveGameDisplayConfig(category) + const plans = await getGamePlans(category.slug) + const comingSoon = plans.length === 0 + const startingPriceGBP = comingSoon ? 0 : Math.min(...plans.map((p) => p.priceGBP)) + return { + slug: category.slug, + name: config.name, + description: config.description, + banner: config.banner, + tag: config.tag, + tagColor: config.tagColor, + icon: ICON_MAP[config.iconName], + features: config.heroFeatures, + comingSoon, + startingPriceGBP, + } + }), + ) return (
{/* Background */}
- + {/* Animated orbs */}
@@ -71,7 +83,7 @@ export default async function GamesPage() {
{games.map((game) => (
- + {/* Tag */}
- {game.features.map((feature, i) => ( + {game.features.map((feature) => (
  • {feature} diff --git a/app/games/palworld/page.tsx b/app/games/palworld/page.tsx deleted file mode 100644 index 983e5a4..0000000 --- a/app/games/palworld/page.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import type { Metadata } from "next" -import { Leaf } from "lucide-react" -import { GameHero } from "@/packages/ui/components/Layouts/Games/game-hero" -import { GameFeatures } from "@/packages/ui/components/Layouts/Games/game-features" -import { GamePricing } from "@/packages/ui/components/Layouts/Games/game-pricing" -import { GameFAQ } from "@/packages/ui/components/Layouts/Games/game-faq" -import { LINKS } from "@/packages/core/constants/links" -import { - PALWORLD_FEATURES, - PALWORLD_FAQS, - PALWORLD_HERO_FEATURES, - PALWORLD_CONFIG, -} from "@/packages/core/constants/game" - -export const metadata: Metadata = { - title: "Palworld Server Hosting", - description: - "High-performance Palworld server hosting with custom world configuration, mod support, automatic backups, and enterprise DDoS protection.", -} - -export default function PalworldPage() { - return ( - <> - - } - headerGradient={PALWORLD_CONFIG.headerGradient} - headerIconBg={PALWORLD_CONFIG.headerIconBg} - /> - - - - ) -} diff --git a/app/games/rust/page.tsx b/app/games/rust/page.tsx deleted file mode 100644 index 1eb4aa2..0000000 --- a/app/games/rust/page.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import type { Metadata } from "next" -import { GameHero } from "@/packages/ui/components/Layouts/Games/game-hero" -import { Gamepad2 } from "lucide-react" -import { GameFeatures } from "@/packages/ui/components/Layouts/Games/game-features" -import { GamePricing } from "@/packages/ui/components/Layouts/Games/game-pricing" -import { GameFAQ } from "@/packages/ui/components/Layouts/Games/game-faq" -import { getTranslations } from "next-intl/server" -import { LINKS } from "@/packages/core/constants/links" -import { - RUST_PLAN_FEATURE_KEYS, - RUST_FEATURE_KEYS, - RUST_FAQ_KEYS, - RUST_HERO_FEATURES, - RUST_CONFIG, -} from "@/packages/core/constants/game" -import { getGamePlans } from "@/packages/core/products/billing-service" - -export const metadata: Metadata = { - title: "Rust Server Hosting", - description: "High-performance Rust server hosting with Oxide/uMod support, custom maps, wipe scheduling, RCON access, and enterprise DDoS protection.", -} - -export default async function RustPage() { - const t = await getTranslations() - - const plans = (await getGamePlans("rust")).map((plan) => ({ - name: t(`games.rust.plans.${plan.id}.name`), - description: t(`games.rust.plans.${plan.id}.description`), - priceGBP: plan.priceGBP, - prices: plan.prices, - period: t("pricing.perMonth"), - popular: plan.popular, - location: plan.location, - stock: plan.stock, - features: RUST_PLAN_FEATURE_KEYS.map((key) => { - if (key === "ram") return t("games.rust.planFeatures.ram", { amount: plan.ramGB }) - if (key === "storage") return t("games.rust.planFeatures.storage", { amount: plan.storageGB }) - return t(`games.rust.planFeatures.${key}`) - }), - url: plan.url, - })) - - const features = RUST_FEATURE_KEYS.map(({ key, icon }) => ({ - title: t(`games.rust.pageFeatures.${key}.title`), - description: t(`games.rust.pageFeatures.${key}.description`), - icon, - highlights: Array.from({ length: 4 }, (_, i) => - t(`games.rust.pageFeatures.${key}.highlights.${i}`) - ), - })) - - const faqs = RUST_FAQ_KEYS.map((key) => ({ - question: t(`games.rust.faqs.${key}.question`), - answer: t(`games.rust.faqs.${key}.answer`), - })) - - return ( - <> - - } - headerGradient={RUST_CONFIG.headerGradient} - headerIconBg={RUST_CONFIG.headerIconBg} - /> - - - - ) -} - diff --git a/app/games/terraria/page.tsx b/app/games/terraria/page.tsx deleted file mode 100644 index 6f5e207..0000000 --- a/app/games/terraria/page.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import type { Metadata } from "next" -import { Pickaxe } from "lucide-react" -import { GameHero } from "@/packages/ui/components/Layouts/Games/game-hero" -import { GameFeatures } from "@/packages/ui/components/Layouts/Games/game-features" -import { GamePricing } from "@/packages/ui/components/Layouts/Games/game-pricing" -import { GameFAQ } from "@/packages/ui/components/Layouts/Games/game-faq" -import { LINKS } from "@/packages/core/constants/links" -import { - TERRARIA_FEATURES, - TERRARIA_FAQS, - TERRARIA_HERO_FEATURES, - TERRARIA_CONFIG, -} from "@/packages/core/constants/game" - -export const metadata: Metadata = { - title: "Terraria Server Hosting", - description: - "High-performance Terraria server hosting with full tModLoader support, custom world configuration, automatic backups, and enterprise DDoS protection.", -} - -export default function TerrariaPage() { - return ( - <> - - } - headerGradient={TERRARIA_CONFIG.headerGradient} - headerIconBg={TERRARIA_CONFIG.headerIconBg} - /> - - - - ) -} diff --git a/app/layout.tsx b/app/layout.tsx index 9c353b4..5f03d09 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -14,6 +14,8 @@ import { ThemeProvider } from "@/packages/ui/components/theme-provider" import { CurrencyProvider } from "@/packages/core/hooks/use-currency" import { LocaleProvider } from "@/packages/core/hooks/use-locale" import { LayoutChrome } from "@/packages/ui/components/layout-chrome" +import { getCategoryHub } from "@/packages/core/lib/bytepay" +import { GAME_HUB_SLUGS } from "@/packages/core/constants/catalog-hubs" const geist = Geist({ subsets: ["latin"], @@ -93,6 +95,12 @@ export default async function RootLayout({ const locale = await getLocale() const messages = await getMessages() + // Never let a billing-panel outage take down every page on the site — the + // nav just falls back to no games submenu entries if this fails. + const gamesNav = await getCategoryHub(GAME_HUB_SLUGS) + .then((hub) => (hub?.children ?? []).map((c) => ({ slug: c.slug, name: c.name }))) + .catch(() => []) + const htmlClass = [geist.variable, geistMono.variable, themeClass].filter(Boolean).join(" ") return ( @@ -148,7 +156,7 @@ export default async function RootLayout({ - + {children} diff --git a/app/vps/page.tsx b/app/vps/page.tsx index 64e0a6f..3b5e94f 100644 --- a/app/vps/page.tsx +++ b/app/vps/page.tsx @@ -1,6 +1,8 @@ import type { Metadata } from "next" import { VpsHub } from "@/packages/ui/components/Layouts/VPS/vps-hub" import { getVpsPlans } from "@/packages/core/products/billing-service" +import { getCategoryHub } from "@/packages/core/lib/bytepay" +import { VPS_HUB_SLUGS } from "@/packages/core/constants/catalog-hubs" export const metadata: Metadata = { title: "VPS Hosting", @@ -9,10 +11,11 @@ export const metadata: Metadata = { } export default async function VpsPage() { - const [sharedPlans, dedicatedPlans] = await Promise.all([ - getVpsPlans("shared-cpu"), - getVpsPlans("dedicated-cpu"), - ]) - return -} + const hub = await getCategoryHub(VPS_HUB_SLUGS) + const children = hub?.children ?? [] + + const plansByCategory = await Promise.all(children.map((c) => getVpsPlans(c.slug))) + const plans = plansByCategory.flat() + return +} diff --git a/packages/core/constants/catalog-hubs.ts b/packages/core/constants/catalog-hubs.ts new file mode 100644 index 0000000..dd40358 --- /dev/null +++ b/packages/core/constants/catalog-hubs.ts @@ -0,0 +1,11 @@ +/** + * The site has exactly 3 fundamentally different page templates (games, + * VPS, dedicated) — which parent category in Paymenter maps to which + * template is the one thing that stays a fixed, hand-maintained mapping. + * Everything under a hub (which games/lines/tiers exist) is discovered live. + * + * Name the parent category in Paymenter as either alias to be picked up. + */ +export const GAME_HUB_SLUGS = ["game-servers", "games"] +export const VPS_HUB_SLUGS = ["vps-hosting", "vps", "vps-servers"] +export const DEDICATED_HUB_SLUGS = ["dedicated-servers", "dedicated", "dedi"] diff --git a/packages/core/constants/game/gmod.ts b/packages/core/constants/game/gmod.ts index 82f92d4..2b4c529 100644 --- a/packages/core/constants/game/gmod.ts +++ b/packages/core/constants/game/gmod.ts @@ -1,8 +1,3 @@ -import type { GamePlanSpec } from "@/packages/core/types/servers/game"; - -/** Garry's Mod hosting is coming soon — no plans yet. */ -export const GMOD_PLANS: GamePlanSpec[] = [] - /** Static features shared across all Garry's Mod plans (for future use). */ export const GMOD_PLAN_STATIC_FEATURES = [ "AMD Ryzen™ 9 5900X", diff --git a/packages/core/constants/game/hytale.ts b/packages/core/constants/game/hytale.ts index f5e0e95..9b8197c 100644 --- a/packages/core/constants/game/hytale.ts +++ b/packages/core/constants/game/hytale.ts @@ -1,31 +1,3 @@ -import type { GamePlanSpec } from "@/packages/core/types/servers/game"; - -/** Hytale is not yet released — plans are placeholder/early-access pricing. */ -export const HYTALE_PLANS: GamePlanSpec[] = [ - { - id: "starter", - priceGBP: 5, - ramGB: 4, - storageGB: 40, - url: "https://billing.nodebyte.host/products/hytale-hosting/hytale-starter", - }, - { - id: "standard", - priceGBP: 7.5, - ramGB: 6, - storageGB: 60, - url: "https://billing.nodebyte.host/products/hytale-hosting/hytale-standard", - }, - { - id: "performance", - priceGBP: 10, - ramGB: 8, - storageGB: 80, - popular: true, - url: "https://billing.nodebyte.host/products/hytale-hosting/hytale-performance", - }, -] - /** * Hytale doesn't have translation keys yet — names/descriptions are stored * here directly instead of being keyed through i18n. diff --git a/packages/core/constants/game/index.ts b/packages/core/constants/game/index.ts index 796df27..b707dc6 100644 --- a/packages/core/constants/game/index.ts +++ b/packages/core/constants/game/index.ts @@ -4,81 +4,3 @@ export * from "./hytale" export * from "./terraria" export * from "./gmod" export * from "./palworld" - -import { MINECRAFT_PLANS } from "./minecraft" -import { RUST_PLANS } from "./rust" -import { HYTALE_PLANS } from "./hytale" -import { TERRARIA_PLANS } from "./terraria" -import { GMOD_PLANS } from "./gmod" -import { PALWORLD_PLANS } from "./palworld" - -/** - * Metadata for each game offering — used by the /games index page. - * Mirrors VPS_OPTIONS in packages/core/constants/vps/index.ts. - */ -export const GAME_OPTIONS = [ - { - slug: "minecraft" as const, - name: "Minecraft", - startingPriceGBP: Math.min(...MINECRAFT_PLANS.map((p) => p.priceGBP)), - banner: "/games/minecraft.png", - iconName: "Blocks" as const, - tagColor: "bg-primary text-primary-foreground", - headerGradient: "from-primary/20 via-primary/10 to-accent/5", - headerIconBg: "bg-primary/10 text-primary", - }, - { - slug: "rust" as const, - name: "Rust", - startingPriceGBP: Math.min(...RUST_PLANS.map((p) => p.priceGBP)), - banner: "/games/rust.png", - iconName: "Gamepad2" as const, - tagColor: "bg-accent text-accent-foreground", - headerGradient: "from-accent/20 via-accent/10 to-primary/5", - headerIconBg: "bg-accent/10 text-accent", - }, - { - slug: "hytale" as const, - name: "Hytale", - startingPriceGBP: HYTALE_PLANS.length ? Math.min(...HYTALE_PLANS.map((p) => p.priceGBP)) : 0, - comingSoon: !HYTALE_PLANS.length, - banner: "/games/hytale.png", - iconName: "Sparkles" as const, - tagColor: "bg-amber-500/15 text-amber-400 border border-amber-500/20", - headerGradient: "from-amber-500/20 via-amber-500/10 to-primary/5", - headerIconBg: "bg-amber-500/10 text-amber-400", - }, - { - slug: "terraria" as const, - name: "Terraria", - startingPriceGBP: TERRARIA_PLANS.length ? Math.min(...TERRARIA_PLANS.map((p) => p.priceGBP)) : 0, - comingSoon: !TERRARIA_PLANS.length, - banner: "/games/terraria.png", - iconName: "Pickaxe" as const, - tagColor: "bg-lime-500/15 text-lime-400 border border-lime-500/20", - headerGradient: "from-lime-500/20 via-lime-500/10 to-primary/5", - headerIconBg: "bg-lime-500/10 text-lime-400", - }, - { - slug: "gmod" as const, - name: "Garry's Mod", - startingPriceGBP: GMOD_PLANS.length ? Math.min(...GMOD_PLANS.map((p) => p.priceGBP)) : 0, - comingSoon: !GMOD_PLANS.length, - banner: "/games/gmod.png", - iconName: "Wrench" as const, - tagColor: "bg-orange-500/15 text-orange-400 border border-orange-500/20", - headerGradient: "from-orange-500/20 via-orange-500/10 to-primary/5", - headerIconBg: "bg-orange-500/10 text-orange-400", - }, - { - slug: "palworld" as const, - name: "Palworld", - startingPriceGBP: PALWORLD_PLANS.length ? Math.min(...PALWORLD_PLANS.map((p) => p.priceGBP)) : 0, - comingSoon: !PALWORLD_PLANS.length, - banner: "/games/palworld.png", - iconName: "Leaf" as const, - tagColor: "bg-green-500/15 text-green-400 border border-green-500/20", - headerGradient: "from-green-500/20 via-green-500/10 to-primary/5", - headerIconBg: "bg-green-500/10 text-green-400", - }, -] diff --git a/packages/core/constants/game/minecraft.ts b/packages/core/constants/game/minecraft.ts index aa5aae4..703706f 100644 --- a/packages/core/constants/game/minecraft.ts +++ b/packages/core/constants/game/minecraft.ts @@ -1,86 +1,95 @@ -import { GamePlanSpec } from "@/packages/core/types/servers/game"; - /** - * MINECRAFT PLAN LIST - * @type {GamePlanSpec} The Gameplan Typing Spec + * Minecraft doesn't need translation keys — plan/feature/FAQ copy is stored + * here directly, same pattern as Hytale/Terraria/Gmod/Palworld. Plan-level + * content always prefers the live billing panel data first; this is only a + * fallback for the 5 originally-curated plan ids. */ -export const MINECRAFT_PLANS: GamePlanSpec[] = [ +export const MINECRAFT_PLAN_DISPLAY = { + ember: { name: "Ember", description: "Perfect for small servers and testing" }, + blaze: { name: "Blaze", description: "Great for growing communities" }, + inferno: { name: "Inferno", description: "Ideal for medium sized communities" }, + firestorm: { name: "Firestorm", description: "Built for large and active communities" }, + supernova: { name: "Supernova", description: "Maximum power for massive servers" }, +} as const satisfies Record + +/** Static features shared across all Minecraft plans; RAM/storage are appended parametrically per plan. */ +export const MINECRAFT_PLAN_STATIC_FEATURES = [ + "High Performance CPU", + "10 MySQL Databases", + "DDoS Protection", + "BytePanel", + "Auto/Pre Installed Jars", + "99.6% Uptime SLA", +] as const + +export const MINECRAFT_FEATURES = [ { - id: "ember", - priceGBP: 4, - ramGB: 4, - storageGB: 40, - url: "https://billing.nodebyte.host/products/minecraft-server-hosting/ember", + title: "One-Click Mod Loaders", + description: "Install Forge, Fabric, Paper, Spigot, and more with a single click from our control panel.", + icon: "Settings" as const, + highlights: ["Forge & Fabric support", "Paper & Spigot servers", "Bukkit compatibility", "Custom JAR uploads"], }, { - id: "blaze", - priceGBP: 6, - ramGB: 6, - storageGB: 60, - url: "https://billing.nodebyte.host/products/minecraft-server-hosting/blaze", + title: "High Performance Hardware", + description: "Enterprise grade processors with NVMe storage for blazing fast performance.", + icon: "Cpu" as const, + highlights: ["High performance CPUs", "NVMe SSD storage", "DDR4 ECC memory", "High clock speed processors"], }, { - id: "inferno", - priceGBP: 7.5, - ramGB: 8, - storageGB: 80, - popular: true, - url: "https://billing.nodebyte.host/products/minecraft-server-hosting/inferno", + title: "DDoS Protection", + description: "Enterprise grade DDoS mitigation keeps your server online even during the largest attacks.", + icon: "Shield" as const, + highlights: ["Layer 3/4/7 protection", "Enterprise network filtering", "Zero downtime mitigation", "Enterprise POPs"], }, { - id: "firestorm", - priceGBP: 15, - ramGB: 16, - storageGB: 160, - popular: true, - url: "https://billing.nodebyte.host/products/minecraft-server-hosting/firestorm", + title: "Instant Setup", + description: "Your server is deployed within seconds. Start playing immediately after purchase.", + icon: "Zap" as const, + highlights: ["Automated provisioning", "Pre configured settings", "Ready in under 60 seconds", "No technical knowledge needed"], }, { - id: "supernova", - priceGBP: 30, - ramGB: 32, - storageGB: 320, - popular: true, - url: "https://billing.nodebyte.host/products/minecraft-server-hosting/supernova", + title: "Full FTP Access", + description: "Complete file access via FTP/SFTP. Upload worlds, plugins, and configurations with ease.", + icon: "HardDrive" as const, + highlights: ["SFTP file access", "Web based file manager", "Drag & drop uploads", "Automatic backups"], + }, + { + title: "Unlimited Slots", + description: "No artificial player limits. Host as many players as your hardware can handle.", + icon: "Users" as const, + highlights: ["No slot restrictions", "Scalable resources", "Upgrade anytime", "Fair resource allocation"], }, -] - -/** - * Plan feature keys — maps to `games.minecraft.planFeatures.` in translations. - * "ram" and "storage" are parametric (use plan.ramGB / plan.storageGB). - */ -export const MINECRAFT_PLAN_FEATURE_KEYS = [ - "cpu", - "ram", - "storage", - "databases", - "ddos", - "panel", - "jars", - "uptime", -] as const - -export type MinecraftPlanFeatureKey = (typeof MINECRAFT_PLAN_FEATURE_KEYS)[number] - -/** Feature section keys — maps to `games.minecraft.pageFeatures..*` in translations. */ -export const MINECRAFT_FEATURE_KEYS = [ - { key: "modLoaders", icon: "Settings" as const }, - { key: "hardware", icon: "Cpu" as const }, - { key: "ddos", icon: "Shield" as const }, - { key: "instant", icon: "Zap" as const }, - { key: "ftp", icon: "HardDrive" as const }, - { key: "slots", icon: "Users" as const }, ] as const -/** FAQ keys — maps to `games.minecraft.faqs..{question,answer}` in translations. */ -export const MINECRAFT_FAQ_KEYS = [ - "versions", - "mods", - "upload", - "playerLimit", - "upgrade", - "refunds", - "location", +export const MINECRAFT_FAQS = [ + { + question: "What Minecraft versions do you support?", + answer: "We support all Minecraft versions from 1.7.10 to the latest release, including snapshots. Both Java Edition and Bedrock Edition servers are available.", + }, + { + question: "Can I install mods and plugins?", + answer: "Yes! We support all major mod loaders including Forge, Fabric, and NeoForge. For plugins, we support Paper, Spigot, Bukkit, and Purpur. You can also upload custom JARs.", + }, + { + question: "How do I upload my existing world?", + answer: "You can upload your world files via our web based file manager or through SFTP. Simply drag and drop your world folder and it will be ready to use.", + }, + { + question: "Is there a player limit?", + answer: "No, we don't impose artificial player limits. Your server can host as many players as your allocated resources can handle.", + }, + { + question: "Can I upgrade my plan later?", + answer: "Absolutely! You can upgrade or downgrade your plan at any time from our billing panel. Changes take effect immediately with no downtime.", + }, + { + question: "Do you offer refunds?", + answer: "Yes, we offer a 48 hour money back guarantee on all new purchases. If you're not satisfied, contact support for a full refund.", + }, + { + question: "Can I choose my server location?", + answer: "Yes! You can select your preferred data centre location at checkout. We offer multiple locations to ensure the best latency for you and your players.", + }, ] as const /** Static hero feature pills. */ @@ -97,6 +106,7 @@ export const MINECRAFT_CONFIG = { description: "High performance Minecraft server hosting with instant setup, one click mod loaders, and enterprise grade DDoS protection. Java & Bedrock support.", banner: "/games/minecraft.png", + tag: "Most Popular", /** String name matching a lucide-react icon — instantiate in the page component */ iconName: "Blocks", tagColor: "bg-primary/10 border border-primary/20 text-primary", diff --git a/packages/core/constants/game/palworld.ts b/packages/core/constants/game/palworld.ts index e232222..82addbd 100644 --- a/packages/core/constants/game/palworld.ts +++ b/packages/core/constants/game/palworld.ts @@ -1,8 +1,3 @@ -import type { GamePlanSpec } from "@/packages/core/types/servers/game"; - -/** Palworld hosting is coming soon — no plans yet. */ -export const PALWORLD_PLANS: GamePlanSpec[] = [] - /** Static features shared across all Palworld plans (for future use). */ export const PALWORLD_PLAN_STATIC_FEATURES = [ "AMD Ryzen™ 9 5900X", diff --git a/packages/core/constants/game/rust.ts b/packages/core/constants/game/rust.ts index 3fe8779..c4698df 100644 --- a/packages/core/constants/game/rust.ts +++ b/packages/core/constants/game/rust.ts @@ -1,75 +1,96 @@ -import type { GamePlanSpec } from "@/packages/core/types/servers/game"; +/** + * Rust doesn't need translation keys — plan/feature/FAQ copy is stored here + * directly, same pattern as Hytale/Terraria/Gmod/Palworld. Plan-level content + * always prefers the live billing panel data first; this is only a fallback + * for the 4 originally-curated plan ids. + */ +export const RUST_PLAN_DISPLAY = { + starter: { name: "Starter", description: "Recommended for 40 Players" }, + standard: { name: "Standard", description: "Recommended for 75 Players" }, + performance: { name: "Performance", description: "Recommended for 100 Players" }, + premium: { name: "Premium", description: "Recommended for 150+ Players" }, +} as const satisfies Record + +/** Static features shared across all Rust plans; RAM/storage are appended parametrically per plan. */ +export const RUST_PLAN_STATIC_FEATURES = [ + "High Performance CPU", + "DDoS Protection", + "Multiple Locations", + "10 MySQL Databases", + "BytePanel (GSM)", + "Oxide/Umod Supported", + "Rust+ Supported", + "99.6% Uptime SLA", +] as const -export const RUST_PLANS: GamePlanSpec[] = [ +export const RUST_FEATURES = [ { - id: "starter", - priceGBP: 5.75, - ramGB: 8, - storageGB: 150, - url: "https://billing.nodebyte.host/products/rust-hosting/starter" + title: "Oxide/uMod Support", + description: "Full support for Oxide and uMod plugins. Install and manage plugins directly from our control panel.", + icon: "Settings" as const, + highlights: ["One-click Oxide install", "Plugin manager", "Auto updates available", "Permission management"], }, { - id: "standard", - priceGBP: 8.95, - ramGB: 12, - storageGB: 200, - popular: true, - url: "https://billing.nodebyte.host/products/rust-hosting/standard" + title: "Custom Maps", + description: "Use procedurally generated maps or upload your own custom maps. Full map customization support.", + icon: "Map" as const, + highlights: ["Procedural generation", "Custom map uploads", "Map size control", "Seed customization"], }, { - id: "performance", - priceGBP: 12.75, - ramGB: 16, - storageGB: 250, - url: "https://billing.nodebyte.host/products/rust-hosting/performance" + title: "High Performance", + description: "Rust demands powerful hardware. Our servers use high performance CPUs and NVMe storage for a smooth experience.", + icon: "Cpu" as const, + highlights: ["High performance CPUs", "NVMe SSD storage", "High single thread performance", "Low-latency networking"], }, { - id: "premium", - priceGBP: 18.99, - ramGB: 32, - storageGB: 350, - url: "https://billing.nodebyte.host/products/rust-hosting/premium" + title: "DDoS Protection", + description: "Enterprise grade DDoS mitigation through multiple network POPs protects your server from attacks 24/7.", + icon: "Shield" as const, + highlights: ["Enterprise network filtering", "Global POPs", "Layer 3/4/7 protection", "Zero downtime"], + }, + { + title: "Wipe Scheduler", + description: "Automated wipe scheduling to keep your server fresh. Configure weekly, bi-weekly, or monthly wipes.", + icon: "Zap" as const, + highlights: ["Automated wipes", "Blueprint wipe options", "Map wipe scheduling", "Discord notifications"], + }, + { + title: "Full RCON Access", + description: "Complete remote console access for server management. Execute commands from anywhere.", + icon: "Server" as const, + highlights: ["Web-based RCON", "Command scheduling", "Player management", "Real-time logs"], }, -] - -/** - * Plan feature keys — maps to `games.rust.planFeatures.` in translations. - * "ram" and "storage" are parametric (use plan.ramGB / plan.storageGB). - */ -export const RUST_PLAN_FEATURE_KEYS = [ - "cpu", - "ram", - "storage", - "ddos", - "location", - "databases", - "panel", - "oxide", - "rustplus", - "uptime", -] as const - -export type RustPlanFeatureKey = (typeof RUST_PLAN_FEATURE_KEYS)[number] - -/** Feature section keys — maps to `games.rust.pageFeatures..*` in translations. */ -export const RUST_FEATURE_KEYS = [ - { key: "oxide", icon: "Settings" as const }, - { key: "maps", icon: "Map" as const }, - { key: "performance", icon: "Cpu" as const }, - { key: "ddos", icon: "Shield" as const }, - { key: "wipe", icon: "Zap" as const }, - { key: "rcon", icon: "Server" as const }, ] as const -/** FAQ keys — maps to `games.rust.faqs..{question,answer}` in translations. */ -export const RUST_FAQ_KEYS = [ - "oxide", - "maps", - "wipe", - "tickRate", - "rcon", - "modded", - "location", +export const RUST_FAQS = [ + { + question: "Do you support Oxide/uMod plugins?", + answer: "Yes! We fully support Oxide and uMod. You can install Oxide with one click from our control panel and manage plugins through our plugin manager or via FTP.", + }, + { + question: "Can I use custom maps?", + answer: "Absolutely! You can use procedurally generated maps with custom seeds and sizes, or upload your own custom map files via FTP.", + }, + { + question: "How does the wipe scheduler work?", + answer: "Our wipe scheduler lets you automate server wipes on a schedule you choose. You can configure map-only wipes or full blueprint wipes, and optionally send Discord notifications.", + }, + { + question: "What's the server tick rate?", + answer: "Our Rust servers run at the default 30 tick rate. Our high-performance hardware ensures consistent performance even with many players online.", + }, + { + question: "Can I access RCON?", + answer: "Yes, you get full RCON access. You can use our web-based RCON console or connect with any standard RCON client.", + }, + { + question: "Do you support modded servers?", + answer: "Yes, we support both vanilla and modded Rust servers. Install Oxide and add any plugins you need to create your perfect modded experience.", + }, + { + question: "Can I choose my server location?", + answer: "Yes! You can select your preferred data centre location at checkout. We offer multiple locations to ensure the best latency for you and your players.", + }, ] as const /** Static hero feature pills. */ @@ -86,6 +107,7 @@ export const RUST_CONFIG = { description: "High-performance Rust server hosting with Oxide/uMod support, custom maps, wipe scheduling, and enterprise-grade DDoS protection.", banner: "/games/rust.png", + tag: "High Performance", /** String name matching a lucide-react icon — instantiate in the page component */ iconName: "Gamepad2", tagColor: "bg-accent/10 border border-accent/20 text-accent", diff --git a/packages/core/constants/game/terraria.ts b/packages/core/constants/game/terraria.ts index 9c89bfc..f01ead4 100644 --- a/packages/core/constants/game/terraria.ts +++ b/packages/core/constants/game/terraria.ts @@ -1,8 +1,3 @@ -import type { GamePlanSpec } from "@/packages/core/types/servers/game"; - -/** Terraria hosting is coming soon — no plans yet. */ -export const TERRARIA_PLANS: GamePlanSpec[] = [] - /** Static features shared across all Terraria plans (for future use). */ export const TERRARIA_PLAN_STATIC_FEATURES = [ "AMD Ryzen™ 9 5900X", diff --git a/packages/core/constants/services.ts b/packages/core/constants/services.ts index 2a8b2ab..a4a7f67 100644 --- a/packages/core/constants/services.ts +++ b/packages/core/constants/services.ts @@ -65,7 +65,7 @@ export const SERVICE_CATEGORIES: ServiceCategory[] = [ highlights: [ "Enterprise-grade processors", "Full root / SSH access", - "NVMe SSD storage", + "Lightning fast networks", "Enterprise DDoS protection", ], enabled: true, diff --git a/packages/core/constants/status-mapping.ts b/packages/core/constants/status-mapping.ts new file mode 100644 index 0000000..7e6418f --- /dev/null +++ b/packages/core/constants/status-mapping.ts @@ -0,0 +1,33 @@ +/** + * Bridges the two independent naming schemes between status.nodebyte.host + * (monitor names) and this site's node/location identifiers. Update these + * maps whenever a node is renamed or a new Region ping monitor is added + * upstream — unmapped entries simply render without live data. + */ + +/** Website node `name` (STATIC_NODES) → status.nodebyte.host monitor `name`. */ +export const NODE_MONITOR_MAP: Record = { + "NEWC-GAME1": "NEWC-GAME1", + "NEWY-GAME1": "NEWY-GAME1", + "FLUXRP-FIVEM": "FLUXRP-FIVEM", + "HEL-VPS1": "HEL-VPS1", +} + +/** + * Website location `id` (LOCATIONS) → status.nodebyte.host "Regions" monitor + * `name`. Only locations with a confirmed matching ping monitor are listed; + * the Regions group also includes PoPs (e.g. Ashburn VA, Atlanta GA) that + * don't correspond to an actual NodeByte data centre location. + */ +export const LOCATION_MONITOR_MAP: Record = { + lon: "London, UK", + fal: "Falkenstein, DE", + fra: "Frankfurt, DE", + hel: "Helsinki, FI", + tor: "Toronto, ON", + vhv: "Ashburn, VA", + newy: "New York, USA", + sgp: "Singapore, Singapore", + syd: "Sydney, Australia", + mum: "Mumbai, India", +} diff --git a/packages/core/hooks/use-node-status.ts b/packages/core/hooks/use-node-status.ts new file mode 100644 index 0000000..1c5b6c4 --- /dev/null +++ b/packages/core/hooks/use-node-status.ts @@ -0,0 +1,41 @@ +"use client" + +import { useEffect, useState } from "react" +import type { StatusApiMonitor, StatusApiResponse } from "@/app/api/status/route" +import type { MonitorStatus } from "@/packages/core/lib/status" + +const REFRESH_INTERVAL_MS = 60_000 + +export function useNodeStatus() { + const [monitors, setMonitors] = useState([]) + const [available, setAvailable] = useState(false) + const [overallStatus, setOverallStatus] = useState(null) + + useEffect(() => { + let cancelled = false + + const load = () => { + fetch("/api/status") + .then((r) => r.json()) + .then((data: StatusApiResponse) => { + if (cancelled) return + setAvailable(data.available) + setMonitors(data.monitors) + setOverallStatus(data.overallStatus) + }) + .catch(() => {}) + } + + load() + const interval = setInterval(load, REFRESH_INTERVAL_MS) + return () => { + cancelled = true + clearInterval(interval) + } + }, []) + + const findMonitor = (name: string) => + monitors.find((m) => m.name.trim().toLowerCase() === name.trim().toLowerCase()) ?? null + + return { available, overallStatus, monitors, findMonitor } +} diff --git a/packages/core/lib/bytepay.ts b/packages/core/lib/bytepay.ts index 6221cb1..7a60723 100644 --- a/packages/core/lib/bytepay.ts +++ b/packages/core/lib/bytepay.ts @@ -5,8 +5,54 @@ * JSON:API response into a flat, typed structure the website can consume. */ -const BYTEPAY_HOST = process.env.BYTEPAY_HOST! -const BYTEPAY_TOKEN = process.env.BYTEPAY_TOKEN! +import { unstable_cache } from "next/cache" + +function getConfig(): { host: string; token: string } { + const host = process.env.BYTEPAY_HOST + const token = process.env.BYTEPAY_TOKEN + if (!host || !token) { + throw new Error( + "Billing API misconfigured: BYTEPAY_HOST and BYTEPAY_TOKEN must both be set.", + ) + } + return { host, token } +} + +const REQUEST_TIMEOUT_MS = 10_000 +const MAX_ATTEMPTS = 3 +const PAGE_SIZE = 100 + +/** Fetch with a timeout and retries for transient failures (429/5xx/network errors). */ +async function fetchWithRetry(url: string, token: string): Promise { + let lastError: unknown + + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + try { + const res = await fetch(url, { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + next: { revalidate: 300 }, + }) + + if (res.ok) return res + + const isRetryable = res.status === 429 || res.status >= 500 + if (!isRetryable || attempt === MAX_ATTEMPTS) return res + + const retryAfter = Number(res.headers.get("Retry-After")) + const delayMs = Number.isFinite(retryAfter) && retryAfter > 0 + ? retryAfter * 1000 + : 250 * 2 ** (attempt - 1) + await new Promise((resolve) => setTimeout(resolve, delayMs)) + } catch (err) { + lastError = err + if (attempt === MAX_ATTEMPTS) throw err + await new Promise((resolve) => setTimeout(resolve, 250 * 2 ** (attempt - 1))) + } + } + + throw lastError +} // ─── JSON:API wire types ─────────────────────────────────────────────────── @@ -80,28 +126,145 @@ function nameToSlug(name: string): string { .replace(/^-|-$/g, "") } +export interface CategoryInfo { + id: string + name: string + slug: string + description: string | null + parentId: string | null +} + +/** + * Fetch every category from the admin Categories endpoint (authoritative — + * independent of whatever a given products page happens to `include`). + * Paymenter doesn't expose a `slug` attribute on categories, only `name` + * and `parent_id`, so slugs are still derived by slugifying the name. + */ +async function fetchAllCategories(): Promise { + const { host, token } = getConfig() + const all: CategoryInfo[] = [] + let page = 1 + + while (true) { + const url = new URL(`${host}/api/v1/admin/categories`) + url.searchParams.set("per_page", String(PAGE_SIZE)) + url.searchParams.set("page", String(page)) + + const res = await fetchWithRetry(url.toString(), token) + if (!res.ok) { + throw new Error(`Billing API error ${res.status}: ${res.statusText}`) + } + + const json: JsonApiResponse = await res.json() + for (const cat of json.data) { + const name = (cat.attributes.name as string) ?? "" + all.push({ + id: cat.id, + name, + slug: nameToSlug(name), + description: (cat.attributes.description as string | null) ?? null, + parentId: cat.attributes.parent_id != null ? String(cat.attributes.parent_id) : null, + }) + } + + if (!json.links?.next) break + page++ + } + + return all +} + +const getCachedCategories = unstable_cache(fetchAllCategories, ["billing-categories"], { + revalidate: 600, +}) + +export interface CategoryHub { + id: string + name: string + slug: string + description: string | null + children: CategoryInfo[] +} + +/** + * Group the flat category list into hubs (top-level categories with no + * parent) and their direct children (the leaf categories products actually + * belong to). Powers live discovery of "which games/VPS lines/dedicated + * tiers exist" instead of hardcoding them per page. + */ +async function fetchCategoryTree(): Promise { + const categories = await getCachedCategories() + const byParent = new Map() + + for (const cat of categories) { + if (!cat.parentId) continue + if (!byParent.has(cat.parentId)) byParent.set(cat.parentId, []) + byParent.get(cat.parentId)!.push(cat) + } + + return categories + .filter((cat) => !cat.parentId) + .map((hub) => ({ + id: hub.id, + name: hub.name, + slug: hub.slug, + description: hub.description, + children: byParent.get(hub.id) ?? [], + })) +} + +export const getCachedCategoryTree = unstable_cache(fetchCategoryTree, ["billing-category-tree"], { + revalidate: 600, +}) + /** - * Derive the category slug from the included map. - * Paymenter's API exposes `name` on categories but not a `slug` attribute. - * Uses `full_slug` if the version of Paymenter provides it, otherwise falls - * back to slugifying the category name. + * Find a hub by its slugified name — accepts one or more acceptable aliases + * (e.g. "vps-hosting" or "vps") since the exact parent category name is + * whatever's configured in Paymenter. */ -function resolveCategorySlug(catId: string, map: Map): string { - const cat = map.get(`categories:${catId}`) - if (!cat) return "" - if (typeof cat.attributes.full_slug === "string") return cat.attributes.full_slug - return nameToSlug((cat.attributes.name as string) ?? "") +export async function getCategoryHub(hubSlugOrAliases: string | string[]): Promise { + const aliases = Array.isArray(hubSlugOrAliases) ? hubSlugOrAliases : [hubSlugOrAliases] + const tree = await getCachedCategoryTree() + return tree.find((hub) => aliases.includes(hub.slug)) ?? null +} + +/** + * Build category id → slug from the authoritative category list, warning on + * any two categories whose names slugify to the same value (since site pages + * key off the flat leaf slug, a collision would silently merge two categories' + * products together). + */ +function buildCategorySlugMap(categories: CategoryInfo[]): Map { + const slugMap = new Map() + const ownerOfSlug = new Map() + + for (const cat of categories) { + const slug = cat.slug + slugMap.set(cat.id, slug) + + const existingOwner = ownerOfSlug.get(slug) + if (existingOwner && existingOwner !== cat.id) { + console.warn( + `[bytepay] Category slug collision: "${cat.name}" (id ${cat.id}) and category id ${existingOwner} both slugify to "${slug}" — products in one category may shadow the other on the site.`, + ) + } else { + ownerOfSlug.set(slug, cat.id) + } + } + + return slugMap } function normalisePage( data: JsonApiResource[], map: Map, + categorySlugMap: Map, ): BillingProduct[] { return data.map((product): BillingProduct => { const attr = product.attributes const catRef = product.relationships?.category?.data as JsonApiRelRef | null | undefined - const categorySlug = catRef ? resolveCategorySlug(catRef.id, map) : "" + const categorySlug = catRef ? (categorySlugMap.get(catRef.id) ?? "") : "" const planRefs = (product.relationships?.plans?.data ?? []) as JsonApiRelRef[] const plans = planRefs @@ -153,16 +316,17 @@ function normalisePage( async function fetchPage( page: number, + categorySlugMap: Map, ): Promise<{ products: BillingProduct[]; hasNext: boolean }> { - const url = new URL(`${BYTEPAY_HOST}/api/v1/admin/products`) + const { host, token } = getConfig() + + const url = new URL(`${host}/api/v1/admin/products`) url.searchParams.set("include", "category,plans,plans.prices") url.searchParams.set("filter[hidden]", "0") + url.searchParams.set("per_page", String(PAGE_SIZE)) url.searchParams.set("page", String(page)) - const res = await fetch(url.toString(), { - headers: { Authorization: `Bearer ${BYTEPAY_TOKEN}` }, - next: { revalidate: 300 }, - }) + const res = await fetchWithRetry(url.toString(), token) if (!res.ok) { throw new Error(`Billing API error ${res.status}: ${res.statusText}`) @@ -172,7 +336,7 @@ async function fetchPage( const map = buildIncludedMap(json.included) return { - products: normalisePage(json.data, map), + products: normalisePage(json.data, map, categorySlugMap), hasNext: Boolean(json.links?.next), } } @@ -181,11 +345,14 @@ async function fetchPage( /** Fetch every non-hidden product across all pagination pages. */ export async function fetchAllBillingProducts(): Promise { + const categories = await getCachedCategories() + const categorySlugMap = buildCategorySlugMap(categories) + const all: BillingProduct[] = [] let page = 1 while (true) { - const { products, hasNext } = await fetchPage(page) + const { products, hasNext } = await fetchPage(page, categorySlugMap) all.push(...products) if (!hasNext) break page++ diff --git a/packages/core/lib/spec-parser.ts b/packages/core/lib/spec-parser.ts index 75383d6..8fd7928 100644 --- a/packages/core/lib/spec-parser.ts +++ b/packages/core/lib/spec-parser.ts @@ -14,9 +14,13 @@ export interface ParsedSpecs { cpu?: number ramGB?: number + /** RAM generation if the description names one, e.g. "DDR3"/"DDR4"/"DDR5" — omitted when unspecified. */ + ramType?: string storageGB?: number /** Raw storage label extracted from the description, e.g. "2 × 1 TB NVMe SSD (RAID 1)" */ storageDescription?: string + /** Which storage keyword the description actually used — "generic" when it only said e.g. "40 GB Storage Array" with no drive type. */ + storageType?: "nvme" | "ssd" | "hdd" | "generic" bandwidth?: { amount: number; unit: "MB" | "GB" | "TB" } | null uplink?: { amount: number; unit: "Mbps" | "Gbps" } cpuModel?: string @@ -66,16 +70,25 @@ export function parseDescriptionSpecs(html: string | null): ParsedSpecs { if (new RegExp(`\\b${name}[- ]?[Cc]ore\\b`, 'i').test(text)) { cpu = count; break } } } + if (!cpu) { + // "2 vCPU", "4 vCPUs" — common cloud/VPS-style core count phrasing + const m = text.match(/\b(\d+)\s*vCPUs?\b/i) + if (m) cpu = parseInt(m[1]) + } // ── RAM ──────────────────────────────────────────────────────────────────── // "2 GB DDR4 RAM", "4 GB ECC RAM", "8GB DDR4 RAM", "1 GB RAM" - const ramMatch = text.match(/(\d+)\s*GB\s+(?:\w+\s+)*?RAM\b/i) + // "4 GB High-Speed DDR4 RAM" — hyphenated adjectives allowed between the size and "RAM" + const ramMatch = text.match(/(\d+)\s*GB\s+(?:[\w-]+\s+)*?RAM\b/i) const ramGB = ramMatch ? parseInt(ramMatch[1]) : undefined + const ramTypeMatch = ramMatch ? ramMatch[0].match(/DDR\s?([345])/i) : null + const ramType = ramTypeMatch ? `DDR${ramTypeMatch[1]}` : undefined // ── Storage ──────────────────────────────────────────────────────────────── // "25 GB SSD", "40 GB NVMe SSD", "100 GB SSD Storage", "40GB Disk Storage" + // "80 GB Local NVMe Storage" (no SSD/HDD/Disk keyword) // Also handles TB drives: "2 x 1 TB NVMe SSD", "4 x 16 TB SATA HDD" - const storageMatchGB = text.match(/(\d+)\s*GB\s+(?:NVMe\s+)?(?:SSD|Disk|HDD)(?:\s+Storage)?/i) + const storageMatchGB = text.match(/(\d+)\s*GB\s+(?:Local\s+)?(?:NVMe\s+)?(?:SSD|Disk|HDD|Storage)\b/i) const storageMatchTB = !storageMatchGB ? text.match(/(\d+)\s*TB\s+(?:NVMe\s+|Enterprise\s+|SATA\s+)?(?:SSD|HDD|Disk)/i) : null @@ -85,6 +98,17 @@ export function parseDescriptionSpecs(html: string | null): ParsedSpecs { ? parseInt(storageMatchTB[1]) * 1024 : undefined + const storageMatchText = storageMatchGB?.[0] ?? storageMatchTB?.[0] + const storageType: ParsedSpecs["storageType"] = storageMatchText + ? /nvme/i.test(storageMatchText) + ? "nvme" + : /ssd/i.test(storageMatchText) + ? "ssd" + : /hdd/i.test(storageMatchText) + ? "hdd" + : "generic" + : undefined + // Raw storage label for multi-drive dedicated configs let storageDescription: string | undefined for (const line of lines) { @@ -144,7 +168,17 @@ export function parseDescriptionSpecs(html: string | null): ParsedSpecs { if (m) { description = m[1].trim(); break } } - return { cpu, ramGB, storageGB, storageDescription, bandwidth, uplink, cpuModel, hardware, description } + return { cpu, ramGB, ramType, storageGB, storageDescription, storageType, bandwidth, uplink, cpuModel, hardware, description } +} + +/** Human-friendly storage type label — falls back to "Storage Array" when the description didn't name a drive type. */ +export function formatStorageType(type: ParsedSpecs["storageType"]): string { + switch (type) { + case "nvme": return "NVMe SSD Storage" + case "ssd": return "SSD Storage" + case "hdd": return "HDD Storage" + default: return "Storage Array" + } } /** diff --git a/packages/core/lib/status.ts b/packages/core/lib/status.ts new file mode 100644 index 0000000..bea5ca8 --- /dev/null +++ b/packages/core/lib/status.ts @@ -0,0 +1,123 @@ +/** + * Server-side only — STATUS_TOKEN is never sent to the browser. + * + * Fetches monitor data from the status.nodebyte.host public status API + * and normalises it into the flat, typed shape the website needs. + */ + +export type MonitorStatus = "up" | "down" | "degraded" | "maintenance" | "paused" | "unknown" +export type MonitorType = "http" | "tcp" | "smtp" | "ping" | "group" + +export interface StatusHeartbeat { + checked_at: number + status: "up" | "down" | "maintenance" | "unknown" + latency_ms: number | null +} + +export interface StatusMonitor { + id: number + name: string + type: MonitorType + group_name: string | null + subgroup_name: string | null + status: MonitorStatus + last_checked_at: number | null + last_latency_ms: number | null + heartbeats: StatusHeartbeat[] + uptime_30d_pct: number | null +} + +export interface StatusSnapshot { + generated_at: number + overall_status: MonitorStatus + monitors: StatusMonitor[] +} + +function getConfig(): { host: string; token: string | undefined } { + const host = process.env.STATUS_API_URL + if (!host) { + throw new Error("Status API misconfigured: STATUS_API_URL must be set.") + } + return { host, token: process.env.STATUS_TOKEN } +} + +async function fetchStatusJson(host: string, token: string | undefined): Promise> { + const res = await fetch(`${host}/api/v1/public/status`, { + headers: { + Accept: "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + next: { revalidate: 30 }, + }) + + if (res.status === 503 && token) { + // The status app's edge proxy refuses to forward the Authorization header + // upstream until its own UPTIMER_API_SENSITIVE_ORIGIN is configured. Fall + // back to the unauthenticated public payload rather than failing outright. + const body = await res.clone().json().catch(() => null) + if (body?.error?.code === "API_ORIGIN_UNTRUSTED_FOR_SENSITIVE_HEADERS") { + return fetchStatusJson(host, undefined) + } + } + + if (!res.ok) { + throw new Error(`Status API returned ${res.status}`) + } + + return res.json() +} + +/** Fetch the current status snapshot. Returns null on any failure so callers can fall back to static data. */ +export async function fetchStatusSnapshot(): Promise { + try { + const { host, token } = getConfig() + const data = await fetchStatusJson(host, token) + const rawMonitors = Array.isArray(data.monitors) ? (data.monitors as Record[]) : [] + + const monitors: StatusMonitor[] = rawMonitors.map((m) => ({ + id: m.id as number, + name: m.name as string, + type: m.type as MonitorType, + group_name: (m.group_name as string | null) ?? null, + subgroup_name: (m.subgroup_name as string | null) ?? null, + status: m.status as MonitorStatus, + last_checked_at: (m.last_checked_at as number | null) ?? null, + last_latency_ms: (m.last_latency_ms as number | null) ?? null, + heartbeats: Array.isArray(m.heartbeats) ? (m.heartbeats as StatusHeartbeat[]) : [], + uptime_30d_pct: + m.uptime_30d && typeof (m.uptime_30d as Record).uptime_pct === "number" + ? ((m.uptime_30d as Record).uptime_pct as number) + : null, + })) + + return { + generated_at: data.generated_at as number, + overall_status: data.overall_status as MonitorStatus, + monitors, + } + } catch (error) { + console.error("Failed to fetch status snapshot:", error) + return null + } +} + +/** Find a monitor by exact name (case-insensitive). */ +export function findMonitor(snapshot: StatusSnapshot | null, name: string): StatusMonitor | null { + if (!snapshot) return null + const target = name.trim().toLowerCase() + return snapshot.monitors.find((m) => m.name.trim().toLowerCase() === target) ?? null +} + +/** Compute fast/avg/slow latency (ms) from a monitor's recent heartbeats. */ +export function computeLatencyStats(monitor: StatusMonitor): { fast: number; avg: number; slow: number } | null { + const samples = monitor.heartbeats + .map((h) => h.latency_ms) + .filter((ms): ms is number => typeof ms === "number") + + if (samples.length === 0) return null + + const fast = Math.min(...samples) + const slow = Math.max(...samples) + const avg = Math.round(samples.reduce((sum, ms) => sum + ms, 0) / samples.length) + return { fast, avg, slow } +} diff --git a/packages/core/products/billing-service.ts b/packages/core/products/billing-service.ts index 4bfb52b..a6d0064 100644 --- a/packages/core/products/billing-service.ts +++ b/packages/core/products/billing-service.ts @@ -10,8 +10,9 @@ import { getStockStatus, getBillingUrl, } from "@/packages/core/lib/bytepay" -import { parseDescriptionSpecs, parseProductName } from "@/packages/core/lib/spec-parser" +import { parseDescriptionSpecs, parseProductName, formatStorageType } from "@/packages/core/lib/spec-parser" import { POPULAR_SLUGS, DEFAULT_DDOS } from "@/packages/core/constants/product-overrides" +import type { BillingProduct } from "@/packages/core/lib/bytepay" const getCachedProducts = unstable_cache( fetchAllBillingProducts, @@ -19,6 +20,22 @@ const getCachedProducts = unstable_cache( { revalidate: 300 }, ) +/** + * A live, non-hidden product is visible in Paymenter but got filtered out + * because its description didn't parse into the specs the site needs. + * Logged so a wording change (e.g. a new storage phrasing) surfaces + * immediately instead of being discovered by a customer. + */ +function warnDroppedProduct( + product: BillingProduct, + categorySlug: string, + missingFields: string[], +): void { + console.warn( + `[billing-service] Product "${product.name}" (id ${product.id}, slug ${product.slug}) in category "${categorySlug}" is live but missing parsed spec(s): ${missingFields.join(", ")}. Check its description formatting.`, + ) +} + /** * Returns live-priced game plans for the given billing category slug. * RAM and storage are parsed from the billing panel description automatically. @@ -29,13 +46,23 @@ export async function getGamePlans(categorySlug: string): Promise { const parsed = parseDescriptionSpecs(product.description) - if (!parsed.ramGB || !parsed.storageGB) return [] + if (!parsed.ramGB || !parsed.storageGB) { + const missing = [!parsed.ramGB && "ramGB", !parsed.storageGB && "storageGB"].filter( + (v): v is string => Boolean(v), + ) + warnDroppedProduct(product, categorySlug, missing) + return [] + } return [ { id: product.slug, + name: product.name, + description: parsed.description, ramGB: parsed.ramGB, + ramType: parsed.ramType, storageGB: parsed.storageGB, + storageLabel: formatStorageType(parsed.storageType), bandwidth: parsed.bandwidth ?? null, popular: POPULAR_SLUGS.has(`${categorySlug}/${product.slug}`), priceGBP: getGbpPrice(product), @@ -57,7 +84,10 @@ export async function getDedicatedPlans(categorySlug: string): Promise { const parsed = parseDescriptionSpecs(product.description) - if (!parsed.ramGB) return [] + if (!parsed.ramGB) { + warnDroppedProduct(product, categorySlug, ["ramGB"]) + return [] + } return [ { @@ -93,7 +123,15 @@ export async function getVpsPlans(categorySlug: string): Promise const parsed = parseDescriptionSpecs(product.description) const { sku, lineup, series } = parseProductName(product.name) - if (!parsed.cpu || !parsed.ramGB || !parsed.storageGB) return [] + if (!parsed.cpu || !parsed.ramGB || !parsed.storageGB) { + const missing = [ + !parsed.cpu && "cpu", + !parsed.ramGB && "ramGB", + !parsed.storageGB && "storageGB", + ].filter((v): v is string => Boolean(v)) + warnDroppedProduct(product, categorySlug, missing) + return [] + } return [ { diff --git a/packages/core/products/catalog-config.ts b/packages/core/products/catalog-config.ts new file mode 100644 index 0000000..a9e34a1 --- /dev/null +++ b/packages/core/products/catalog-config.ts @@ -0,0 +1,247 @@ +/** + * Resolves the display config + plan-card content for a game category page. + * + * Every game category discovered from Paymenter gets a working page for + * free (name/description from the category, specs parsed from the product + * description, generic marketing copy). The handful of games we've actually + * curated (banner art, hand-picked feature lists) override that generic + * output — see GAME_OVERRIDES below. Plan-level content (name/description) + * always prefers live billing data first; curated per-plan copy is only a + * fallback for the originally-curated plan ids. + */ + +import type { GamePlanSpec } from "@/packages/core/types/servers/game" +import type { CategoryInfo } from "@/packages/core/lib/bytepay" +import { LINKS } from "@/packages/core/constants/links" +import { + MINECRAFT_CONFIG, + MINECRAFT_PLAN_DISPLAY, + MINECRAFT_PLAN_STATIC_FEATURES, + MINECRAFT_FEATURES, + MINECRAFT_FAQS, + MINECRAFT_HERO_FEATURES, +} from "@/packages/core/constants/game/minecraft" +import { + RUST_CONFIG, + RUST_PLAN_DISPLAY, + RUST_PLAN_STATIC_FEATURES, + RUST_FEATURES, + RUST_FAQS, + RUST_HERO_FEATURES, +} from "@/packages/core/constants/game/rust" +import { + HYTALE_CONFIG, + HYTALE_PLAN_DISPLAY, + HYTALE_PLAN_STATIC_FEATURES, + HYTALE_FEATURES, + HYTALE_FAQS, + HYTALE_HERO_FEATURES, +} from "@/packages/core/constants/game/hytale" +import { + TERRARIA_CONFIG, + TERRARIA_FEATURES, + TERRARIA_FAQS, + TERRARIA_HERO_FEATURES, +} from "@/packages/core/constants/game/terraria" +import { + GMOD_CONFIG, + GMOD_FEATURES, + GMOD_FAQS, + GMOD_HERO_FEATURES, +} from "@/packages/core/constants/game/gmod" +import { + PALWORLD_CONFIG, + PALWORLD_FEATURES, + PALWORLD_FAQS, + PALWORLD_HERO_FEATURES, +} from "@/packages/core/constants/game/palworld" + +export type GameIconName = "Blocks" | "Gamepad2" | "Sparkles" | "Leaf" | "Pickaxe" | "Wrench" +export type FeatureIconName = + | "Settings" | "Cpu" | "Shield" | "Zap" | "HardDrive" | "Users" | "Server" | "Map" | "Globe" | "Sparkles" | "Gamepad2" + +export interface GamePageFeature { + title: string + description: string + icon: FeatureIconName + highlights: string[] +} + +export interface GameFaq { + question: string + answer: string +} + +export interface GamePlanDisplay { + name: string + description: string + features: string[] +} + +export interface GameDisplayConfig { + name: string + description: string + banner: string + iconName: GameIconName + tag: string + tagColor: string + headerGradient: string + headerIconBg: string + billingUrl: string + heroFeatures: string[] + pageFeatures: GamePageFeature[] + faqs: GameFaq[] + resolvePlanDisplay: (plan: GamePlanSpec) => GamePlanDisplay +} + +// ─── Generic fallback (any category with no curated override) ────────────── + +const GENERIC_ICON_ROTATION: GameIconName[] = ["Gamepad2", "Sparkles", "Blocks", "Pickaxe", "Wrench", "Leaf"] +const GENERIC_PALETTES = [ + { tagColor: "bg-primary/10 border border-primary/20 text-primary", headerGradient: "from-primary/20 via-primary/10 to-accent/5", headerIconBg: "bg-primary/10 text-primary" }, + { tagColor: "bg-blue-500/15 text-blue-400 border border-blue-500/20", headerGradient: "from-blue-500/20 via-blue-500/10 to-primary/5", headerIconBg: "bg-blue-500/10 text-blue-400" }, + { tagColor: "bg-violet-500/15 text-violet-400 border border-violet-500/20", headerGradient: "from-violet-500/20 via-violet-500/10 to-primary/5", headerIconBg: "bg-violet-500/10 text-violet-400" }, + { tagColor: "bg-rose-500/15 text-rose-400 border border-rose-500/20", headerGradient: "from-rose-500/20 via-rose-500/10 to-primary/5", headerIconBg: "bg-rose-500/10 text-rose-400" }, +] + +/** Deterministic small hash so the same category always gets the same generic palette/icon. */ +function hashSlug(slug: string): number { + let hash = 0 + for (let i = 0; i < slug.length; i++) hash = (hash * 31 + slug.charCodeAt(i)) >>> 0 + return hash +} + +function formatStorage(gb: number): string { + return gb >= 1024 ? `${gb / 1024} TB` : `${gb} GB` +} + +/** Plan display built straight from live billing data — used as the generic fallback, and for any plan added to a curated category that has no curated entry. */ +function buildGenericPlanDisplay(plan: GamePlanSpec): GamePlanDisplay { + const storageLabel = plan.storageLabel ?? "Storage Array" + const ramLabel = `${plan.ramGB} GB ${plan.ramType ? plan.ramType + " " : ""}RAM` + return { + name: plan.name || plan.id, + description: plan.description || `${formatStorage(plan.storageGB)} ${storageLabel}, ${ramLabel}.`, + features: [ + ramLabel, + `${formatStorage(plan.storageGB)} ${storageLabel}`, + "Enterprise DDoS Protection", + "BytePanel Control Panel", + ], + } +} + +function buildGenericDisplayConfig(category: CategoryInfo): GameDisplayConfig { + const hash = hashSlug(category.slug) + const icon = GENERIC_ICON_ROTATION[hash % GENERIC_ICON_ROTATION.length] + const palette = GENERIC_PALETTES[hash % GENERIC_PALETTES.length] + + return { + name: category.name, + description: + category.description || + `High-performance ${category.name} server hosting with instant setup, enterprise DDoS protection, and 24/7 support.`, + banner: "/games/generic.png", + iconName: icon, + tag: "Game Server", + ...palette, + billingUrl: `${LINKS.billing.root}/products/${category.slug}`, + heroFeatures: ["Instant Setup", "DDoS Protection", "24/7 Support", "Upgrade Anytime"], + pageFeatures: [ + { title: "Instant Setup", description: `Your ${category.name} server deploys automatically the moment your order completes — no waiting on manual provisioning.`, icon: "Zap", highlights: ["Automated deployment", "No setup fees", "Ready in minutes", "Zero manual steps"] }, + { title: "Enterprise Hardware", description: "Servers run on enterprise-grade CPUs with NVMe SSD storage for consistently fast, low-latency performance.", icon: "Cpu", highlights: ["NVMe SSD storage", "High clock speed CPUs", "Low latency networking", "DDR4 ECC memory"] }, + { title: "DDoS Protection", description: "Enterprise-grade DDoS mitigation is included on every plan, keeping your server online during attacks.", icon: "Shield", highlights: ["Always-on protection", "Layer 3/4/7 filtering", "Zero downtime", "Global POPs"] }, + { title: "24/7 Support", description: "Our support team is available around the clock to help with setup, configuration, or troubleshooting.", icon: "Server", highlights: ["24/7 availability", "Knowledgeable staff", "Fast response times", "Discord & ticket support"] }, + ], + faqs: [ + { question: "How quickly will my server be online?", answer: "Your server is provisioned automatically as soon as your order completes — usually within a couple of minutes." }, + { question: "Can I upgrade my plan later?", answer: "Yes, you can upgrade or downgrade your plan at any time from the billing panel." }, + { question: "Is DDoS protection included?", answer: "Yes, enterprise-grade DDoS protection is included on every plan at no extra cost." }, + ], + resolvePlanDisplay: buildGenericPlanDisplay, + } +} + +// ─── Curated overrides ─────────────────────────────────────────────────────── + +/** Build a resolvePlanDisplay for a curated game — curated copy for known plan ids, live data for anything else. */ +function curatedPlanDisplay( + display: Record, + staticFeatures: readonly string[], +): (plan: GamePlanSpec) => GamePlanDisplay { + return (plan) => { + const entry = display[plan.id] + if (!entry) return buildGenericPlanDisplay(plan) + return { + name: entry.name, + description: entry.description, + features: [ + ...staticFeatures, + `${plan.ramGB}GB ${plan.ramType ? plan.ramType + " " : ""}RAM`, + `${plan.storageGB}GB ${plan.storageLabel ?? "Storage Array"}`, + ], + } + } +} + +/** + * Curated display config, keyed by the Paymenter category slug. A category + * without an entry here falls back to buildGenericDisplayConfig() — nothing + * needs to be added here for a new game/category to work. + */ +const GAME_OVERRIDES: Record GameDisplayConfig> = { + minecraft: () => ({ + ...MINECRAFT_CONFIG, + billingUrl: LINKS.billing.minecraftHosting, + heroFeatures: [...MINECRAFT_HERO_FEATURES], + pageFeatures: MINECRAFT_FEATURES.map((f) => ({ ...f, highlights: [...f.highlights] })), + faqs: [...MINECRAFT_FAQS], + resolvePlanDisplay: curatedPlanDisplay(MINECRAFT_PLAN_DISPLAY, MINECRAFT_PLAN_STATIC_FEATURES), + }), + rust: () => ({ + ...RUST_CONFIG, + billingUrl: LINKS.billing.rustHosting, + heroFeatures: [...RUST_HERO_FEATURES], + pageFeatures: RUST_FEATURES.map((f) => ({ ...f, highlights: [...f.highlights] })), + faqs: [...RUST_FAQS], + resolvePlanDisplay: curatedPlanDisplay(RUST_PLAN_DISPLAY, RUST_PLAN_STATIC_FEATURES), + }), + hytale: () => ({ + ...HYTALE_CONFIG, + billingUrl: LINKS.billing.hytaleHosting, + heroFeatures: [...HYTALE_HERO_FEATURES], + pageFeatures: HYTALE_FEATURES.map((f) => ({ ...f, highlights: [...f.highlights] })), + faqs: [...HYTALE_FAQS], + resolvePlanDisplay: curatedPlanDisplay(HYTALE_PLAN_DISPLAY, HYTALE_PLAN_STATIC_FEATURES), + }), + terraria: () => ({ + ...TERRARIA_CONFIG, + billingUrl: LINKS.billing.terrariaHosting, + heroFeatures: [...TERRARIA_HERO_FEATURES], + pageFeatures: TERRARIA_FEATURES.map((f) => ({ ...f, highlights: [...f.highlights] })), + faqs: [...TERRARIA_FAQS], + resolvePlanDisplay: buildGenericPlanDisplay, + }), + gmod: () => ({ + ...GMOD_CONFIG, + billingUrl: LINKS.billing.gmodHosting, + heroFeatures: [...GMOD_HERO_FEATURES], + pageFeatures: GMOD_FEATURES.map((f) => ({ ...f, highlights: [...f.highlights] })), + faqs: [...GMOD_FAQS], + resolvePlanDisplay: buildGenericPlanDisplay, + }), + palworld: () => ({ + ...PALWORLD_CONFIG, + billingUrl: LINKS.billing.palworldHosting, + heroFeatures: [...PALWORLD_HERO_FEATURES], + pageFeatures: PALWORLD_FEATURES.map((f) => ({ ...f, highlights: [...f.highlights] })), + faqs: [...PALWORLD_FAQS], + resolvePlanDisplay: buildGenericPlanDisplay, + }), +} + +/** Resolve the display config for a game category — curated override if one exists, else auto-generated. */ +export function resolveGameDisplayConfig(category: CategoryInfo): GameDisplayConfig { + const override = GAME_OVERRIDES[category.slug] + return override ? override() : buildGenericDisplayConfig(category) +} diff --git a/packages/core/products/index.ts b/packages/core/products/index.ts deleted file mode 100644 index 94a3ab3..0000000 --- a/packages/core/products/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export type { ProductEntry, ProductType, StockStatus } from "./types" -export { - getAllProducts, - getProductsByType, - getProductsByCategory, - isCategoryOutOfStock, - getCategoryStartingPrice, -} from "./service" diff --git a/packages/core/products/override-store.ts b/packages/core/products/override-store.ts deleted file mode 100644 index f80fb4d..0000000 --- a/packages/core/products/override-store.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { StockStatus } from "./types" - -export interface ProductOverride { - stock: StockStatus - enabled: boolean -} - -/** - * Module-level store — persists across requests within the same server process. - * Resets on server restart. Replace with DB/Redis when a real backend is available. - */ -const store = new Map() - -export function getOverride(id: string): ProductOverride | undefined { - return store.get(id) -} - -export function getAllOverrides(): Record { - return Object.fromEntries(store.entries()) -} - -export function setOverride(id: string, data: ProductOverride): void { - store.set(id, data) -} diff --git a/packages/core/products/server.ts b/packages/core/products/server.ts deleted file mode 100644 index 54f6764..0000000 --- a/packages/core/products/server.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { GamePlanSpec } from "@/packages/core/types/servers/game" -import type { VpsPlanSpec } from "@/packages/core/types/servers/vps" -import { getOverride } from "./override-store" - -/** - * Returns game plans with admin overrides applied. - * Plans with enabled=false are filtered out entirely (category shows OOS when all removed). - * Plans with stock overridden show their OOS badge on the pricing card. - */ -function applyOverrides( - category: string, - plans: T[], -): T[] { - const result: T[] = [] - for (const plan of plans) { - const ov = getOverride(`${category}-${plan.id}`) - if (ov && !ov.enabled) continue - result.push(ov ? { ...plan, stock: ov.stock } as T : plan) - } - return result -} - -export function applyGamePlanOverrides( - category: string, - plans: GamePlanSpec[], -): GamePlanSpec[] { - return applyOverrides(category, plans) -} - -export function applyVpsPlanOverrides( - category: string, - plans: VpsPlanSpec[], -): VpsPlanSpec[] { - return applyOverrides(category, plans) -} diff --git a/packages/core/products/service.ts b/packages/core/products/service.ts deleted file mode 100644 index eeab69c..0000000 --- a/packages/core/products/service.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { MINECRAFT_PLANS } from "@/packages/core/constants/game/minecraft" -import { RUST_PLANS } from "@/packages/core/constants/game/rust" -import { HYTALE_PLANS } from "@/packages/core/constants/game/hytale" -import { TERRARIA_PLANS } from "@/packages/core/constants/game/terraria" -import { GMOD_PLANS } from "@/packages/core/constants/game/gmod" -import { PALWORLD_PLANS } from "@/packages/core/constants/game/palworld" -import { AMD_PLANS } from "@/packages/core/constants/vps/amd" -import { INTEL_PLANS } from "@/packages/core/constants/vps/intel" -import type { GamePlanSpec } from "@/packages/core/types/servers/game" -import type { VpsPlanSpec } from "@/packages/core/types/servers/vps" -import type { ProductEntry } from "./types" - -// ─── Adapters ──────────────────────────────────────────────────────────────── - -function fromGamePlan(plan: GamePlanSpec, category: string): ProductEntry { - return { - id: `${category}-${plan.id}`, - planId: plan.id, - category, - type: "game", - description: plan.description, - priceGBP: plan.priceGBP, - stock: plan.stock ?? "in_stock", - popular: plan.popular, - billingUrl: plan.url, - location: plan.location, - cpuModel: plan.cpuModel, - ramGB: plan.ramGB, - storageGB: plan.storageGB, - bandwidth: plan.bandwidth, - uplink: plan.uplink, - ddos: plan.ddos, - } -} - -function fromVpsPlan(plan: VpsPlanSpec, category: string): ProductEntry { - return { - id: `${category}-${plan.id}`, - planId: plan.id, - category, - type: "vps", - description: plan.description, - priceGBP: plan.priceGBP, - stock: plan.stock ?? "in_stock", - popular: plan.popular, - billingUrl: plan.url, - location: plan.location, - cpu: plan.cpu, - cpuModel: plan.cpuModel, - ramGB: plan.ramGB, - storageGB: plan.storageGB, - bandwidth: plan.bandwidth, - uplink: plan.uplink, - ddos: plan.ddos, - } -} - -// ─── Public API ─────────────────────────────────────────────────────────────── - -/** - * All products derived from the hardcoded plan constants. - * TODO: replace with an API fetch when the backend product endpoint is ready. - */ -export function getAllProducts(): ProductEntry[] { - return [ - ...MINECRAFT_PLANS.map((p) => fromGamePlan(p, "minecraft")), - ...RUST_PLANS.map((p) => fromGamePlan(p, "rust")), - ...HYTALE_PLANS.map((p) => fromGamePlan(p, "hytale")), - ...TERRARIA_PLANS.map((p) => fromGamePlan(p, "fivem")), - ...GMOD_PLANS.map((p) => fromGamePlan(p, "redm")), - ...PALWORLD_PLANS.map((p) => fromGamePlan(p, "palworld")), - ...AMD_PLANS.map((p) => fromVpsPlan(p, "amd")), - ...INTEL_PLANS.map((p) => fromVpsPlan(p, "intel")), - ] -} - -/** Products filtered by product type ("game" | "vps"). */ -export function getProductsByType(type: "game" | "vps"): ProductEntry[] { - return getAllProducts().filter((p) => p.type === type) -} - -/** Products for a specific category slug e.g. "minecraft", "amd". */ -export function getProductsByCategory(category: string): ProductEntry[] { - return getAllProducts().filter((p) => p.category === category) -} - -/** - * Returns true if the whole category should be shown as out-of-stock on its - * landing page — i.e. the plan list is empty OR every plan is individually OOS. - */ -export function isCategoryOutOfStock(category: string): boolean { - const plans = getProductsByCategory(category) - return plans.length === 0 || plans.every((p) => p.stock === "out_of_stock") -} - -/** - * Lowest in-stock price for a category. - * Returns null when every plan is OOS or the category has no plans. - */ -export function getCategoryStartingPrice(category: string): number | null { - const available = getProductsByCategory(category).filter( - (p) => p.stock === "in_stock", - ) - if (available.length === 0) return null - return Math.min(...available.map((p) => p.priceGBP)) -} diff --git a/packages/core/products/types.ts b/packages/core/products/types.ts deleted file mode 100644 index 910c296..0000000 --- a/packages/core/products/types.ts +++ /dev/null @@ -1,40 +0,0 @@ -export type StockStatus = "in_stock" | "out_of_stock" | "coming_soon" - -export type ProductType = "game" | "vps" - -/** - * Unified read-only view of any product plan. - * Consumed by the admin panel and derived from the hardcoded plan constants. - * Replace `getAllProducts()` with an API call in `service.ts` when the backend is ready. - */ -export interface ProductEntry { - /** Unique slug — format: "{category}-{planId}" e.g. "minecraft-ember", "amd-2GB-R71700X" */ - id: string - /** Technical plan id from the spec constant e.g. "ember", "2GB-R71700X" */ - planId: string - /** Category slug: "minecraft" | "rust" | "hytale" | "amd" | "intel" */ - category: string - /** Product type */ - type: ProductType - /** Optional marketing description */ - description?: string - /** Monthly price in GBP */ - priceGBP: number - /** Availability status */ - stock: StockStatus - /** Highlighted as the recommended / most-popular plan */ - popular?: boolean - /** Direct order URL on the billing portal */ - billingUrl?: string - /** Data-centre location string */ - location?: string - - // ── Specs (optional — not all product types expose all fields) ──────────── - cpu?: number - cpuModel?: string - ramGB?: number - storageGB?: number - bandwidth?: { amount: number; unit: "MB" | "GB" | "TB" } | null - uplink?: { amount: number; unit: "Mbps" | "Gbps" } - ddos?: { layers: number[]; autoOn: boolean } -} diff --git a/packages/core/types/servers/game.ts b/packages/core/types/servers/game.ts index dc99167..961bbca 100644 --- a/packages/core/types/servers/game.ts +++ b/packages/core/types/servers/game.ts @@ -16,11 +16,17 @@ */ export interface GamePlanSpec { id: string + /** Raw product name from the billing panel — used as the display name for auto-generated (non-curated) game pages */ + name?: string description?: string cpuModel?: string priceGBP: number ramGB: number + /** RAM generation if the description names one, e.g. "DDR4" — omitted when unspecified */ + ramType?: string storageGB: number + /** Human-friendly storage type, e.g. "NVMe SSD Storage" or "Storage Array" when no drive type was named */ + storageLabel?: string bandwidth: { amount: number; unit: "MB" | "GB" | "TB" } | null uplink?: { amount: number; unit: "Mbps" | "Gbps" } ddos?: { layers: number[]; autoOn: boolean } diff --git a/packages/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/ui/components/Layouts/About/about-page.tsx b/packages/ui/components/Layouts/About/about-page.tsx index aad457b..c914b9a 100644 --- a/packages/ui/components/Layouts/About/about-page.tsx +++ b/packages/ui/components/Layouts/About/about-page.tsx @@ -49,7 +49,7 @@ export function AboutPage() { ] const displayStats = [ - { value: "3+", label: t("aboutPage.stats.locations"), icon: Globe }, + { value: "10+", label: t("aboutPage.stats.locations"), icon: Globe }, { value: "Always on", label: t("aboutPage.stats.ddos"), icon: Shield }, { value: "~1 Gbps", label: t("aboutPage.stats.network"), icon: Zap }, { value: "99.9%", label: t("aboutPage.stats.uptime"), icon: Server }, diff --git a/packages/ui/components/Layouts/Dedicated/dedicated-hub.tsx b/packages/ui/components/Layouts/Dedicated/dedicated-hub.tsx index d910624..6a89abd 100644 --- a/packages/ui/components/Layouts/Dedicated/dedicated-hub.tsx +++ b/packages/ui/components/Layouts/Dedicated/dedicated-hub.tsx @@ -13,6 +13,7 @@ import { ArrowRight, Star, Lock, + PackageX, } from "lucide-react" import { Button } from "@/packages/ui/components/ui/button" import { Input } from "@/packages/ui/components/ui/input" @@ -300,7 +301,13 @@ export function DedicatedHub({ plans }: DedicatedHubProps) { {/* ── Plan grid ────────────────────────────────────────────────────── */}
    - {filtered.length === 0 ? ( + {plans.length === 0 ? ( +
    + +

    No dedicated servers in stock right now

    +

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

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

    No servers match your filters

    diff --git a/packages/ui/components/Layouts/Games/game-features.tsx b/packages/ui/components/Layouts/Games/game-features.tsx index 9403f36..7646391 100644 --- a/packages/ui/components/Layouts/Games/game-features.tsx +++ b/packages/ui/components/Layouts/Games/game-features.tsx @@ -1,18 +1,19 @@ "use client" import { Card } from "@/packages/ui/components/ui/card" -import { - CheckCircle2, - Sparkles, - Settings, - Cpu, - Shield, - Zap, - HardDrive, - Users, - Server, - Map, - Globe +import { + CheckCircle2, + Sparkles, + Settings, + Cpu, + Shield, + Zap, + HardDrive, + Users, + Server, + Map, + Globe, + Gamepad2, } from "lucide-react" import { cn } from "@/lib/utils" import { useTranslations } from "next-intl" @@ -28,6 +29,7 @@ const iconMap = { Map: Map, Globe: Globe, Sparkles: Sparkles, + Gamepad2: Gamepad2, } interface Feature { diff --git a/packages/ui/components/Layouts/Home/about.tsx b/packages/ui/components/Layouts/Home/about.tsx index 7945bfb..f92aa39 100644 --- a/packages/ui/components/Layouts/Home/about.tsx +++ b/packages/ui/components/Layouts/Home/about.tsx @@ -11,7 +11,7 @@ export function About() { const t = useTranslations() const stats = [ - { value: "3+", label: t("about.stats.locations"), icon: Globe }, + { value: "10+", label: t("about.stats.locations"), icon: Globe }, { value: "Always on", label: t("about.stats.ddos"), icon: Shield }, { value: "~1 Gbps", label: t("about.stats.network"), icon: Zap }, { value: "99.6%", label: t("about.stats.uptime"), icon: Server }, diff --git a/packages/ui/components/Layouts/Home/hero-graphic.tsx b/packages/ui/components/Layouts/Home/hero-graphic.tsx index 23bfa67..c5aa71b 100644 --- a/packages/ui/components/Layouts/Home/hero-graphic.tsx +++ b/packages/ui/components/Layouts/Home/hero-graphic.tsx @@ -278,7 +278,7 @@ export default function HeroGraphic() {
    - Global Network · 3+ Data Center Partners + Global Network · 10+ Data Center Partners
    diff --git a/packages/ui/components/Layouts/Home/services.tsx b/packages/ui/components/Layouts/Home/services.tsx index b147267..0705857 100644 --- a/packages/ui/components/Layouts/Home/services.tsx +++ b/packages/ui/components/Layouts/Home/services.tsx @@ -3,14 +3,44 @@ import { Card } from "@/packages/ui/components/ui/card" import { Layers, ArrowRight, Check } from "lucide-react" import Link from "next/link" import { cn } from "@/lib/utils" -import { useTranslations } from "next-intl" +import { getTranslations } from "next-intl/server" import { SERVICE_CATEGORIES } from "@/packages/core/constants/services" import { Price } from "@/packages/ui/components/ui/price" +import { getCategoryHub } from "@/packages/core/lib/bytepay" +import { getGamePlans, getVpsPlans, getDedicatedPlans } from "@/packages/core/products/billing-service" +import { GAME_HUB_SLUGS, VPS_HUB_SLUGS, DEDICATED_HUB_SLUGS } from "@/packages/core/constants/catalog-hubs" -export function Services() { - const t = useTranslations() +/** Live starting price (min across all of a hub's children's plans), falling back to the static config value if a hub has no live pricing yet or the billing panel is unreachable. */ +async function getLiveStartingPrice( + hubSlugs: string[], + getPlans: (categorySlug: string) => Promise<{ priceGBP: number }[]>, +): Promise { + try { + const hub = await getCategoryHub(hubSlugs) + if (!hub) return null + const plansByCategory = await Promise.all(hub.children.map((c) => getPlans(c.slug))) + const prices = plansByCategory.flat().map((p) => p.priceGBP) + return prices.length ? Math.min(...prices) : null + } catch { + return null + } +} + +const HUB_PRICE_RESOLVERS: Record Promise> = { + "game-servers": () => getLiveStartingPrice(GAME_HUB_SLUGS, getGamePlans), + "vps": () => getLiveStartingPrice(VPS_HUB_SLUGS, getVpsPlans), + "dedicated": () => getLiveStartingPrice(DEDICATED_HUB_SLUGS, getDedicatedPlans), +} - const activeServices = SERVICE_CATEGORIES.filter((s) => s.enabled) +export async function Services() { + const t = await getTranslations() + + const activeServices = await Promise.all( + SERVICE_CATEGORIES.filter((s) => s.enabled).map(async (service) => { + const livePrice = await HUB_PRICE_RESOLVERS[service.id]?.() + return { ...service, startingPriceGBP: livePrice ?? service.startingPriceGBP } + }), + ) return (
    @@ -37,12 +67,12 @@ export function Services() { {/* Service Hub Cards */}
    {activeServices.map((service) => ( @@ -59,23 +89,23 @@ export function Services() { {/* Card header — gradient visual */}
    {/* Large faded background icon */} - + {/* Centred icon badge */}
    -
    +
    {/* Title + starting price */} -
    -

    {service.name}

    - +
    +

    {service.name}

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

    {country}

    - {locations.map((loc) => ( - - {loc.city} - {loc.area && · {loc.area}} - {loc.primary && } - - ))} + {locations.map((loc) => { + const monitorName = LOCATION_MONITOR_MAP[loc.id] + const live = monitorName ? findMonitor(monitorName) : null + const liveState = live ? LIVE_STATE_STYLES[live.status] : null + return ( + + {liveState && } + {loc.city} + {loc.area && · {loc.area}} + {loc.primary && } + + ) + })}
    @@ -282,8 +316,15 @@ const FAQS = [ export function NodesClient() { const nodes = STATIC_NODES - const onlineCount = nodes.filter((n) => !n.isMaintenanceMode).length - const maintenanceCount = nodes.filter((n) => n.isMaintenanceMode).length + const { findMonitor } = useNodeStatus() + const nodeLiveStates = nodes.map((node) => { + const monitorName = NODE_MONITOR_MAP[node.name] + return monitorName ? findMonitor(monitorName) : null + }) + const onlineCount = nodeLiveStates.filter( + (live, i) => (live ? live.status === "up" : !nodes[i].isMaintenanceMode), + ).length + const maintenanceCount = nodes.length - onlineCount const hydrated = useSyncExternalStore(() => () => {}, () => true, () => false) return ( @@ -320,7 +361,7 @@ export function NodesClient() { { label: "Total Nodes", value: nodes.length }, { label: "Online", value: onlineCount, color: "text-green-400" }, { label: "In Maintenance", value: maintenanceCount, color: "text-amber-400" }, - { label: "Data Center Partners", value: "3+" }, + { label: "Data Center Partners", value: "10+" }, ].map((stat) => (
    {stat.value}
    @@ -341,8 +382,8 @@ export function NodesClient() {
    - {nodes.map((node) => ( - + {nodes.map((node, i) => ( + ))}
    @@ -384,6 +425,7 @@ export function NodesClient() { country={country} flag={flag} locations={locations} + findMonitor={findMonitor} /> ))}
    diff --git a/packages/ui/components/Layouts/VPS/vps-hub.tsx b/packages/ui/components/Layouts/VPS/vps-hub.tsx index 47c09fe..85610e9 100644 --- a/packages/ui/components/Layouts/VPS/vps-hub.tsx +++ b/packages/ui/components/Layouts/VPS/vps-hub.tsx @@ -13,6 +13,7 @@ import { X, ArrowRight, Star, + PackageX, } from "lucide-react" import { Button } from "@/packages/ui/components/ui/button" import { Badge } from "@/packages/ui/components/ui/badge" @@ -378,7 +379,13 @@ export function VpsHub({ plans }: VpsHubProps) { {/* ── Plan grid ────────────────────────────────────────────────────── */}
    - {filtered.length === 0 ? ( + {plans.length === 0 ? ( +
    + +

    No VPS plans in stock right now

    +

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

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

    No plans match your filters

    diff --git a/packages/ui/components/Static/footer.tsx b/packages/ui/components/Static/footer.tsx index d02e72b..fb9e16c 100644 --- a/packages/ui/components/Static/footer.tsx +++ b/packages/ui/components/Static/footer.tsx @@ -10,6 +10,7 @@ import { Logo } from "@/packages/ui/components/logo" import Image from "next/image" import { useTranslations } from "next-intl" import { LINKS } from "@/packages/core/constants/links" +import { StatusBadge } from "@/packages/ui/components/Static/status-badge" export function Footer() { const t = useTranslations() @@ -262,6 +263,7 @@ export function Footer() { © {new Date().getFullYear()} NodeByte LTD. {t("footer.copyright")}

    + Company No. 15432941
    diff --git a/packages/ui/components/Static/navigation.tsx b/packages/ui/components/Static/navigation.tsx index 524b7f0..439c5bf 100644 --- a/packages/ui/components/Static/navigation.tsx +++ b/packages/ui/components/Static/navigation.tsx @@ -8,7 +8,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/packages/ui/components/ui/dropdown-menu" -import { Server, Gamepad2, Blocks, ExternalLink, ChevronRight, ChevronDown, Book, Mail, Users, Sparkles, Cpu, Network } from "lucide-react" +import { Server, Gamepad2, ExternalLink, ChevronRight, ChevronDown, Book, Mail, Users, Sparkles, Cpu, Network } from "lucide-react" import { ThemeToggle } from "@/packages/ui/components/theme-toggle" import { CurrencySelector } from "@/packages/ui/components/ui/price" import { LanguageSelector } from "@/packages/ui/components/ui/language-selector" @@ -20,7 +20,12 @@ import { useTranslations } from "next-intl" import { LINKS } from "@/packages/core/constants/links" import { SiDiscord } from "react-icons/si" -export function Navigation() { +interface NavigationProps { + /** Live game categories discovered from the billing panel, fetched server-side in app/layout.tsx. */ + gamesNav?: { slug: string; name: string }[] +} + +export function Navigation({ gamesNav = [] }: NavigationProps) { const t = useTranslations() const mountedRef = useRef(false) @@ -53,27 +58,13 @@ export function Navigation() { ] const services = [ - { - title: t("services.minecraft.title"), - href: "/games/minecraft", - description: t("services.minecraft.description"), - icon: Blocks, - section: "game", - }, - { - title: t("services.rust.title"), - href: "/games/rust", - description: t("services.rust.description"), + ...gamesNav.map((game) => ({ + title: game.name, + href: `/games/${game.slug}`, + description: `${game.name} server hosting`, icon: Gamepad2, - section: "game", - }, - { - title: t("services.hytale.title"), - href: "/games/hytale", - description: t("services.hytale.description"), - icon: Gamepad2, - section: "game", - }, + section: "game" as const, + })), { title: t("services.gameServers.title"), href: "/games", diff --git a/packages/ui/components/Static/status-badge.tsx b/packages/ui/components/Static/status-badge.tsx new file mode 100644 index 0000000..1d5cc76 --- /dev/null +++ b/packages/ui/components/Static/status-badge.tsx @@ -0,0 +1,44 @@ +"use client" + +import { useTranslations } from "next-intl" +import { cn } from "@/lib/utils" +import { useNodeStatus } from "@/packages/core/hooks/use-node-status" +import { LINKS } from "@/packages/core/constants/links" + +const STATUS_DOT: Record = { + up: "bg-green-400 animate-pulse", + degraded: "bg-amber-400", + maintenance: "bg-amber-400", + paused: "bg-amber-400", + down: "bg-red-400", + unknown: "bg-muted-foreground", +} + +const STATUS_LABEL_KEY: Record = { + up: "operational", + degraded: "degraded", + maintenance: "maintenance", + paused: "maintenance", + down: "down", + unknown: "unavailable", +} + +export function StatusBadge() { + const t = useTranslations() + const { available, overallStatus } = useNodeStatus() + + const labelKey = available && overallStatus ? STATUS_LABEL_KEY[overallStatus] : "unavailable" + const dotClass = available && overallStatus ? STATUS_DOT[overallStatus] : "bg-muted-foreground" + + return ( + + + {t(`footer.statusLabels.${labelKey}`)} + + ) +} diff --git a/packages/ui/components/layout-chrome.tsx b/packages/ui/components/layout-chrome.tsx index 1d18874..8d3e1c3 100644 --- a/packages/ui/components/layout-chrome.tsx +++ b/packages/ui/components/layout-chrome.tsx @@ -5,15 +5,16 @@ import { Footer } from "@/packages/ui/components/Static/footer" interface LayoutChromeProps { children: React.ReactNode + gamesNav?: { slug: string; name: string }[] } /** * Client component that wraps pages with navigation and footer. */ -export function LayoutChrome({ children }: LayoutChromeProps) { +export function LayoutChrome({ children, gamesNav }: LayoutChromeProps) { return ( <> - +
    {children}
    diff --git a/translations b/translations index 67a79ea..01c38f0 160000 --- a/translations +++ b/translations @@ -1 +1 @@ -Subproject commit 67a79ea5ff53a62e9c7c8595861c6f200c56128f +Subproject commit 01c38f0e9689fa9c0591ac55937e2ff20e1ea8b9