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 ( +
+ + + + +
+

{entry.label}

+
+ + / + +
+
+
+ ) +} + +export function BrandPage() { + return ( +
+ {/* ── Hero ─────────────────────────────────────────────────────────── */} +
+
+
+
+ + Brand & Press Kit +
+

+ NodeByte{" "} + + Brand Guidelines + +

+

+ Logo files, color palettes, and usage guidelines for partners, press, and anyone writing about NodeByte Hosting. +

+
+ {NAV_SECTIONS.map((s) => ( + + {s.label} + + ))} +
+
+
+ + {/* ── Logo ─────────────────────────────────────────────────────────── */} + + + {/* ── Background Template ─────────────────────────────────────────── */} +
+
+
+

Background Template

+

+ 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 */} + Branded background template, 1200 by 630 pixels + + +
+ + PNG · matches our OG/social image style +
+
+
+ + {/* ── Colors ───────────────────────────────────────────────────────── */} +
+
+
+

Colors

+

+ Our core palette is light + dark. Click any swatch to copy its value. +

+
+ +
+ {(["light", "dark"] as const).map((mode) => ( +
+

+ {mode === "light" ? "Light Theme" : "Dark Theme"} +

+
+ {Object.entries(CORE_COLORS[mode]).map(([key, value]) => ( + + ))} +
+
+ ))} +
+ +
+
+

+ Extended Palette +

+

+ nodebyte.host ships {Object.values(THEMES).flat().length} selectable themes. Background / accent hex shown below each. +

+
+ {THEME_SECTIONS.map(({ key, label }) => ( +
+

{label}

+
+ {THEMES[key].map((entry) => ( + + ))} +
+
+ ))} +
+
+
+ + {/* ── Typography ───────────────────────────────────────────────────── */} +
+
+
+

Typography

+

Geist for interface text, Geist Mono for code and technical values.

+
+ +
+ +
+ Geist — headings & body +
+

Aa Bb Cc 123

+

The quick brown fox jumps over the lazy dog.

+
+ +
+ Geist Mono — code & data +
+

Aa Bb Cc 123

+

The quick brown fox jumps over the lazy dog.

+
+
+
+
+ + {/* ── Name & Voice ─────────────────────────────────────────────────── */} +
+
+
+

Name & Voice

+

How we refer to ourselves in writing.

+
+ + + {[ + { label: "Full name", value: "NodeByte Hosting" }, + { label: "Short wordmark", value: "NodeByte" }, + { label: "Legal entity", value: "NodeByte LTD (Company No. 15432941)" }, + { label: "Tagline", value: "Built for Humans. Powered by Bytes." }, + ].map((row) => ( +
+ {row.label} + {row.value} +
+ ))} +
+ +
+ +

+ Have a partnership, press, or media request? Reach out through our{" "} + contact page. +

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

Looking to partner with NodeByte, not just grab assets?

+
+ + +
+
+
+
+ ) +} diff --git a/packages/ui/components/Layouts/Brand/color-swatch.tsx b/packages/ui/components/Layouts/Brand/color-swatch.tsx new file mode 100644 index 0000000..e1d0929 --- /dev/null +++ b/packages/ui/components/Layouts/Brand/color-swatch.tsx @@ -0,0 +1,47 @@ +"use client" + +import { Copy } from "lucide-react" +import { cn } from "@/lib/utils" +import { useToast } from "@/packages/core/hooks/use-toast" + +interface ColorSwatchProps { + label: string + value: string + /** Rendered swatch color — usually the same as `value`, but callers can pass a resolved hex when `value` is an oklch() string that CSS can't preview reliably everywhere. */ + swatch?: string + className?: string +} + +/** A labeled color swatch that copies its value to the clipboard on click. */ +export function ColorSwatch({ label, value, swatch, className }: ColorSwatchProps) { + const { toast } = useToast() + + const copy = () => { + navigator.clipboard.writeText(value).then(() => { + toast({ title: "Copied!", description: `${label}: ${value}` }) + }).catch(() => { + toast({ title: "Copy failed", description: "Your browser blocked clipboard access.", variant: "destructive" }) + }) + } + + return ( + + ) +} diff --git a/packages/ui/components/Layouts/Home/services.tsx b/packages/ui/components/Layouts/Home/services.tsx index 0705857..ad4aab1 100644 --- a/packages/ui/components/Layouts/Home/services.tsx +++ b/packages/ui/components/Layouts/Home/services.tsx @@ -7,10 +7,10 @@ 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" +import { getGamePlans, getVpsPlans, getDedicatedPlans, getObjectStoragePlans } from "@/packages/core/products/billing-service" +import { GAME_HUB_SLUGS, VPS_HUB_SLUGS, DEDICATED_HUB_SLUGS, OBJECT_STORAGE_HUB_SLUGS } from "@/packages/core/constants/catalog-hubs" -/** 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. */ +/** Live starting price (min across all of a hub's children's plans — getCategoryHub already includes the hub itself as a leaf), 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 }[]>, @@ -30,6 +30,7 @@ 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), + "object-storage": () => getLiveStartingPrice(OBJECT_STORAGE_HUB_SLUGS, getObjectStoragePlans), } export async function Services() { diff --git a/packages/ui/components/Layouts/ObjectStorage/object-storage-hub.tsx b/packages/ui/components/Layouts/ObjectStorage/object-storage-hub.tsx new file mode 100644 index 0000000..4252bf1 --- /dev/null +++ b/packages/ui/components/Layouts/ObjectStorage/object-storage-hub.tsx @@ -0,0 +1,407 @@ +"use client" + +import { useState } from "react" +import { + Cloud, + Search, + HardDrive, + Key, + ArrowLeftRight, + Zap, + Shield, + Archive, + SlidersHorizontal, + X, + ArrowRight, + ChevronDown, + Star, + Check, + PackageX, +} from "lucide-react" +import { Button } from "@/packages/ui/components/ui/button" +import { Badge } from "@/packages/ui/components/ui/badge" +import { Input } from "@/packages/ui/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/packages/ui/components/ui/select" +import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/packages/ui/components/ui/collapsible" +import { PlanInfoRow } from "@/packages/ui/components/ui/plan-info-row" +import { FilterChipRow, FilterChip } from "@/packages/ui/components/ui/filter-chip" +import Link from "next/link" +import { cn } from "@/lib/utils" +import type { ObjectStoragePlanSpec } from "@/packages/core/types/servers/object-storage" +import { Price } from "@/packages/ui/components/ui/price" + +type SortKey = "default" | "price-asc" | "price-desc" | "storage-asc" | "storage-desc" + +function formatStorage(gb: number): string { + return gb >= 1024 ? `${gb / 1024} TB` : `${gb} GB` +} + +// ─── PlanCard ───────────────────────────────────────────────────────────────── + +function PlanCard({ plan }: { plan: ObjectStoragePlanSpec }) { + const outOfStock = plan.stock === "out_of_stock" + const [infoOpen, setInfoOpen] = useState(false) + + return ( +
+ {plan.popular && ( +
+ + Most Popular +
+ )} + +
+ {/* Header row */} +
+
+

