From 8758908c4faa9938e6685bb7dcaa2502b9de95e9 Mon Sep 17 00:00:00 2001
From: TheRealToxicDev
Date: Mon, 20 Jul 2026 19:01:24 -0600
Subject: [PATCH] feat(add): more new stuff and things
---
.env.example | 6 +-
app/_og/image-generator.tsx | 208 ++++++---
app/api/status/route.ts | 6 +-
app/brand/background/route.ts | 5 +
app/brand/opengraph-image.tsx | 9 +
app/brand/page.tsx | 11 +
app/brand/twitter-image.tsx | 9 +
app/dedicated/page.tsx | 4 +-
app/object-storage/page.tsx | 21 +
app/vps/page.tsx | 4 +-
packages/core/constants/catalog-hubs.ts | 1 +
packages/core/constants/links.ts | 2 +-
packages/core/constants/services.ts | 21 +-
packages/core/constants/status-mapping.ts | 34 +-
packages/core/constants/themes.ts | 76 ++++
packages/core/lib/spec-parser.ts | 73 +++-
packages/core/lib/status.ts | 169 ++++----
packages/core/products/billing-service.ts | 43 +-
packages/core/types/servers/object-storage.ts | 37 ++
.../components/Layouts/Brand/brand-page.tsx | 314 ++++++++++++++
.../components/Layouts/Brand/color-swatch.tsx | 47 ++
.../ui/components/Layouts/Home/services.tsx | 7 +-
.../ObjectStorage/object-storage-hub.tsx | 407 ++++++++++++++++++
.../Layouts/Partners/partners-page.tsx | 8 +-
packages/ui/components/Static/footer.tsx | 17 +-
packages/ui/components/Static/navigation.tsx | 15 +-
packages/ui/components/theme-toggle.tsx | 76 +---
public/og.png | Bin 95484 -> 0 bytes
translations | 2 +-
29 files changed, 1363 insertions(+), 269 deletions(-)
create mode 100644 app/brand/background/route.ts
create mode 100644 app/brand/opengraph-image.tsx
create mode 100644 app/brand/page.tsx
create mode 100644 app/brand/twitter-image.tsx
create mode 100644 app/object-storage/page.tsx
create mode 100644 packages/core/constants/themes.ts
create mode 100644 packages/core/types/servers/object-storage.ts
create mode 100644 packages/ui/components/Layouts/Brand/brand-page.tsx
create mode 100644 packages/ui/components/Layouts/Brand/color-swatch.tsx
create mode 100644 packages/ui/components/Layouts/ObjectStorage/object-storage-hub.tsx
delete mode 100644 public/og.png
diff --git a/.env.example b/.env.example
index 5f0af76..3d507d1 100644
--- a/.env.example
+++ b/.env.example
@@ -8,7 +8,5 @@ JWT_SECRET=""
# Free tier: use a key ending in :fx | Paid tier: standard key
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
+# nodebytestat.us public status API — no auth required.
+STATUS_API_URL="https://nodebytestat.us"
\ No newline at end of file
diff --git a/app/_og/image-generator.tsx b/app/_og/image-generator.tsx
index 6fe8af4..359df34 100644
--- a/app/_og/image-generator.tsx
+++ b/app/_og/image-generator.tsx
@@ -1,3 +1,4 @@
+import type { ReactNode } from 'react'
import { ImageResponse } from 'next/og'
export const OG_SIZE = { width: 1200, height: 630 } as const
@@ -25,6 +26,11 @@ export const OG_CONFIGS = {
description: 'Minecraft, Rust, Hytale and more — one-click deployment with mod support included.',
features: ['Minecraft', 'Rust', 'Hytale', 'Mod Support', 'DDoS Protected'],
},
+ brand: {
+ headline: ['Brand &', 'Press Kit.'] as const,
+ description: 'Logo files, color palettes, and usage guidelines for partners and press.',
+ features: ['Logo Assets', '47 Themes', 'Templates', 'Usage Guide'],
+ },
} satisfies Record
// ─── Logo path data ────────────────────────────────────────────────────────────
@@ -53,74 +59,98 @@ function LogoMark({ size, clipId }: { size: number; clipId: string }) {
)
}
-export function makeOGImage(config: OGConfig): ImageResponse {
- return new ImageResponse(
- (
+/** The atmospheric layer shared by every generated image — bg color, radial glows, top accent bar, ghost logo watermark, bottom URL. */
+function OGBackdrop({ clipId, children }: { clipId: string; children?: ReactNode }) {
+ return (
+
+ {/* Blue radial glow — top right */}
- {/* Blue radial glow — top right */}
-
+ />
- {/* Purple radial glow — bottom left */}
-
+ {/* Purple radial glow — bottom left */}
+
- {/* Top accent bar */}
-
+ {/* Top accent bar */}
+
- {/* Ghost logo watermark — right side */}
-
-
-
+ {/* Ghost logo watermark — right side */}
+
+
+
+
+ {/* Bottom — URL */}
+
+ nodebyte.host
+
+
+ {children}
+
+ )
+}
+export function makeOGImage(config: OGConfig): ImageResponse {
+ return new ImageResponse(
+ (
+
{/* Main content */}
+
+ ),
+ {
+ width: OG_SIZE.width,
+ height: OG_SIZE.height,
+ },
+ )
+}
- {/* Bottom — URL */}
+/**
+ * A blank branded canvas — same backdrop (glows, top accent bar, ghost logo
+ * watermark) as the real OG images, but with no headline/description/feature
+ * copy baked in. Downloadable from /brand as a starting template partners
+ * and press can drop their own text onto.
+ */
+export function makeOGBackgroundTemplate(): ImageResponse {
+ return new ImageResponse(
+ (
+
- nodebyte.host
+
+
+
+ NodeByte Hosting
+
+
+ Built for Humans. Powered by Bytes.
+
+
-
+
),
{
width: OG_SIZE.width,
diff --git a/app/api/status/route.ts b/app/api/status/route.ts
index 36ba89d..221b906 100644
--- a/app/api/status/route.ts
+++ b/app/api/status/route.ts
@@ -1,13 +1,11 @@
import { NextResponse } from "next/server"
-import { computeLatencyStats, fetchStatusSnapshot, type MonitorStatus, type MonitorType } from "@/packages/core/lib/status"
+import { computeLatencyStats, fetchStatusSnapshot, type MonitorStatus } 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
@@ -32,9 +30,7 @@ export async function GET() {
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),
diff --git a/app/brand/background/route.ts b/app/brand/background/route.ts
new file mode 100644
index 0000000..a7de18b
--- /dev/null
+++ b/app/brand/background/route.ts
@@ -0,0 +1,5 @@
+import { makeOGBackgroundTemplate } from '../../_og/image-generator'
+
+export async function GET() {
+ return makeOGBackgroundTemplate()
+}
diff --git a/app/brand/opengraph-image.tsx b/app/brand/opengraph-image.tsx
new file mode 100644
index 0000000..8698106
--- /dev/null
+++ b/app/brand/opengraph-image.tsx
@@ -0,0 +1,9 @@
+import { OG_CONFIGS, OG_CONTENT_TYPE, OG_SIZE, makeOGImage } from '../_og/image-generator'
+
+export const alt = 'NodeByte Hosting — Brand & Press Kit'
+export const size = OG_SIZE
+export const contentType = OG_CONTENT_TYPE
+
+export default function Image() {
+ return makeOGImage(OG_CONFIGS.brand)
+}
diff --git a/app/brand/page.tsx b/app/brand/page.tsx
new file mode 100644
index 0000000..b6c9e5f
--- /dev/null
+++ b/app/brand/page.tsx
@@ -0,0 +1,11 @@
+import { BrandPage } from "@/packages/ui/components/Layouts/Brand/brand-page"
+import type { Metadata } from "next"
+
+export const metadata: Metadata = {
+ title: "Brand & Press Kit",
+ description: "Logo files, color palettes, and usage guidelines for partners, press, and anyone writing about NodeByte Hosting.",
+}
+
+export default function Brand() {
+ return
+}
diff --git a/app/brand/twitter-image.tsx b/app/brand/twitter-image.tsx
new file mode 100644
index 0000000..8698106
--- /dev/null
+++ b/app/brand/twitter-image.tsx
@@ -0,0 +1,9 @@
+import { OG_CONFIGS, OG_CONTENT_TYPE, OG_SIZE, makeOGImage } from '../_og/image-generator'
+
+export const alt = 'NodeByte Hosting — Brand & Press Kit'
+export const size = OG_SIZE
+export const contentType = OG_CONTENT_TYPE
+
+export default function Image() {
+ return makeOGImage(OG_CONFIGS.brand)
+}
diff --git a/app/dedicated/page.tsx b/app/dedicated/page.tsx
index cf7aae6..b407e56 100644
--- a/app/dedicated/page.tsx
+++ b/app/dedicated/page.tsx
@@ -12,9 +12,9 @@ export const metadata: Metadata = {
export default async function DedicatedPage() {
const hub = await getCategoryHub(DEDICATED_HUB_SLUGS)
- const children = hub?.children ?? []
+ const categorySlugs = hub?.children.map((c) => c.slug) ?? []
- const plansByCategory = await Promise.all(children.map((c) => getDedicatedPlans(c.slug)))
+ const plansByCategory = await Promise.all(categorySlugs.map((slug) => getDedicatedPlans(slug)))
const plans = plansByCategory.flat()
return
diff --git a/app/object-storage/page.tsx b/app/object-storage/page.tsx
new file mode 100644
index 0000000..e568b19
--- /dev/null
+++ b/app/object-storage/page.tsx
@@ -0,0 +1,21 @@
+import type { Metadata } from "next"
+import { ObjectStorageHub } from "@/packages/ui/components/Layouts/ObjectStorage/object-storage-hub"
+import { getObjectStoragePlans } from "@/packages/core/products/billing-service"
+import { getCategoryHub } from "@/packages/core/lib/bytepay"
+import { OBJECT_STORAGE_HUB_SLUGS } from "@/packages/core/constants/catalog-hubs"
+
+export const metadata: Metadata = {
+ title: "Object Storage",
+ description:
+ "S3-compatible object storage with generous free egress, self-service access keys, and 99.99% enterprise-grade reliability.",
+}
+
+export default async function ObjectStoragePage() {
+ const hub = await getCategoryHub(OBJECT_STORAGE_HUB_SLUGS)
+ const categorySlugs = hub?.children.map((c) => c.slug) ?? []
+
+ const plansByCategory = await Promise.all(categorySlugs.map((slug) => getObjectStoragePlans(slug)))
+ const plans = plansByCategory.flat()
+
+ return
+}
diff --git a/app/vps/page.tsx b/app/vps/page.tsx
index 3b5e94f..841cd9f 100644
--- a/app/vps/page.tsx
+++ b/app/vps/page.tsx
@@ -12,9 +12,9 @@ export const metadata: Metadata = {
export default async function VpsPage() {
const hub = await getCategoryHub(VPS_HUB_SLUGS)
- const children = hub?.children ?? []
+ const categorySlugs = hub?.children.map((c) => c.slug) ?? []
- const plansByCategory = await Promise.all(children.map((c) => getVpsPlans(c.slug)))
+ const plansByCategory = await Promise.all(categorySlugs.map((slug) => getVpsPlans(slug)))
const plans = plansByCategory.flat()
return
diff --git a/packages/core/constants/catalog-hubs.ts b/packages/core/constants/catalog-hubs.ts
index dd40358..b679c09 100644
--- a/packages/core/constants/catalog-hubs.ts
+++ b/packages/core/constants/catalog-hubs.ts
@@ -9,3 +9,4 @@
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"]
+export const OBJECT_STORAGE_HUB_SLUGS = ["object-storage", "storage"]
diff --git a/packages/core/constants/links.ts b/packages/core/constants/links.ts
index 75d9294..d426497 100644
--- a/packages/core/constants/links.ts
+++ b/packages/core/constants/links.ts
@@ -10,7 +10,7 @@ export const LINKS = {
githubDiscussions: "https://github.com/orgs/NodeByteHosting/discussions",
twitter: "https://twitter.com/NodeByteHosting",
trustpilot: "https://uk.trustpilot.com/review/nodebyte.host",
- status: "https://status.nodebyte.host",
+ status: "https://nodebytestat.us",
network: "https://lg.nodebyte.host",
contact: "/contact",
billing: {
diff --git a/packages/core/constants/services.ts b/packages/core/constants/services.ts
index a4a7f67..9e58d21 100644
--- a/packages/core/constants/services.ts
+++ b/packages/core/constants/services.ts
@@ -1,4 +1,4 @@
-import { Gamepad2, Server, Cpu, type LucideIcon } from "lucide-react"
+import { Gamepad2, Server, Cpu, Cloud, type LucideIcon } from "lucide-react"
/**
* ServiceCategory defines a top-level service hub offered by NodeByte.
@@ -89,4 +89,23 @@ export const SERVICE_CATEGORIES: ServiceCategory[] = [
],
enabled: true,
},
+ {
+ id: "object-storage",
+ name: "Object Storage",
+ description:
+ "S3 API compatible cloud storage with generous free egress, self-service access keys, and 99.99% enterprise-grade reliability.",
+ href: "/object-storage",
+ icon: Cloud,
+ gradient: "from-emerald-600/25 via-emerald-500/8 to-transparent",
+ iconColor: "text-emerald-400",
+ accentBorder: "hover:border-emerald-400/40",
+ startingPriceGBP: 4,
+ highlights: [
+ "S3 API compatible",
+ "Self-service access keys",
+ "Generous free egress",
+ "99.99% reliability",
+ ],
+ enabled: true,
+ },
]
diff --git a/packages/core/constants/status-mapping.ts b/packages/core/constants/status-mapping.ts
index 06318bf..27232f4 100644
--- a/packages/core/constants/status-mapping.ts
+++ b/packages/core/constants/status-mapping.ts
@@ -1,37 +1,31 @@
/**
- * The node list on /nodes is discovered live from status.nodebyte.host's
- * "Nodes" group (see getNodeMonitorNames in lib/status.ts) — adding a node
- * there is all that's needed for it to appear on the site.
+ * The node list on /nodes is discovered live from nodebytestat.us — any
+ * monitor whose group name ends in "Nodes" (e.g. "Game Nodes", "VPS Nodes",
+ * see getNodeMonitorNames in lib/status.ts) — adding a node there is all
+ * that's needed for it to appear on the site.
*
- * status.nodebyte.host doesn't carry location/hardware details, so those are
+ * nodebytestat.us doesn't carry location/hardware details, so those are
* filled in here as optional per-node overrides, keyed by the exact monitor
* name. A node with no entry here still shows up, just without these extras.
*/
export const NODE_DISPLAY_OVERRIDES: Record = {
"NEWC-GAME1": { locationCode: "Newcastle, UK" },
- "NEWY-GAME1": { locationCode: "New York, USA" },
+ "NEWY-GAME1": { locationCode: "New York, US" },
"HEL-VPS1": { locationCode: "Helsinki, FI" },
- // Inferred from the "FSN" prefix (Falkenstein) — confirm/correct if wrong.
+ // Inferred from the "FSN" prefix (Falkenstein, a confirmed Data Centres entry) — confirm/correct if wrong.
"FSN-VPS1": { locationCode: "Falkenstein, DE" },
}
/**
- * Website location `id` (LOCATIONS) → status.nodebyte.host "Regions" monitor
- * `name`. Only locations with a confirmed matching ping monitor are listed;
- * the Regions group also includes PoPs (e.g. Ashburn VA, Atlanta GA) that
- * don't correspond to an actual NodeByte data centre location. Update
- * whenever a new Region ping monitor is added upstream — unmapped locations
- * simply render without live data.
+ * Website location `id` (LOCATIONS) → nodebytestat.us "Data Centres" component
+ * `name`. Only locations with a confirmed matching monitor are listed —
+ * unmapped locations simply render without live data. Update whenever a new
+ * Data Centres component is added upstream.
*/
export const LOCATION_MONITOR_MAP: Record = {
- lon: "London, UK",
+ ncl: "Newcastle, 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",
+ tor: "Toronto, CA",
+ newy: "New York, NY",
}
diff --git a/packages/core/constants/themes.ts b/packages/core/constants/themes.ts
new file mode 100644
index 0000000..9640138
--- /dev/null
+++ b/packages/core/constants/themes.ts
@@ -0,0 +1,76 @@
+/**
+ * Single source of truth for every theme the site offers — used by the
+ * theme picker (packages/ui/components/theme-toggle.tsx) and the brand/press
+ * kit page (/brand). Each entry: value (next-themes key), label, bg swatch,
+ * accent swatch. Keep in sync with the actual CSS variable blocks in
+ * app/globals.css.
+ */
+export const THEMES = {
+ catppuccin: [
+ { value: "catppuccin-mocha", label: "Mocha", bg: "#1e1e2e", accent: "#cba6f7" },
+ { value: "catppuccin-macchiato", label: "Macchiato", bg: "#24273a", accent: "#c6a0f6" },
+ { value: "catppuccin-frappe", label: "Frappé", bg: "#303446", accent: "#ca9ee6" },
+ { value: "catppuccin-latte", label: "Latte", bg: "#eff1f5", accent: "#8839ef" },
+ ],
+ popular: [
+ { value: "dracula", label: "Dracula", bg: "#282a36", accent: "#bd93f9" },
+ { value: "nord", label: "Nord", bg: "#2e3440", accent: "#88c0d0" },
+ { value: "gruvbox", label: "Gruvbox", bg: "#282828", accent: "#d79921" },
+ { value: "solarized", label: "Solarized", bg: "#002b36", accent: "#268bd2" },
+ { value: "tokyo-night", label: "Tokyo Night", bg: "#1a1b26", accent: "#7aa2f7" },
+ { value: "one-dark", label: "One Dark", bg: "#282c34", accent: "#61afef" },
+ { value: "rose-pine", label: "Rosé Pine", bg: "#191724", accent: "#c4a7e7" },
+ { value: "kanagawa", label: "Kanagawa", bg: "#1f1f28", accent: "#e46876" },
+ { value: "everforest", label: "Everforest", bg: "#2d353b", accent: "#a7c080" },
+ { value: "monokai", label: "Monokai", bg: "#272822", accent: "#a6e22e" },
+ ],
+ palette: [
+ { value: "slate", label: "Slate", bg: "#1e293b", accent: "#38bdf8" },
+ { value: "ocean", label: "Ocean", bg: "#0c4a6e", accent: "#22d3ee" },
+ { value: "midnight", label: "Midnight", bg: "#0f172a", accent: "#6366f1" },
+ { value: "teal", label: "Teal", bg: "#134e4a", accent: "#14b8a6" },
+ { value: "lavender", label: "Lavender", bg: "#2e1065", accent: "#a855f7" },
+ { value: "violet", label: "Violet", bg: "#4c1d95", accent: "#8b5cf6" },
+ { value: "rose", label: "Rose", bg: "#4c0519", accent: "#fb7185" },
+ { value: "amber", label: "Amber", bg: "#78350f", accent: "#f59e0b" },
+ { value: "desert", label: "Desert", bg: "#451a03", accent: "#c2410c" },
+ { value: "forest", label: "Forest", bg: "#14532d", accent: "#22c55e" },
+ { value: "emerald", label: "Emerald", bg: "#064e3b", accent: "#10b981" },
+ { value: "crimson", label: "Crimson", bg: "#1a0a0f", accent: "#dc2626" },
+ { value: "cobalt", label: "Cobalt", bg: "#0a1628", accent: "#3b82f6" },
+ { value: "sakura", label: "Sakura", bg: "#1a0f14", accent: "#f472b6" },
+ { value: "copper", label: "Copper", bg: "#1c1208", accent: "#b45309" },
+ { value: "abyss", label: "Abyss", bg: "#000c1a", accent: "#0ea5e9" },
+ ],
+ seasonal: [
+ // ── Winter / Holidays ──
+ { value: "christmas", label: "Christmas", bg: "#0d1f0f", accent: "#c4122e" },
+ { value: "newyear", label: "New Year", bg: "#0a0808", accent: "#ffd166" },
+ { value: "winter", label: "Winter", bg: "#0d1b2a", accent: "#93c5fd" },
+ // ── Spring ──
+ { value: "stpatricks", label: "St. Pat's", bg: "#052e16", accent: "#4ade80" },
+ { value: "easter", label: "Easter", bg: "#fdf4ff", accent: "#c084fc" },
+ { value: "spring", label: "Spring", bg: "#fafff7", accent: "#86efac" },
+ // ── Summer ──
+ { value: "summer", label: "Summer", bg: "#0c1f3a", accent: "#facc15" },
+ { value: "fourthjuly", label: "4th July", bg: "#030712", accent: "#f87171" },
+ // ── Autumn ──
+ { value: "halloween", label: "Halloween", bg: "#0d0208", accent: "#f97316" },
+ { value: "autumn", label: "Autumn", bg: "#1c0f00", accent: "#ea580c" },
+ { value: "thanksgiving",label: "Thanks.", bg: "#1a0f00", accent: "#d97706" },
+ // ── Other ──
+ { value: "valentines", label: "Valentine's", bg: "#1a0007", accent: "#f43f5e" },
+ { value: "stranger", label: "Stranger", bg: "#0a0a0a", accent: "#ff2d55" },
+ ],
+} as const
+
+export type ThemeEntry = { value: string; label: string; bg: string; accent: string }
+
+export const THEME_SECTIONS: { key: keyof typeof THEMES; label: string }[] = [
+ { key: "catppuccin", label: "Catppuccin" },
+ { key: "popular", label: "Popular" },
+ { key: "palette", label: "Palette" },
+ { key: "seasonal", label: "Seasonal" },
+]
+
+export const ALL_THEME_ENTRIES: ThemeEntry[] = Object.values(THEMES).flat()
diff --git a/packages/core/lib/spec-parser.ts b/packages/core/lib/spec-parser.ts
index d9cbddb..1a13696 100644
--- a/packages/core/lib/spec-parser.ts
+++ b/packages/core/lib/spec-parser.ts
@@ -47,7 +47,7 @@ function stripHtml(html: string): string {
* extraction below sees one bullet per line regardless of which format the
* description actually uses.
*/
-function bulletLines(html: string): string[] {
+export function bulletLines(html: string): string[] {
const withBreaks = html
.replace(/<\/(li|p|div|h[1-6])>/gi, "\n")
.replace(/ /gi, "\n")
@@ -69,11 +69,11 @@ function bulletLines(html: string): string[] {
.filter(Boolean)
}
-/** First " GB|TB" in a line, converted to GB (TB × 1024). Returns undefined if the line has none. */
-function firstSizeGB(line: string): number | undefined {
- const m = line.match(/(\d+)\s*(GB|TB)\b/i)
+/** First " GB|TB" in a line (comma thousands separators allowed, e.g. "1,000 GB"), converted to GB (TB × 1024). Returns undefined if the line has none. */
+export function firstSizeGB(line: string): number | undefined {
+ const m = line.match(/([\d,]+)\s*(GB|TB)\b/i)
if (!m) return undefined
- const amount = parseInt(m[1])
+ const amount = parseInt(m[1].replace(/,/g, ""))
return /tb/i.test(m[2]) ? amount * 1024 : amount
}
@@ -271,3 +271,66 @@ export function parseProductName(name: string): {
const series = parts[1] ?? undefined
return { sku, lineup, series }
}
+
+export interface ObjectStorageSpecs {
+ storageGB?: number
+ /** Raw value text for the storage bullet, e.g. "1,000 GB SSD-Cached Storage". */
+ storageValue?: string
+ storageType?: ParsedSpecs["storageType"]
+ /** Access key/credential allowance, e.g. "Max 5 active credentials", "Unlimited". */
+ accessKeys?: string
+ /** Monthly egress allowance, e.g. "1 TB Free (Overage just $0.01/GB)". */
+ egress?: string
+ /** API request pricing/limits, e.g. "100% Free (Unlimited GET, PUT, LIST)". */
+ apiRequests?: string
+ /** Auto-archive/lifecycle policy, e.g. "14 Days (Files transition automatically)". */
+ archivePolicy?: string
+ /** Remaining plain-text bullets not matched to a labeled field above. */
+ features: string[]
+}
+
+const OBJECT_STORAGE_LABELS: Record = {
+ "storage limit": "storageValue",
+ "access keys": "accessKeys",
+ "monthly egress": "egress",
+ "egress": "egress",
+ "api requests": "apiRequests",
+ "auto-archive threshold": "archivePolicy",
+}
+
+/**
+ * Parses object storage plan bullets, which follow a "Label: value" pattern
+ * for the structured fields (Storage Limit, Access Keys, Monthly Egress, API
+ * Requests, Auto-Archive Threshold) followed by plain marketing bullets.
+ */
+export function parseObjectStorageSpecs(html: string | null): ObjectStorageSpecs {
+ const result: ObjectStorageSpecs = { features: [] }
+ if (!html) return result
+
+ for (const line of bulletLines(html)) {
+ const m = line.match(/^([^:]{2,40}):\s*(.+)$/)
+ const key = m ? OBJECT_STORAGE_LABELS[m[1].trim().toLowerCase()] : undefined
+
+ if (m && key) {
+ const value = m[2].trim()
+ if (key === "storageValue") {
+ result.storageValue = value
+ result.storageGB = firstSizeGB(value)
+ result.storageType = /nvme/i.test(value)
+ ? "nvme"
+ : /ssd/i.test(value)
+ ? "ssd"
+ : /hdd/i.test(value)
+ ? "hdd"
+ : "generic"
+ } else {
+ (result as unknown as Record)[key] = value
+ }
+ continue
+ }
+
+ result.features.push(line)
+ }
+
+ return result
+}
diff --git a/packages/core/lib/status.ts b/packages/core/lib/status.ts
index ed2016b..98ace83 100644
--- a/packages/core/lib/status.ts
+++ b/packages/core/lib/status.ts
@@ -1,29 +1,19 @@
/**
- * 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.
+ * Server-side fetch for nodebytestat.us's public status API — fully public,
+ * no auth required. 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
+ id: string
name: string
- type: MonitorType
+ /** Name of the monitor's immediate parent group, e.g. "Game Nodes", "Data Centres > Europe". */
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
}
@@ -33,66 +23,97 @@ export interface StatusSnapshot {
monitors: StatusMonitor[]
}
-function getConfig(): { host: string; token: string | undefined } {
+type ComponentStatus = "OPERATIONAL" | "DEGRADED_PERFORMANCE" | "PARTIAL_OUTAGE" | "MAJOR_OUTAGE" | "UNDER_MAINTENANCE"
+type Indicator = "NONE" | "MINOR" | "MAJOR" | "CRITICAL"
+
+interface RawComponent {
+ id: string
+ name: string
+ status: ComponentStatus
+ groupId: string | null
+ uptimePct: number | null
+ monitor: { lastStatus: string | null; lastResponseMs: number | null; lastCheckedAt: string | null } | null
+}
+
+interface RawGroup {
+ id: string
+ name: string
+ parentId: string | null
+ components: RawComponent[]
+ children: RawGroup[]
+}
+
+interface RawStatusResponse {
+ status: { indicator: Indicator; description: string }
+ groups: RawGroup[]
+ components: RawComponent[]
+}
+
+const COMPONENT_STATUS_MAP: Record = {
+ OPERATIONAL: "up",
+ DEGRADED_PERFORMANCE: "degraded",
+ PARTIAL_OUTAGE: "down",
+ MAJOR_OUTAGE: "down",
+ UNDER_MAINTENANCE: "maintenance",
+}
+
+const INDICATOR_MAP: Record = {
+ NONE: "up",
+ MINOR: "degraded",
+ MAJOR: "down",
+ CRITICAL: "down",
+}
+
+function getConfig(): { host: string } {
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 }
+ return { host }
}
-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)
+/** Walk the group tree (including nested children) into a flat id → display-path map, e.g. "Europe" under "Data Centres" becomes "Data Centres > Europe". */
+function buildGroupNameMap(groups: RawGroup[], parentPath = ""): Map {
+ const map = new Map()
+ for (const group of groups) {
+ const path = parentPath ? `${parentPath} > ${group.name}` : group.name
+ map.set(group.id, path)
+ for (const [id, name] of buildGroupNameMap(group.children, path)) {
+ map.set(id, name)
}
}
-
- if (!res.ok) {
- throw new Error(`Status API returned ${res.status}`)
- }
-
- return res.json()
+ return map
}
/** 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,
+ const { host } = getConfig()
+ const res = await fetch(`${host}/api/status`, {
+ headers: { Accept: "application/json" },
+ next: { revalidate: 30 },
+ })
+
+ if (!res.ok) {
+ throw new Error(`Status API returned ${res.status}`)
+ }
+
+ const data: RawStatusResponse = await res.json()
+ const groupNames = buildGroupNameMap(data.groups)
+
+ const monitors: StatusMonitor[] = data.components.map((c) => ({
+ id: c.id,
+ name: c.name,
+ group_name: c.groupId ? (groupNames.get(c.groupId) ?? null) : null,
+ status: COMPONENT_STATUS_MAP[c.status] ?? "unknown",
+ last_checked_at: c.monitor?.lastCheckedAt ? Math.floor(Date.parse(c.monitor.lastCheckedAt) / 1000) : null,
+ last_latency_ms: c.monitor?.lastResponseMs ?? null,
+ uptime_30d_pct: c.uptimePct,
}))
return {
- generated_at: data.generated_at as number,
- overall_status: data.overall_status as MonitorStatus,
+ generated_at: Math.floor(Date.now() / 1000),
+ overall_status: INDICATOR_MAP[data.status.indicator] ?? "unknown",
monitors,
}
} catch (error) {
@@ -109,30 +130,24 @@ export function findMonitor(snapshot: StatusSnapshot | null, name: string): Stat
}
/**
- * Names of individual node monitors under the "Nodes" status group — this is
- * the live source of truth for which nodes exist on /nodes. Excludes the
- * "group"-type aggregate rollups (e.g. "Game Servers", "VPS Servers") that
- * summarise the individual node monitors rather than representing one.
- * Add a node on status.nodebyte.host under the "Nodes" group and it appears
- * here automatically — no website code change needed.
+ * Names of individual node monitors — any monitor whose immediate group name
+ * ends in "Nodes" (e.g. "Game Nodes", "VPS Nodes"). This is the live source
+ * of truth for which nodes exist on /nodes. Add a node under a "*Nodes"
+ * group on nodebytestat.us and it appears here automatically — no website
+ * code change needed.
*/
export function getNodeMonitorNames(snapshot: StatusSnapshot | null): string[] {
if (!snapshot) return []
return snapshot.monitors
- .filter((m) => m.group_name === "Nodes" && m.type !== "group")
+ .filter((m) => {
+ const leafGroup = m.group_name?.split(" > ").pop()?.trim()
+ return leafGroup?.toLowerCase().endsWith("nodes") ?? false
+ })
.map((m) => m.name)
}
-/** Compute fast/avg/slow latency (ms) from a monitor's recent heartbeats. */
+/** Single-sample "latency" — the new status API only exposes the most recent check, not a rolling history. */
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 }
+ if (monitor.last_latency_ms == null) return null
+ return { fast: monitor.last_latency_ms, avg: monitor.last_latency_ms, slow: monitor.last_latency_ms }
}
diff --git a/packages/core/products/billing-service.ts b/packages/core/products/billing-service.ts
index 6ca3075..9b48ea7 100644
--- a/packages/core/products/billing-service.ts
+++ b/packages/core/products/billing-service.ts
@@ -2,6 +2,7 @@ import { unstable_cache } from "next/cache"
import type { GamePlanSpec } from "@/packages/core/types/servers/game"
import type { VpsPlanSpec } from "@/packages/core/types/servers/vps"
import type { DedicatedPlanSpec } from "@/packages/core/types/servers/dedicated"
+import type { ObjectStoragePlanSpec } from "@/packages/core/types/servers/object-storage"
import {
fetchAllBillingProducts,
getProductsByCategory,
@@ -12,7 +13,7 @@ import {
getStockStatus,
getBillingUrl,
} from "@/packages/core/lib/bytepay"
-import { parseDescriptionSpecs, parseProductName, formatStorageType } from "@/packages/core/lib/spec-parser"
+import { parseDescriptionSpecs, parseProductName, parseObjectStorageSpecs, 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"
@@ -175,3 +176,43 @@ export async function getVpsPlans(categorySlug: string): Promise
]
})
}
+
+/**
+ * Returns live-priced object storage plans for the given billing category slug.
+ * Storage size, access keys, egress, and API request policy are parsed from
+ * the billing panel description automatically. Plans missing a storage size
+ * are skipped.
+ */
+export async function getObjectStoragePlans(categorySlug: string): Promise {
+ const all = await getCachedProducts()
+ return getProductsByCategory(all, categorySlug).flatMap((product) => {
+ const parsed = parseObjectStorageSpecs(product.description)
+
+ if (!parsed.storageGB) {
+ warnDroppedProduct(product, categorySlug, ["storageGB"])
+ return []
+ }
+
+ return [
+ {
+ id: product.slug,
+ name: product.name,
+ description: parsed.storageValue,
+ storageGB: parsed.storageGB,
+ storageLabel: formatStorageType(parsed.storageType),
+ accessKeys: parsed.accessKeys,
+ egress: parsed.egress,
+ apiRequests: parsed.apiRequests,
+ archivePolicy: parsed.archivePolicy,
+ features: parsed.features,
+ popular: POPULAR_SLUGS.has(`${categorySlug}/${product.slug}`),
+ priceGBP: getGbpPrice(product),
+ prices: getPricesMap(product),
+ setupFeeGBP: getSetupFeeGBP(product),
+ setupFees: getSetupFeesMap(product),
+ stock: getStockStatus(product),
+ url: getBillingUrl(categorySlug, product.slug),
+ } satisfies ObjectStoragePlanSpec,
+ ]
+ })
+}
diff --git a/packages/core/types/servers/object-storage.ts b/packages/core/types/servers/object-storage.ts
new file mode 100644
index 0000000..1d26926
--- /dev/null
+++ b/packages/core/types/servers/object-storage.ts
@@ -0,0 +1,37 @@
+/**
+ * Shared interface for object storage (S3-compatible) plan specs.
+ * @param {string} id - Unique plan slug
+ * @param {string} [description] - Short marketing description
+ * @param {number} priceGBP - Monthly price in GBP (base currency)
+ * @param {number} storageGB - Storage allowance in gigabytes
+ * @param {string} [storageLabel] - Human-friendly storage type, e.g. "SSD-Cached Storage", "High-Speed Storage"
+ * @param {string} [accessKeys] - Access key/credential allowance, e.g. "Max 5 active credentials", "Unlimited"
+ * @param {string} [egress] - Monthly egress allowance, e.g. "1 TB Free (Overage just $0.01/GB)"
+ * @param {string} [apiRequests] - API request pricing/limits, e.g. "100% Free (Unlimited GET, PUT, LIST)"
+ * @param {string} [archivePolicy] - Auto-archive/lifecycle policy, e.g. "14 Days (Files transition automatically)"
+ * @param {boolean} [popular] - Highlights the plan as a recommended/popular choice
+ * @param {string} [url] - Direct order URL on the billing portal
+ */
+export interface ObjectStoragePlanSpec {
+ id: string
+ name?: string
+ description?: string
+ priceGBP: number
+ storageGB: number
+ storageLabel?: string
+ accessKeys?: string
+ egress?: string
+ apiRequests?: string
+ archivePolicy?: string
+ /** Remaining marketing bullets not captured by a structured field above. */
+ features: string[]
+ popular?: boolean
+ url?: string
+ /** One-time setup fee in GBP (0 if none). */
+ setupFeeGBP: number
+ /** Native one-time setup fees per currency code. */
+ setupFees?: Record
+ stock?: "in_stock" | "out_of_stock" | "coming_soon"
+ /** Native billing prices per currency code, e.g. { GBP: 4, EUR: 4.59, USD: 5.37 } */
+ prices?: Record
+}
diff --git a/packages/ui/components/Layouts/Brand/brand-page.tsx b/packages/ui/components/Layouts/Brand/brand-page.tsx
new file mode 100644
index 0000000..4f8dc9d
--- /dev/null
+++ b/packages/ui/components/Layouts/Brand/brand-page.tsx
@@ -0,0 +1,314 @@
+"use client"
+
+import Link from "next/link"
+import {
+ Palette,
+ Download,
+ ArrowRight,
+ Type,
+ MessageSquare,
+ Ban,
+ Ruler,
+ Move,
+ Sparkles,
+} from "lucide-react"
+import { Card } from "@/packages/ui/components/ui/card"
+import { Button } from "@/packages/ui/components/ui/button"
+import { Logo } from "@/packages/ui/components/logo"
+import { ColorSwatch } from "@/packages/ui/components/Layouts/Brand/color-swatch"
+import { THEMES, THEME_SECTIONS } from "@/packages/core/constants/themes"
+import { LINKS } from "@/packages/core/constants/links"
+
+const NAV_SECTIONS = [
+ { id: "logo", label: "Logo" },
+ { id: "background", label: "Background" },
+ { id: "colors", label: "Colors" },
+ { id: "typography", label: "Typography" },
+ { id: "voice", label: "Name & Voice" },
+]
+
+const CORE_COLORS = {
+ light: {
+ background: "oklch(0.98 0.01 260)",
+ foreground: "oklch(0.12 0.01 260)",
+ primary: "oklch(0.28 0.12 230)",
+ accent: "oklch(0.65 0.15 200)",
+ },
+ dark: {
+ background: "oklch(0.155 0.03 235)",
+ foreground: "oklch(0.97 0.02 230)",
+ primary: "oklch(0.45 0.12 235)",
+ accent: "oklch(0.55 0.12 200)",
+ },
+}
+
+const LOGO_DOS_DONTS = [
+ { icon: Ruler, text: "Keep clear space around the mark equal to at least the height of the \"N\"." },
+ { icon: Move, text: "Don't stretch, skew, or rotate the logo." },
+ { icon: Ban, text: "Don't recolor the mark or apply drop shadows/outlines/gradients to it." },
+ { icon: Sparkles, text: "On nodebyte.host the mark auto-adapts to the active theme — outside the site, use the static SVG/PNG as-is." },
+]
+
+function ThemeColorCard({ entry }: { entry: { value: string; label: string; bg: string; accent: string } }) {
+ return (
+
+ The same backdrop we use for our own social preview images radial glows, top accent bar, and a faint logo watermark with no headline baked in. Drop your own text or logo on top.
+
+
+
+
+ {/* eslint-disable-next-line @next/next/no-img-element -- generated PNG, not a Next-optimizable static asset */}
+
+
+
+