+ {plan.name ?? plan.id.replace(/-/g, " ")} +

+ {plan.storageLabel && ( +

{plan.storageLabel}

+ )} +
+
+ +

/month

+
+
+ + {outOfStock && ( + + Out of Stock + + )} + +
+ + {/* Specs grid */} +
+
+ + {formatStorage(plan.storageGB)} Storage +
+ {plan.accessKeys && ( +
+ + {plan.accessKeys} +
+ )} + {plan.egress && ( +
+ + {plan.egress} +
+ )} + {plan.archivePolicy && ( +
+ + Auto-Archive: {plan.archivePolicy} +
+ )} + {plan.apiRequests && ( +
+ + {plan.apiRequests} +
+ )} +
+ + {plan.features.length > 0 && ( + <> +
+
    + {plan.features.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+ + )} + + {plan.setupFeeGBP > 0 && ( + + + + + + } /> + + + )} + + +
+
+ ) +} + +// ─── Hub ───────────────────────────────────────────────────────────────────── + +interface ObjectStorageHubProps { + plans: ObjectStoragePlanSpec[] +} + +export function ObjectStorageHub({ plans }: ObjectStorageHubProps) { + const [search, setSearch] = useState("") + const [storage, setStorage] = useState("ALL") + const [priceMin, setPriceMin] = useState("") + const [priceMax, setPriceMax] = useState("") + const [sort, setSort] = useState("price-asc") + const [filtersOpen, setFiltersOpen] = useState(false) + + const availableStorage = Array.from(new Set(plans.map((p) => p.storageGB))).sort((a, b) => a - b) + + const filtered = (() => { + let result = [...plans] + const q = search.trim().toLowerCase() + if (q) { + result = result.filter( + (p) => + p.id.toLowerCase().includes(q) || + p.name?.toLowerCase().includes(q) || + p.description?.toLowerCase().includes(q), + ) + } + if (storage !== "ALL") result = result.filter((p) => p.storageGB === storage) + const min = parseFloat(priceMin) + const max = parseFloat(priceMax) + if (!isNaN(min)) result = result.filter((p) => p.priceGBP >= min) + if (!isNaN(max)) result = result.filter((p) => p.priceGBP <= max) + if (sort === "price-asc") result.sort((a, b) => a.priceGBP - b.priceGBP) + if (sort === "price-desc") result.sort((a, b) => b.priceGBP - a.priceGBP) + if (sort === "storage-asc") result.sort((a, b) => a.storageGB - b.storageGB) + if (sort === "storage-desc") result.sort((a, b) => b.storageGB - a.storageGB) + return result + })() + + const activeFilterCount = [storage !== "ALL", priceMin !== "", priceMax !== ""].filter(Boolean).length + const hasActiveFilters = activeFilterCount > 0 || search !== "" + + function clearFilters() { + setSearch("") + setStorage("ALL") + setPriceMin("") + setPriceMax("") + setSort("price-asc") + } + + return ( +
+ {/* Background */} +
+
+
+ +
+ + {/* ── Hero ─────────────────────────────────────────────────────────── */} +
+
+ + S3-Compatible Storage +
+

+ Object{" "} + + Storage + +

+

+ S3 API compatible cloud storage with generous free egress and self-service access keys. Works with rclone, Cyberduck, AWS CLI, SDKs, and Docker. +

+ + {/* Key differentiators */} +
+ {[ + { icon: Cloud, text: "S3 API Compatible" }, + { icon: Key, text: "Self-Service Credentials" }, + { icon: Shield, text: "99.99% Reliability" }, + { icon: ArrowLeftRight, text: "Free Egress Included" }, + ].map(({ icon: Icon, text }) => ( +
+ + {text} +
+ ))} +
+
+ + {/* ── Filter bar ───────────────────────────────────────────────────── */} +
+
+
+ + setSearch(e.target.value)} + className="pl-9 bg-card/30 border-border/50" + /> +
+ + + {hasActiveFilters && ( + + )} +
+ + + +
+ +
+ setPriceMin(e.target.value)} + className="w-24 h-7 bg-card/30 border-border/50 text-xs" + /> + + setPriceMax(e.target.value)} + className="w-24 h-7 bg-card/30 border-border/50 text-xs" + /> +
+
+ + + setStorage("ALL")}>All Sizes + {availableStorage.map((s) => ( + setStorage(storage === s ? "ALL" : s)}> + {formatStorage(s)} + + ))} + +
+
+
+
+ + {/* ── Plan grid ────────────────────────────────────────────────────── */} +
+ {plans.length === 0 ? ( +
+ +

No object storage plans in stock right now

+

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

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

No plans match your filters

+

Try adjusting or clearing your filters.

+ +
+ ) : ( + <> +

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

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

Need a custom storage configuration?

+

+ Looking for more storage, higher egress, or a dedicated bucket setup? Get in touch and we'll build a solution that fits. +

+
+ +
+
+
+ +
+
+ ) +} diff --git a/packages/ui/components/Layouts/Partners/partners-page.tsx b/packages/ui/components/Layouts/Partners/partners-page.tsx index 7abfa12..32954ba 100644 --- a/packages/ui/components/Layouts/Partners/partners-page.tsx +++ b/packages/ui/components/Layouts/Partners/partners-page.tsx @@ -3,7 +3,7 @@ import { Card } from "@/packages/ui/components/ui/card" import { Button } from "@/packages/ui/components/ui/button" import { Badge } from "@/packages/ui/components/ui/badge" -import { Handshake, Award, Users, ArrowRight, ExternalLink, MessageCircle } from "lucide-react" +import { Handshake, Award, Users, ArrowRight, ExternalLink, MessageCircle, Palette } from "lucide-react" import Image from "next/image" import Link from "next/link" import { cn } from "@/lib/utils" @@ -175,6 +175,12 @@ export function PartnersPage() {
+

+ Need our logo or brand colors?{" "} + + Visit the Brand & Press Kit + +

diff --git a/packages/ui/components/Static/footer.tsx b/packages/ui/components/Static/footer.tsx index ca2aa30..48017cf 100644 --- a/packages/ui/components/Static/footer.tsx +++ b/packages/ui/components/Static/footer.tsx @@ -119,10 +119,11 @@ export function Footer() {