diff --git a/.env.example b/.env.example index 3d507d1..38c3275 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,5 @@ -NEXT_PUBLIC_GO_API_URL="http://localhost:8080" - -BACKEND_API_KEY="" - -JWT_SECRET="" +# Paymenter origin for the public catalogue (/api/v1/catalog) behind the plan and price cards. No token needed. +BYTEPAY_HOST="" # DeepL API key for automated translation (https://www.deepl.com/pro-api) # Free tier: use a key ending in :fx | Paid tier: standard key diff --git a/app/_og/image-generator.tsx b/app/_og/image-generator.tsx index 359df34..9f1b558 100644 --- a/app/_og/image-generator.tsx +++ b/app/_og/image-generator.tsx @@ -26,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'], }, + discordBots: { + headline: ['Discord Bot', 'Hosting.'] as const, + description: 'Always-on hosting for Discord bots, with backups, DDoS protection and instant setup.', + features: ['Node.js', 'Python', 'Java', 'Go & Rust', 'Backups'], + }, brand: { headline: ['Brand &', 'Press Kit.'] as const, description: 'Logo files, color palettes, and usage guidelines for partners and press.', diff --git a/app/api/billing-debug/route.ts b/app/api/billing-debug/route.ts deleted file mode 100644 index b2ddfaf..0000000 --- a/app/api/billing-debug/route.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { fetchAllBillingProducts } from "@/packages/core/lib/bytepay" -import { parseDescriptionSpecs } from "@/packages/core/lib/spec-parser" - -export const dynamic = "force-dynamic" - -export async function GET(request: Request) { - if (process.env.NODE_ENV !== "development") { - return Response.json({ error: "Not available in production" }, { status: 403 }) - } - - const { searchParams } = new URL(request.url) - const category = searchParams.get("category") - - const products = await fetchAllBillingProducts() - const filtered = category ? products.filter((p) => p.categorySlug === category) : products - - const summary = filtered.map((p) => ({ - id: p.id, - name: p.name, - slug: p.slug, - categorySlug: p.categorySlug, - hidden: p.hidden, - stock: p.stock, - gbpMonthly: - p.plans - .find((pl) => pl.type === "recurring" && pl.billingPeriod === 1 && pl.billingUnit === "month") - ?.prices.find((pr) => pr.currencyCode === "GBP")?.price ?? null, - description: p.description, - })) - - // 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/discord-bots/opengraph-image.tsx b/app/discord-bots/opengraph-image.tsx new file mode 100644 index 0000000..e3223b7 --- /dev/null +++ b/app/discord-bots/opengraph-image.tsx @@ -0,0 +1,9 @@ +import { OG_CONFIGS, OG_CONTENT_TYPE, OG_SIZE, makeOGImage } from '../_og/image-generator' + +export const alt = 'NodeByte Discord Bot Hosting — Node.js, Python, Java, Go, Rust & more' +export const size = OG_SIZE +export const contentType = OG_CONTENT_TYPE + +export default function Image() { + return makeOGImage(OG_CONFIGS.discordBots) +} diff --git a/app/discord-bots/page.tsx b/app/discord-bots/page.tsx new file mode 100644 index 0000000..be9de08 --- /dev/null +++ b/app/discord-bots/page.tsx @@ -0,0 +1,20 @@ +import type { Metadata } from "next" +import { DiscordBotsHub } from "@/packages/ui/components/Layouts/DiscordBots/discord-bots-hub" +import { getDiscordBotPlans } from "@/packages/core/products/billing-service" +import { getCategoryHub } from "@/packages/core/lib/bytepay" +import { DISCORD_BOT_HUB_SLUGS } from "@/packages/core/constants/catalog-hubs" + +export const metadata: Metadata = { + title: "Discord Bot Hosting", + description: + "Affordable 24/7 Discord bot hosting for Node.js, Python, Java, Go, Rust and more, with backups, DDoS protection and instant setup.", +} + +export default async function DiscordBotsPage() { + const hub = await getCategoryHub(DISCORD_BOT_HUB_SLUGS) + const categorySlugs = hub?.children.map((c) => c.slug) ?? [] + + const plansByCategory = await Promise.all(categorySlugs.map((slug) => getDiscordBotPlans(slug))) + + return +} diff --git a/app/discord-bots/twitter-image.tsx b/app/discord-bots/twitter-image.tsx new file mode 100644 index 0000000..e3223b7 --- /dev/null +++ b/app/discord-bots/twitter-image.tsx @@ -0,0 +1,9 @@ +import { OG_CONFIGS, OG_CONTENT_TYPE, OG_SIZE, makeOGImage } from '../_og/image-generator' + +export const alt = 'NodeByte Discord Bot Hosting — Node.js, Python, Java, Go, Rust & more' +export const size = OG_SIZE +export const contentType = OG_CONTENT_TYPE + +export default function Image() { + return makeOGImage(OG_CONFIGS.discordBots) +} diff --git a/app/nodes/page.tsx b/app/nodes/page.tsx deleted file mode 100644 index bf7c396..0000000 --- a/app/nodes/page.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import type { Metadata } from "next" -import { NodesClient } from "@/packages/ui/components/Layouts/Nodes/nodes-client" -import { fetchStatusSnapshot, getNodeMonitorNames } from "@/packages/core/lib/status" - -export const metadata: Metadata = { - title: "Our Network & Nodes", - description: - "Live status and information about NodeByte Hosting's network. See where our infrastructure is located and what powers our game server and VPS hosting.", -} - -export default async function NodesPage() { - const snapshot = await fetchStatusSnapshot() - const nodeNames = getNodeMonitorNames(snapshot) - return -} diff --git a/app/sitemap.ts b/app/sitemap.ts index 59431ac..313ffe7 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -2,7 +2,6 @@ import type { MetadataRoute } from "next" const BASE_URL = "https://nodebyte.host" -// Static routes with their priorities and change frequencies const staticRoutes: Array<{ path: string priority: number @@ -11,8 +10,9 @@ const staticRoutes: Array<{ { path: "", priority: 1.0, changeFrequency: "weekly" }, { path: "/games", priority: 0.9, changeFrequency: "weekly" }, { path: "/vps", priority: 0.9, changeFrequency: "weekly" }, - { path: "/vps/amd", priority: 0.9, changeFrequency: "weekly" }, - { path: "/vps/intel", priority: 0.8, changeFrequency: "monthly" }, + { path: "/dedicated", priority: 0.9, changeFrequency: "weekly" }, + { path: "/object-storage", priority: 0.8, changeFrequency: "weekly" }, + { path: "/discord-bots", priority: 0.8, changeFrequency: "weekly" }, { path: "/about", priority: 0.6, changeFrequency: "monthly" }, { path: "/partners", priority: 0.5, changeFrequency: "monthly" }, { path: "/contact", priority: 0.7, changeFrequency: "monthly" }, diff --git a/next.config.mjs b/next.config.mjs index c18cad5..c13a789 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -18,6 +18,11 @@ const nextConfig = { typescript: { ignoreBuildErrors: true, }, + async redirects() { + return [ + { source: "/nodes", destination: "https://billing.nodebyte.host/looking-glass", permanent: true }, + ] + }, async headers() { return [ { diff --git a/packages/core/constants/catalog-hubs.ts b/packages/core/constants/catalog-hubs.ts index b679c09..27fd320 100644 --- a/packages/core/constants/catalog-hubs.ts +++ b/packages/core/constants/catalog-hubs.ts @@ -1,12 +1,5 @@ -/** - * 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"] export const OBJECT_STORAGE_HUB_SLUGS = ["object-storage", "storage"] +export const DISCORD_BOT_HUB_SLUGS = ["discord-bots", "discord-bot-hosting"] diff --git a/packages/core/constants/links.ts b/packages/core/constants/links.ts index d426497..cb4b8c3 100644 --- a/packages/core/constants/links.ts +++ b/packages/core/constants/links.ts @@ -3,31 +3,34 @@ * Update here once instead of hunting through every component. */ +/** The NodeByte billing panel (nodebyte-bp). Store pages are /store/{category}/{product}. */ +export const BILLING_URL = "https://billing.nodebyte.host" + export const LINKS = { - discord: "https://discord.gg/nodebyte", + discord: "https://discord.gg/Bg3Sf5fqa4", github: "https://github.com/NodeByteHosting", githubWebsite: "https://github.com/NodeByteHosting/website", githubDiscussions: "https://github.com/orgs/NodeByteHosting/discussions", twitter: "https://twitter.com/NodeByteHosting", trustpilot: "https://uk.trustpilot.com/review/nodebyte.host", status: "https://nodebytestat.us", - network: "https://lg.nodebyte.host", + lookingGlass: `${BILLING_URL}/looking-glass`, contact: "/contact", billing: { - root: "https://billing.nodebyte.host", - store: "https://billing.nodebyte.host", - login: "https://billing.nodebyte.host/login", - submitTicket: "https://billing.nodebyte.host/tickets/create", - freeTrial: "https://billing.nodebyte.host/products/free-trial", - amdVps: "https://billing.nodebyte.host/products/amdvps", - intelVps: "https://billing.nodebyte.host/products/intelvps", - minecraftHosting: "https://billing.nodebyte.host/products/minecraft-server-hosting", - hytaleHosting: "https://billing.nodebyte.host/products/hytale-hosting", - rustHosting: "https://billing.nodebyte.host/products/rust-hosting", - minecraft: "https://billing.nodebyte.host/products/minecraft", - rust: "https://billing.nodebyte.host/products/rust", - terrariaHosting: "https://billing.nodebyte.host/products/terraria-server-hosting", - gmodHosting: "https://billing.nodebyte.host/products/garrys-mod-server-hosting", - palworldHosting: "https://billing.nodebyte.host/products/palworld-server-hosting", + root: BILLING_URL, + store: `${BILLING_URL}/store`, + login: `${BILLING_URL}/login`, + submitTicket: `${BILLING_URL}/tickets/new`, + freeTrial: `${BILLING_URL}/store/free-trial`, + amdVps: `${BILLING_URL}/store/amdvps`, + intelVps: `${BILLING_URL}/store/intelvps`, + minecraftHosting: `${BILLING_URL}/store/minecraft-server-hosting`, + hytaleHosting: `${BILLING_URL}/store/hytale-hosting`, + rustHosting: `${BILLING_URL}/store/rust-hosting`, + minecraft: `${BILLING_URL}/store/minecraft`, + rust: `${BILLING_URL}/store/rust`, + terrariaHosting: `${BILLING_URL}/store/terraria-server-hosting`, + gmodHosting: `${BILLING_URL}/store/garrys-mod-server-hosting`, + palworldHosting: `${BILLING_URL}/store/palworld-server-hosting`, }, } as const diff --git a/packages/core/constants/status-mapping.ts b/packages/core/constants/status-mapping.ts deleted file mode 100644 index 27232f4..0000000 --- a/packages/core/constants/status-mapping.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * 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. - * - * 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, US" }, - "HEL-VPS1": { locationCode: "Helsinki, FI" }, - // Inferred from the "FSN" prefix (Falkenstein, a confirmed Data Centres entry) — confirm/correct if wrong. - "FSN-VPS1": { locationCode: "Falkenstein, DE" }, -} - -/** - * 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 = { - ncl: "Newcastle, UK", - fal: "Falkenstein, DE", - hel: "Helsinki, FI", - tor: "Toronto, CA", - newy: "New York, NY", -} diff --git a/packages/core/constants/vps/amd.ts b/packages/core/constants/vps/amd.ts deleted file mode 100644 index 85eec72..0000000 --- a/packages/core/constants/vps/amd.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { VpsPlanSpec } from "@/packages/core/types/servers/vps"; - -export const AMD_BILLING_URL = "https://billing.nodebyte.host/products/amdvps" - -/** AMD plans: add / remove entries here to control what's listed on the site */ -export const AMD_PLANS: VpsPlanSpec[] = [ - { - id: "2GB-R71700X", - sku: "BASE-RG1-2GB", - lineup: "BASE", - series: "RG1", - hardware: "amd", - location: "Helsinki, Finland", - description: "Perfect for small projects, dev environments, and personal sites.", - cpuModel: "AMD Ryzen™ 7 1700X", - priceGBP: 4.50, - setupFeeGBP: 0, - cpu: 1, - ramGB: 2, - storageGB: 25, - bandwidth: { amount: 1, unit: "GB" }, - uplink: { amount: 1, unit: "Gbps" }, - ddos: { layers: [3, 4, 7], autoOn: true }, - url: "https://billing.nodebyte.host/products/amdvps/2gb-r71700x", - }, - { - id: "4GB-R71700X", - sku: "COMP-RG1-4GB", - lineup: "COMP", - series: "RG1", - hardware: "amd", - description: "Great for growing web apps, APIs, and small databases.", - cpuModel: "AMD Ryzen™ 7 1700X", - priceGBP: 9, - setupFeeGBP: 0, - cpu: 2, - ramGB: 4, - storageGB: 50, - bandwidth: { amount: 1, unit: "GB" }, - uplink: { amount: 1, unit: "Gbps" }, - ddos: { layers: [3, 4, 7], autoOn: true }, - url: "https://billing.nodebyte.host/products/amdvps/comp-rg1-4gb", - }, - { - id: "8GB-R71700X", - sku: "COMP-RG1-8GB", - lineup: "COMP", - series: "RG1", - hardware: "amd", - description: "Ideal for production workloads, game backends, and high-traffic sites.", - cpuModel: "AMD Ryzen™ 7 1700X", - priceGBP: 18, - setupFeeGBP: 0, - cpu: 4, - ramGB: 8, - storageGB: 100, - bandwidth: { amount: 1, unit: "GB" }, - uplink: { amount: 1, unit: "Gbps" }, - ddos: { layers: [3, 4, 7], autoOn: true }, - popular: true, - url: "https://billing.nodebyte.host/products/amdvps/comp-rg1-8gb", - }, - { - id: "16GB-R71700X", - sku: "COMP-RG1-16GB", - lineup: "COMP", - series: "RG1", - hardware: "amd", - description: "Maximum power for demanding applications and resource-heavy services.", - cpuModel: "AMD Ryzen™ 7 1700X", - priceGBP: 40, - setupFeeGBP: 0, - cpu: 4, - ramGB: 16, - storageGB: 200, - bandwidth: null, - uplink: { amount: 1, unit: "Gbps" }, - ddos: { layers: [3, 4, 7], autoOn: true }, - url: "https://billing.nodebyte.host/products/amdvps/comp-rg1-16gb", - }, - { - id: "32GB-R71700X", - sku: "COMP-RG1-32GB", - lineup: "COMP", - series: "RG1", - hardware: "amd", - stock: "out_of_stock", - description: "Maximum power for demanding applications and resource-heavy services.", - cpuModel: "AMD Ryzen™ 7 1700X", - priceGBP: 45, - setupFeeGBP: 0, - cpu: 8, - ramGB: 32, - storageGB: 500, - bandwidth: null, - uplink: { amount: 1, unit: "Gbps" }, - ddos: { layers: [3, 4, 7], autoOn: true }, - url: "https://billing.nodebyte.host/products/amdvps/comp-rg1-32gb", - }, - { - id: "64GB-R71700X", - sku: "COMP-RG1-64GB", - lineup: "COMP", - series: "RG1", - hardware: "amd", - stock: "out_of_stock", - description: "Maximum power for demanding applications and resource-heavy services.", - cpuModel: "AMD Ryzen™ 7 1700X", - priceGBP: 60, - setupFeeGBP: 0, - cpu: 8, - ramGB: 64, - storageGB: 1000, - bandwidth: null, - uplink: { amount: 1, unit: "Gbps" }, - ddos: { layers: [3, 4, 7], autoOn: true }, - url: "https://billing.nodebyte.host/products/amdvps/comp-rg1-64gb", - }, -] - -/** Feature section definitions — icon maps to GameFeatures iconMap */ -export const AMD_FEATURE_KEYS = [ - { key: "hardware", icon: "Cpu" as const }, - { key: "storage", icon: "HardDrive" as const }, - { key: "ddos", icon: "Shield" as const }, - { key: "network", icon: "Zap" as const }, - { key: "access", icon: "Settings" as const }, - { key: "support", icon: "Users" as const }, -] as const - -/** FAQ keys — maps to `vps.amd.faqs.` in translations */ -export const AMD_FAQ_KEYS = [ - "rootAccess", "os", "upgrade", "bandwidth", "ddos", "refunds", "windows", "location", -] as const - -/** Number of hero feature pills (maps to `vps.amd.heroFeatures.{0..n}`) */ -export const AMD_HERO_FEATURE_COUNT = 4 - -/** Spec panel rows shown in the hero */ -export const AMD_SPECS = [ - { icon: "Cpu" as const, label: "Processor", value: "Enterprise AMD™" }, - { icon: "Zap" as const, label: "Storage", value: "NVMe SSD" }, - { icon: "HardDrive" as const, label: "Memory", value: "DDR4 ECC RAM" }, - { icon: "Network" as const, label: "Network", value: "1 Gbps Uplink" }, - { icon: "Shield" as const, label: "Protection", value: "Enterprise DDoS" }, - { icon: "Terminal" as const, label: "Access", value: "Full Root / SSH" }, -] diff --git a/packages/core/constants/vps/index.ts b/packages/core/constants/vps/index.ts deleted file mode 100644 index 0b8a4e4..0000000 --- a/packages/core/constants/vps/index.ts +++ /dev/null @@ -1,32 +0,0 @@ -export * from "./amd" -export * from "./intel" - -import { AMD_PLANS } from "./amd" -import { INTEL_PLANS } from "./intel" -import type { VpsPlanSpec } from "@/packages/core/types/servers/vps" - -/** All plans across every hardware line — used by the /vps hub page */ -export const ALL_VPS_PLANS: VpsPlanSpec[] = [ - ...AMD_PLANS, - ...INTEL_PLANS, -] - -/** Static option data for each VPS type — used by the /vps index page */ -export const VPS_OPTIONS = [ - { - slug: "amd" as const, - startingPriceGBP: 4.50, - inStock: true, - gradient: "from-red-500/20 via-orange-500/10 to-primary/5", - iconBg: "bg-red-500/10 text-red-500", - tagColor: "bg-primary text-primary-foreground", - }, - { - slug: "intel" as const, - startingPriceGBP: 4.50, - inStock: false, - gradient: "from-blue-500/20 via-cyan-500/10 to-accent/5", - iconBg: "bg-blue-500/10 text-blue-500", - tagColor: "bg-accent text-accent-foreground", - }, -] as const diff --git a/packages/core/constants/vps/intel.ts b/packages/core/constants/vps/intel.ts deleted file mode 100644 index f9e1616..0000000 --- a/packages/core/constants/vps/intel.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { VpsPlanSpec } from "@/packages/core/types/servers/vps"; - -export const INTEL_BILLING_URL = "https://billing.nodebyte.host/store/vps-hosting" - -/** - * Intel VPS plans. - * Currently EMPTY — out of stock. - * Add entries here (matching the VpsPlanSpec shape) when inventory is available. - */ -export const INTEL_PLANS: VpsPlanSpec[] = [] - -/** Feature section definitions — icon maps to GameFeatures iconMap */ -export const INTEL_FEATURE_KEYS = [ - { key: "hardware", icon: "Cpu" as const }, - { key: "storage", icon: "HardDrive" as const }, - { key: "ddos", icon: "Shield" as const }, - { key: "network", icon: "Zap" as const }, - { key: "access", icon: "Settings" as const }, - { key: "support", icon: "Users" as const }, -] as const - -/** FAQ keys — maps to `vps.intel.faqs.` in translations */ -export const INTEL_FAQ_KEYS = [ - "rootAccess", "os", "upgrade", "bandwidth", "ddos", "refunds", "windows", "location", -] as const - -/** Number of hero feature pills (maps to `vps.intel.heroFeatures.{0..n}`) */ -export const INTEL_HERO_FEATURE_COUNT = 4 - -/** Spec panel rows shown in the hero */ -export const INTEL_SPECS = [ - { icon: "Cpu" as const, label: "Processor", value: "Enterprise Intel®" }, - { icon: "Zap" as const, label: "Storage", value: "NVMe SSD" }, - { icon: "HardDrive" as const, label: "Memory", value: "DDR4 ECC RAM" }, - { icon: "Network" as const, label: "Network", value: "1 Gbps Uplink" }, - { icon: "Shield" as const, label: "Protection", value: "Enterprise DDoS" }, - { icon: "Terminal" as const, label: "Access", value: "Full Root / SSH" }, -] diff --git a/packages/core/lib/bytepay.ts b/packages/core/lib/bytepay.ts index d94fa62..184c8c1 100644 --- a/packages/core/lib/bytepay.ts +++ b/packages/core/lib/bytepay.ts @@ -1,35 +1,25 @@ -/** - * Server-side only — BYTEPAY_TOKEN is never sent to the browser. - * - * Fetches products from the Paymenter admin API and normalises the - * JSON:API response into a flat, typed structure the website can consume. - */ - 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 } -} +import { BILLING_URL } from "@/packages/core/constants/links" 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 { +function getHost(): string { + const host = process.env.BYTEPAY_HOST + if (!host) { + throw new Error("Billing API misconfigured: BYTEPAY_HOST must be set.") + } + return host.replace(/\/+$/, "") +} + +async function fetchWithRetry(url: string): Promise { let lastError: unknown for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { try { const res = await fetch(url, { - headers: { Authorization: `Bearer ${token}` }, + headers: { Accept: "application/json" }, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), next: { revalidate: 300 }, }) @@ -54,28 +44,46 @@ async function fetchWithRetry(url: string, token: string): Promise { throw lastError } -// ─── JSON:API wire types ─────────────────────────────────────────────────── +interface CatalogPrice { + currency_code: string + price: number + setup_fee: number +} -interface JsonApiRelRef { - type: string - id: string +interface CatalogPlan { + id: number + name: string | null + type: BillingPlan["type"] + billing_period: number | null + billing_unit: string | null + sort: number | null + prices: CatalogPrice[] } -interface JsonApiResource { - type: string - id: string - attributes: Record - relationships?: Record +interface CatalogProduct { + id: number + name: string + slug: string + description: string | null + category_id: number | null + stock: number | null + sort: number | null + plans: CatalogPlan[] } -interface JsonApiResponse { - data: JsonApiResource[] - included?: JsonApiResource[] - links?: { next?: string | null } - meta?: Record +interface CatalogCategory { + id: number + name: string + slug: string + description: string | null + parent_id: number | null + sort: number | null } -// ─── Normalised types ────────────────────────────────────────────────────── +interface Catalog { + categories: CatalogCategory[] + products: CatalogProduct[] +} export interface BillingPrice { price: number @@ -98,102 +106,65 @@ export interface BillingProduct { name: string slug: string description: string | null - image: string | null - /** null = unlimited, 0 = out of stock, n = n remaining */ stock: number | null hidden: boolean sort: number | null - /** Derived from the category's name, e.g. "minecraft", "rust", "amd-vps" */ categorySlug: string + storeCategorySlug: string plans: BillingPlan[] } -// ─── JSON:API normalisation ──────────────────────────────────────────────── - -function buildIncludedMap(included: JsonApiResource[] = []): Map { - const map = new Map() - for (const item of included) { - map.set(`${item.type}:${item.id}`, item) - } - return map -} - -/** Convert a display name to a URL-safe slug, e.g. "AMD VPS" → "amd-vps". */ -function nameToSlug(name: string): string { - return name - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-|-$/g, "") -} - export interface CategoryInfo { id: string name: string slug: string + storeSlug: 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}`) - } +export interface CategoryHub { + id: string + name: string + slug: string + description: string | null + children: CategoryInfo[] +} - 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, - }) - } +function nameToSlug(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") +} - if (!json.links?.next) break - page++ +async function fetchCatalog(): Promise { + const res = await fetchWithRetry(`${getHost()}/api/v1/catalog`) + if (!res.ok) { + throw new Error(`Billing API error ${res.status}: ${res.statusText}`) } - - return all + const json: { data: Catalog } = await res.json() + return json.data } -const getCachedCategories = unstable_cache(fetchAllCategories, ["billing-categories"], { - revalidate: 600, +const getCachedCatalog = unstable_cache(fetchCatalog, ["billing-public-catalog"], { + revalidate: 300, }) -export interface CategoryHub { - id: string - name: string - slug: string - description: string | null - children: CategoryInfo[] +async function getCategories(): Promise { + const { categories } = await getCachedCatalog() + return categories.map((category) => ({ + id: String(category.id), + name: category.name, + slug: nameToSlug(category.name), + storeSlug: category.slug, + description: category.description, + parentId: category.parent_id != null ? String(category.parent_id) : null, + })) } -/** - * 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 categories = await getCategories() const byParent = new Map() for (const cat of categories) { @@ -204,44 +175,19 @@ async function fetchCategoryTree(): Promise { return categories .filter((cat) => !cat.parentId) - .map((hub) => { - const children = byParent.get(hub.id) ?? [] - return { - id: hub.id, - name: hub.name, - slug: hub.slug, - description: hub.description, - // Products can be filed directly on the hub category itself — either - // because it has no sub-categories at all yet (e.g. bare-metal - // servers filed straight under "Dedicated Servers"), or because it - // has real sub-categories but *also* some generic products of its - // own (e.g. unified "GAME-BASE"/"GAME-PLUS" tiers filed directly on - // "Game Servers" alongside its existing Minecraft/Rust/Hytale - // children). Always include the hub itself as a leaf, in addition to - // any real children, so pages that iterate `hub.children` never miss - // hub-level products. - children: [ - { id: hub.id, name: hub.name, slug: hub.slug, description: hub.description, parentId: null }, - ...children, - ], - } - }) + .map((hub) => ({ + id: hub.id, + name: hub.name, + slug: hub.slug, + description: hub.description, + children: [hub, ...(byParent.get(hub.id) ?? [])], + })) } -export const getCachedCategoryTree = unstable_cache(fetchCategoryTree, ["billing-category-tree"], { - revalidate: 600, +export const getCachedCategoryTree = unstable_cache(fetchCategoryTree, ["billing-public-category-tree"], { + revalidate: 300, }) -/** - * Find a hub by its slugified name — accepts one or more acceptable aliases - * (e.g. "vps-hosting" or "vps") since the exact parent category name is - * whatever's configured in Paymenter. - * - * Falls back to a substring match (a hub whose slug *contains* one of the - * aliases) if no exact match is found, so a rename like "VPS Servers" → - * "VPS Plans 2026" doesn't silently empty out the whole section — it just - * warns, since the fallback match is a guess rather than a guarantee. - */ export async function getCategoryHub(hubSlugOrAliases: string | string[]): Promise { const aliases = Array.isArray(hubSlugOrAliases) ? hubSlugOrAliases : [hubSlugOrAliases] const tree = await getCachedCategoryTree() @@ -260,144 +206,54 @@ export async function getCategoryHub(hubSlugOrAliases: string | string[]): Promi return null } -/** - * Build category id → slug from the authoritative category list, warning on - * any two categories whose names slugify to the same value (since site pages - * key off the flat leaf slug, a collision would silently merge two categories' - * products together). - */ -function buildCategorySlugMap(categories: CategoryInfo[]): Map { - const slugMap = new Map() - const ownerOfSlug = new Map() - +function warnSlugCollisions(categories: CategoryInfo[]): void { + 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) { + const owner = ownerOfSlug.get(cat.slug) + if (owner && owner.id !== 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.`, + `[bytepay] Category slug collision: "${cat.name}" (id ${cat.id}) and "${owner.name}" (id ${owner.id}) both slugify to "${cat.slug}" — products in one category may shadow the other on the site.`, ) } else { - ownerOfSlug.set(slug, cat.id) + ownerOfSlug.set(cat.slug, cat) } } - - 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 ? (categorySlugMap.get(catRef.id) ?? "") : "" - - const planRefs = (product.relationships?.plans?.data ?? []) as JsonApiRelRef[] - const plans = planRefs - .map((ref): BillingPlan | null => { - const planRes = map.get(`plans:${ref.id}`) - if (!planRes) return null - const pa = planRes.attributes - - const priceRefs = (planRes.relationships?.prices?.data ?? []) as JsonApiRelRef[] - const prices = priceRefs - .map((pref): BillingPrice | null => { - const priceRes = map.get(`prices:${pref.id}`) - if (!priceRes) return null - const pra = priceRes.attributes - return { - price: parseFloat((pra.price as string) ?? "0"), - setupFee: parseFloat((pra.setup_fee as string) ?? "0"), - currencyCode: pra.currency_code as string, - } - }) - .filter((p): p is BillingPrice => p !== null) - - return { - id: ref.id, - name: (pa.name as string | null) ?? null, - type: pa.type as BillingPlan["type"], - billingPeriod: (pa.billing_period as number | null) ?? null, - billingUnit: (pa.billing_unit as string | null) ?? null, - sort: (pa.sort as number) ?? 0, - prices, - } - }) - .filter((p): p is BillingPlan => p !== null) +export async function fetchAllBillingProducts(): Promise { + const [{ products }, categories] = await Promise.all([getCachedCatalog(), getCategories()]) + warnSlugCollisions(categories) + const byId = new Map(categories.map((category) => [category.id, category])) + return products.map((product): BillingProduct => { + const category = product.category_id != null ? byId.get(String(product.category_id)) : undefined return { - id: product.id, - name: attr.name as string, - slug: attr.slug as string, - description: (attr.description as string | null) ?? null, - image: (attr.image as string | null) ?? null, - stock: attr.stock === undefined ? null : (attr.stock as number | null), - hidden: Boolean(attr.hidden), - sort: attr.sort === null || attr.sort === undefined ? null : (attr.sort as number), - categorySlug, - plans, + id: String(product.id), + name: product.name, + slug: product.slug, + description: product.description, + stock: product.stock, + hidden: false, + sort: product.sort, + categorySlug: category?.slug ?? "", + storeCategorySlug: category?.storeSlug ?? "", + plans: product.plans.map((plan) => ({ + id: String(plan.id), + name: plan.name, + type: plan.type, + billingPeriod: plan.billing_period, + billingUnit: plan.billing_unit, + sort: plan.sort ?? 0, + prices: plan.prices.map((price) => ({ + price: price.price, + setupFee: price.setup_fee, + currencyCode: price.currency_code, + })), + })), } }) } -async function fetchPage( - page: number, - categorySlugMap: Map, -): Promise<{ products: BillingProduct[]; hasNext: boolean }> { - 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 fetchWithRetry(url.toString(), token) - - if (!res.ok) { - throw new Error(`Billing API error ${res.status}: ${res.statusText}`) - } - - const json: JsonApiResponse = await res.json() - const map = buildIncludedMap(json.included) - - return { - products: normalisePage(json.data, map, categorySlugMap), - hasNext: Boolean(json.links?.next), - } -} - -// ─── Public API ──────────────────────────────────────────────────────────── - -/** 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, categorySlugMap) - all.push(...products) - if (!hasNext) break - page++ - } - - return all -} - -/** - * Return products belonging to a category, identified by the slugified - * category name (e.g. "minecraft", "rust", "amd-vps"). - * Ordered by ascending sort index; null-sort products go last, then by id. - */ export function getProductsByCategory( products: BillingProduct[], categorySlug: string, @@ -412,55 +268,38 @@ export function getProductsByCategory( }) } -/** Resolve the GBP monthly price from the product's recurring plan. */ -export function getGbpPrice(product: BillingProduct): number { - const plan = +function monthlyPlan(product: BillingProduct): BillingPlan | undefined { + return ( product.plans.find((p) => p.type === "recurring" && p.billingPeriod === 1 && p.billingUnit === "month") ?? product.plans.find((p) => p.type === "recurring") ?? product.plans[0] - if (!plan) return 0 - return plan.prices.find((p) => p.currencyCode === "GBP")?.price ?? 0 + ) +} + +export function getGbpPrice(product: BillingProduct): number { + return monthlyPlan(product)?.prices.find((p) => p.currencyCode === "GBP")?.price ?? 0 } -/** Return a map of currency code → monthly price from the product's recurring plan. */ export function getPricesMap(product: BillingProduct): Record { - const plan = - product.plans.find((p) => p.type === "recurring" && p.billingPeriod === 1 && p.billingUnit === "month") - ?? product.plans.find((p) => p.type === "recurring") - ?? product.plans[0] - if (!plan) return {} const map: Record = {} - for (const price of plan.prices) { + for (const price of monthlyPlan(product)?.prices ?? []) { map[price.currencyCode] = price.price } return map } -/** Resolve the GBP one-time setup fee from the product's recurring plan (0 if none). */ export function getSetupFeeGBP(product: BillingProduct): number { - const plan = - product.plans.find((p) => p.type === "recurring" && p.billingPeriod === 1 && p.billingUnit === "month") - ?? product.plans.find((p) => p.type === "recurring") - ?? product.plans[0] - if (!plan) return 0 - return plan.prices.find((p) => p.currencyCode === "GBP")?.setupFee ?? 0 + return monthlyPlan(product)?.prices.find((p) => p.currencyCode === "GBP")?.setupFee ?? 0 } -/** Return a map of currency code → one-time setup fee from the product's recurring plan. */ export function getSetupFeesMap(product: BillingProduct): Record { - const plan = - product.plans.find((p) => p.type === "recurring" && p.billingPeriod === 1 && p.billingUnit === "month") - ?? product.plans.find((p) => p.type === "recurring") - ?? product.plans[0] - if (!plan) return {} const map: Record = {} - for (const price of plan.prices) { + for (const price of monthlyPlan(product)?.prices ?? []) { map[price.currencyCode] = price.setupFee } return map } -/** Map billing stock to the website StockStatus type. */ export function getStockStatus( product: BillingProduct, ): "in_stock" | "out_of_stock" | "coming_soon" { @@ -468,7 +307,6 @@ export function getStockStatus( return "in_stock" } -/** Build the billing portal order URL for a product. */ -export function getBillingUrl(categorySlug: string, productSlug: string): string { - return `https://billing.nodebyte.host/products/${categorySlug}/${productSlug}` +export function getBillingUrl(product: BillingProduct): string { + return `${BILLING_URL}/store/${product.storeCategorySlug}/${product.slug}` } diff --git a/packages/core/lib/spec-parser.ts b/packages/core/lib/spec-parser.ts index 1a13696..8ffb862 100644 --- a/packages/core/lib/spec-parser.ts +++ b/packages/core/lib/spec-parser.ts @@ -123,8 +123,7 @@ export function parseDescriptionSpecs(html: string | null): ParsedSpecs { } } if (!cpu) { - // "2 vCPU", "4 vCPUs" — common cloud/VPS-style core count phrasing - const m = text.match(/\b(\d+)\s*vCPUs?\b/i) + const m = text.match(/\b(\d+)\s*v(?:CPU|Core)s?\b/i) if (m) cpu = parseInt(m[1]) } @@ -334,3 +333,58 @@ export function parseObjectStorageSpecs(html: string | null): ObjectStorageSpecs return result } + +export interface BotSpecs { + worksWith?: string + ramMB?: number + vcpu?: number + storageGB?: number + backups?: number + features: string[] +} + +export function parseBotSpecs(html: string | null): BotSpecs { + const result: BotSpecs = { features: [] } + if (!html) return result + + for (const line of bulletLines(html)) { + const worksWith = line.match(/^works with\s*:?\s*(.+)$/i) + if (worksWith) { + result.worksWith = worksWith[1].trim() + continue + } + + const ram = line.match(/([\d.]+)\s*(MB|GB)\s*(?:of\s+)?RAM\b/i) + if (ram && result.ramMB == null) { + const amount = parseFloat(ram[1]) + result.ramMB = /gb/i.test(ram[2]) ? Math.round(amount * 1024) : Math.round(amount) + continue + } + + const cpu = line.match(/([\d.]+)\s*v(?:CPU|Core)s?\b/i) + if (cpu && result.vcpu == null) { + result.vcpu = parseFloat(cpu[1]) + continue + } + + if (/storage|disk|ssd|nvme/i.test(line) && result.storageGB == null) { + const size = line.match(/([\d.]+)\s*(MB|GB|TB)\b/i) + if (size) { + const amount = parseFloat(size[1]) + const unit = size[2].toUpperCase() + result.storageGB = unit === "TB" ? amount * 1024 : unit === "MB" ? amount / 1024 : amount + continue + } + } + + const backups = line.match(/(\d+)\s*Backups?\b/i) + if (backups && result.backups == null) { + result.backups = parseInt(backups[1]) + continue + } + + result.features.push(line) + } + + return result +} diff --git a/packages/core/lib/status.ts b/packages/core/lib/status.ts index 98ace83..1e6d294 100644 --- a/packages/core/lib/status.ts +++ b/packages/core/lib/status.ts @@ -129,23 +129,6 @@ export function findMonitor(snapshot: StatusSnapshot | null, name: string): Stat return snapshot.monitors.find((m) => m.name.trim().toLowerCase() === target) ?? null } -/** - * 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) => { - const leafGroup = m.group_name?.split(" > ").pop()?.trim() - return leafGroup?.toLowerCase().endsWith("nodes") ?? false - }) - .map((m) => m.name) -} - /** 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 { if (monitor.last_latency_ms == null) return null diff --git a/packages/core/products/billing-service.ts b/packages/core/products/billing-service.ts index 9b48ea7..bd080fa 100644 --- a/packages/core/products/billing-service.ts +++ b/packages/core/products/billing-service.ts @@ -3,6 +3,7 @@ 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 type { DiscordBotPlanSpec } from "@/packages/core/types/servers/discord-bot" import { fetchAllBillingProducts, getProductsByCategory, @@ -13,22 +14,16 @@ import { getStockStatus, getBillingUrl, } from "@/packages/core/lib/bytepay" -import { parseDescriptionSpecs, parseProductName, parseObjectStorageSpecs, formatStorageType } from "@/packages/core/lib/spec-parser" +import { parseBotSpecs, 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" const getCachedProducts = unstable_cache( fetchAllBillingProducts, - ["billing-products"], + ["billing-public-products"], { 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, @@ -39,11 +34,6 @@ function warnDroppedProduct( ) } -/** - * Returns live-priced game plans for the given billing category slug. - * RAM and storage are parsed from the billing panel description automatically. - * Plans whose descriptions don't contain the required specs are skipped. - */ export async function getGamePlans(categorySlug: string): Promise { const all = await getCachedProducts() return getProductsByCategory(all, categorySlug).flatMap((product) => { @@ -77,17 +67,12 @@ export async function getGamePlans(categorySlug: string): Promise { const all = await getCachedProducts() return getProductsByCategory(all, categorySlug).flatMap((product) => { @@ -119,18 +104,12 @@ export async function getDedicatedPlans(categorySlug: string): Promise { const all = await getCachedProducts() return getProductsByCategory(all, categorySlug).flatMap((product) => { @@ -171,18 +150,12 @@ export async function getVpsPlans(categorySlug: string): Promise setupFeeGBP: getSetupFeeGBP(product), setupFees: getSetupFeesMap(product), stock: getStockStatus(product), - url: getBillingUrl(categorySlug, product.slug), + url: getBillingUrl(product), } satisfies VpsPlanSpec, ] }) } -/** - * 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) => { @@ -211,8 +184,40 @@ export async function getObjectStoragePlans(categorySlug: string): Promise { + const all = await getCachedProducts() + return getProductsByCategory(all, categorySlug).flatMap((product) => { + const parsed = parseBotSpecs(product.description) + + if (!parsed.ramMB) { + warnDroppedProduct(product, categorySlug, ["ramMB"]) + return [] + } + + return [ + { + id: product.slug, + name: product.name, + worksWith: parsed.worksWith, + ramMB: parsed.ramMB, + vcpu: parsed.vcpu, + storageGB: parsed.storageGB, + backups: parsed.backups, + 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(product), + } satisfies DiscordBotPlanSpec, + ] + }) +} diff --git a/packages/core/types/servers/discord-bot.ts b/packages/core/types/servers/discord-bot.ts new file mode 100644 index 0000000..68eda3e --- /dev/null +++ b/packages/core/types/servers/discord-bot.ts @@ -0,0 +1,17 @@ +export interface DiscordBotPlanSpec { + id: string + name: string + worksWith?: string + ramMB: number + vcpu?: number + storageGB?: number + backups?: number + features: string[] + priceGBP: number + prices: Record + setupFeeGBP: number + setupFees: Record + popular?: boolean + url: string + stock: "in_stock" | "out_of_stock" | "coming_soon" +} diff --git a/packages/ui/components/Layouts/Contact/contact.tsx b/packages/ui/components/Layouts/Contact/contact.tsx index fdeb1c3..3a55135 100644 --- a/packages/ui/components/Layouts/Contact/contact.tsx +++ b/packages/ui/components/Layouts/Contact/contact.tsx @@ -58,7 +58,7 @@ export function Contact() { title: t("contact.ticket.title"), description: t("contact.ticket.description"), icon: Headphones, - href: "https://billing.nodebyte.host/tickets/create", + href: LINKS.billing.submitTicket, cta: t("contact.ticket.button"), color: "bg-accent/10 text-accent", hoverColor: "hover:bg-accent hover:text-accent-foreground", diff --git a/packages/ui/components/Layouts/Dedicated/dedicated-hub.tsx b/packages/ui/components/Layouts/Dedicated/dedicated-hub.tsx index 4a959c6..a92fcb6 100644 --- a/packages/ui/components/Layouts/Dedicated/dedicated-hub.tsx +++ b/packages/ui/components/Layouts/Dedicated/dedicated-hub.tsx @@ -13,7 +13,6 @@ import { X, ArrowRight, ChevronDown, - Star, Lock, PackageX, } from "lucide-react" @@ -27,7 +26,8 @@ import { SelectTrigger, SelectValue, } from "@/packages/ui/components/ui/select" -import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/packages/ui/components/ui/collapsible" +import { Collapsible, CollapsibleContent } from "@/packages/ui/components/ui/collapsible" +import { PlanCard as PlanCardShell } from "@/packages/ui/components/ui/plan-card" 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" @@ -47,7 +47,6 @@ function formatBandwidth(plan: DedicatedPlanSpec): string { return `${plan.bandwidth.amount} ${plan.bandwidth.unit}` } -/** Sort by a possibly-undefined numeric field — plans missing it (not listed in their description) always sort last, regardless of direction. */ function compareNullable(a: number | undefined, b: number | undefined, dir: 1 | -1): number { if (a == null && b == null) return 0 if (a == null) return 1 @@ -63,49 +62,18 @@ function formatStorage(plan: DedicatedPlanSpec): string { return "—" } -// ─── PlanCard ───────────────────────────────────────────────────────────────── - function PlanCard({ plan }: { plan: DedicatedPlanSpec }) { - const outOfStock = plan.stock === "out_of_stock" - const [infoOpen, setInfoOpen] = useState(false) - return ( -
- {plan.popular && ( -
- - Most Popular -
- )} - -
- {/* Header row */} -
-
-

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

- {plan.cpuModel && ( -

{plan.cpuModel}

- )} -
-
- -

/month

-
-
- - {/* Hardware badge */} -
+ {plan.hardware && ( Bare Metal - {outOfStock && ( - - Out of Stock - - )} -
- -
- - {/* Specs grid */} -
-
- - - {plan.cores != null ? `${plan.cores} Cores` : plan.cpuModel ?? "Dedicated CPU"} - -
-
- - {plan.ramGB} GB RAM -
-
- - {formatStorage(plan)} -
-
- - {formatBandwidth(plan)} -
-
- - {/* Features */} -
- {[ - { icon: Shield, text: "DDoS Protection" }, - { icon: Lock, text: "IPMI Access" }, - { icon: Zap, text: "Full Dedicated" }, - ].map(({ icon: Icon, text }) => ( - - - {text} - - ))} -
- - {/* Server info */} - - - - - - 0 ? : "None"} - /> - {plan.uplink && ( - - )} - {plan.location && } - {plan.databases != null && } - {plan.backups && } - - - - -
-
+ + } + specs={[ + { icon: Cpu, value: plan.cores != null ? `${plan.cores}` : "Dedicated", label: "CPU Cores" }, + { icon: Server, value: `${plan.ramGB} GB`, label: "RAM" }, + { icon: HardDrive, value: formatStorage(plan), label: "Storage" }, + { icon: Network, value: formatBandwidth(plan), label: "Bandwidth" }, + ]} + features={[ + { icon: Shield, text: "DDoS Protection" }, + { icon: Lock, text: "IPMI Access" }, + { icon: Zap, text: "Full Dedicated" }, + ]} + info={ + <> + 0 ? : "None"} + /> + {plan.uplink && } + {plan.location && } + {plan.databases != null && } + {plan.backups && } + + } + /> ) } -// ─── Hub ───────────────────────────────────────────────────────────────────── - interface DedicatedHubProps { plans: DedicatedPlanSpec[] } @@ -226,7 +131,6 @@ export function DedicatedHub({ plans }: DedicatedHubProps) { const [sort, setSort] = useState("price-asc") const [filtersOpen, setFiltersOpen] = useState(false) - // Derived from live plan data, not the server name — reliable regardless of naming. const availableRam = Array.from(new Set(plans.map((p) => p.ramGB))).sort((a, b) => a - b) const filtered = (() => { @@ -273,7 +177,6 @@ export function DedicatedHub({ plans }: DedicatedHubProps) { return (
- {/* Background */}
- {/* ── Hero ─────────────────────────────────────────────────────────── */}
@@ -299,7 +201,6 @@ export function DedicatedHub({ plans }: DedicatedHubProps) { Physical bare-metal servers with zero resource contention. Fully dedicated CPU cores, enterprise storage, and IPMI out-of-band access on every plan.

- {/* Key differentiators */}
{[ { icon: Cpu, text: "100% Dedicated Cores" }, @@ -318,7 +219,6 @@ export function DedicatedHub({ plans }: DedicatedHubProps) {
- {/* ── Filter bar ───────────────────────────────────────────────────── */}
@@ -414,7 +314,6 @@ export function DedicatedHub({ plans }: DedicatedHubProps) {
- {/* ── Plan grid ────────────────────────────────────────────────────── */}
{plans.length === 0 ? (
@@ -434,7 +333,7 @@ export function DedicatedHub({ plans }: DedicatedHubProps) {

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

-
+
{filtered.map((plan) => ( ))} @@ -443,7 +342,6 @@ export function DedicatedHub({ plans }: DedicatedHubProps) { )}
- {/* ── Custom / Enterprise CTA ───────────────────────────────────────── */}
diff --git a/packages/ui/components/Layouts/DiscordBots/discord-bots-hub.tsx b/packages/ui/components/Layouts/DiscordBots/discord-bots-hub.tsx new file mode 100644 index 0000000..3a0aaa4 --- /dev/null +++ b/packages/ui/components/Layouts/DiscordBots/discord-bots-hub.tsx @@ -0,0 +1,145 @@ +"use client" + +import { ArrowRight, Bot, Code2, Cpu, DatabaseBackup, HardDrive, MemoryStick, PackageX, Shield, Zap } from "lucide-react" +import Link from "next/link" +import { Button } from "@/packages/ui/components/ui/button" +import { PlanCard as PlanCardShell, type PlanSpec } from "@/packages/ui/components/ui/plan-card" +import { PlanInfoRow } from "@/packages/ui/components/ui/plan-info-row" +import { Price } from "@/packages/ui/components/ui/price" +import type { DiscordBotPlanSpec } from "@/packages/core/types/servers/discord-bot" + +function formatRam(mb: number): string { + return mb >= 1024 ? `${Math.round((mb / 1024) * 100) / 100} GB` : `${mb} MB` +} + +function formatStorage(gb: number): string { + return gb < 1 ? `${Math.round(gb * 1024)} MB` : `${gb} GB` +} + +function PlanCard({ plan }: { plan: DiscordBotPlanSpec }) { + const specs: PlanSpec[] = [{ icon: MemoryStick, value: formatRam(plan.ramMB), label: "RAM" }] + if (plan.vcpu != null) specs.push({ icon: Cpu, value: `${plan.vcpu}`, label: plan.vcpu === 1 ? "vCPU" : "vCPUs" }) + if (plan.storageGB != null) specs.push({ icon: HardDrive, value: formatStorage(plan.storageGB), label: "SSD Storage" }) + if (plan.backups != null) specs.push({ icon: DatabaseBackup, value: `${plan.backups}`, label: plan.backups === 1 ? "Backup" : "Backups" }) + + return ( + 0 && ( +
    + {plan.features.map((feature) => ( +
  • + {feature} +
  • + ))} +
+ ) + } + infoLabel="Plan Info" + info={ + 0 ? : "None"} + /> + } + /> + ) +} + +export function DiscordBotsHub({ plans }: { plans: DiscordBotPlanSpec[] }) { + const sorted = [...plans].sort((a, b) => a.priceGBP - b.priceGBP) + + return ( +
+
+
+
+ +
+
+
+ + Discord Bot Hosting +
+

+ Keep Your Bot{" "} + + Online 24/7 + +

+

+ Affordable, always-on hosting for Discord bots. Upload your code, pick your runtime, and let it run, with backups included. +

+
+ {[ + { icon: Code2, text: "Node.js, Python, Java, Go & Rust" }, + { icon: Zap, text: "Instant Setup" }, + { icon: Shield, text: "DDoS Protected" }, + { icon: DatabaseBackup, text: "Backups Included" }, + ].map(({ icon: Icon, text }) => ( +
+ + {text} +
+ ))} +
+
+ +
+ {sorted.length === 0 ? ( +
+ +

No bot hosting plans in stock right now

+

Check back soon, or get in touch and we'll help you find a fit.

+
+ ) : ( +
+ {sorted.map((plan) => ( + + ))} +
+ )} +
+ +
+
+
+
+

Need more power for a bigger bot?

+

+ Large or sharded bots run great on a VPS. Get in touch and we'll help you size it. +

+
+
+ + +
+
+
+
+
+
+ ) +} diff --git a/packages/ui/components/Layouts/Games/game-hub.tsx b/packages/ui/components/Layouts/Games/game-hub.tsx index 6f67ab8..6762fbd 100644 --- a/packages/ui/components/Layouts/Games/game-hub.tsx +++ b/packages/ui/components/Layouts/Games/game-hub.tsx @@ -11,8 +11,6 @@ import { Zap, X, ArrowRight, - ChevronDown, - Star, Gamepad2, PackageX, } from "lucide-react" @@ -25,10 +23,9 @@ import { SelectTrigger, SelectValue, } from "@/packages/ui/components/ui/select" -import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/packages/ui/components/ui/collapsible" +import { PlanCard as PlanCardShell } from "@/packages/ui/components/ui/plan-card" import { PlanInfoRow } from "@/packages/ui/components/ui/plan-info-row" import Link from "next/link" -import { cn } from "@/lib/utils" import type { GamePlanSpec } from "@/packages/core/types/servers/game" import { Price } from "@/packages/ui/components/ui/price" import { SUPPORTED_GAMES } from "@/packages/core/constants/supported-games" @@ -40,137 +37,43 @@ function formatBandwidth(plan: GamePlanSpec): string { return `${plan.bandwidth.amount} ${plan.bandwidth.unit}` } -// ─── PlanCard ───────────────────────────────────────────────────────────────── - function PlanCard({ plan }: { plan: GamePlanSpec }) { - const outOfStock = plan.stock === "out_of_stock" - const [infoOpen, setInfoOpen] = useState(false) - return ( -
- {plan.popular && ( -
- - Most Popular -
- )} - -
- {/* Header row */} -
-
-

- {plan.name ?? plan.id} -

- {plan.description && ( -

{plan.description}

- )} -
-
- -

/month

-
-
- - {outOfStock && ( -
- - Out of Stock - -
- )} - -
- - {/* Specs grid */} -
-
- - - {plan.ramGB} GB {plan.ramType ? plan.ramType : ""} RAM - -
-
- - {plan.storageGB} GB {plan.storageLabel ?? "Storage"} -
-
- - {formatBandwidth(plan)} -
-
- - {/* Included features */} -
- {[ - { icon: Shield, text: "DDoS Protection" }, - { icon: Zap, text: "Instant Setup" }, - { icon: Server, text: "Control Panel" }, - ].map(({ icon: Icon, text }) => ( - - - {text} - - ))} -
- - {/* Server info */} - - - - - - 0 ? : "None"} - /> - {plan.cpuModel && } - {plan.uplink && ( - - )} - {plan.location && } - {plan.databases != null && } - {plan.backups && } - - - - -
-
+ + 0 ? : "None"} + /> + {plan.cpuModel && } + {plan.uplink && } + {plan.location && } + {plan.databases != null && } + {plan.backups && } + + } + /> ) } -// ─── Hub ───────────────────────────────────────────────────────────────────── - interface GameHubProps { plans: GamePlanSpec[] } @@ -203,7 +106,6 @@ export function GameHub({ plans }: GameHubProps) { return (
- {/* Background */}
- {/* ── Hero ─────────────────────────────────────────────────────────── */}
@@ -240,7 +141,6 @@ export function GameHub({ plans }: GameHubProps) {
- {/* ── Filter bar ───────────────────────────────────────────────────── */}
@@ -271,7 +171,6 @@ export function GameHub({ plans }: GameHubProps) {
- {/* ── Plan grid ────────────────────────────────────────────────────── */}
{plans.length === 0 ? (
@@ -291,7 +190,7 @@ export function GameHub({ plans }: GameHubProps) {

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

-
+
{filtered.map((plan) => ( ))} @@ -300,7 +199,6 @@ export function GameHub({ plans }: GameHubProps) { )}
- {/* ── Custom / Enterprise CTA ───────────────────────────────────────── */}
diff --git a/packages/ui/components/Layouts/Home/services.tsx b/packages/ui/components/Layouts/Home/services.tsx index ad4aab1..95bafb0 100644 --- a/packages/ui/components/Layouts/Home/services.tsx +++ b/packages/ui/components/Layouts/Home/services.tsx @@ -10,7 +10,6 @@ import { getCategoryHub } from "@/packages/core/lib/bytepay" 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 — 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 }[]>, @@ -48,7 +47,6 @@ export async function Services() {
- {/* Header */}
@@ -65,15 +63,16 @@ export async function Services() {

- {/* Service Hub Cards */}
{activeServices.map((service) => ( @@ -87,35 +86,30 @@ export async function Services() { "flex flex-col", )} > - {/* Card header — gradient visual */}
- {/* Large faded background icon */} - {/* Centred icon badge */}
- {/* Title + starting price */} -
-

{service.name}

+
+

{service.name}

{t("servicesHome.startingFrom")} /mo
-

{service.description}

+

{service.description}

- {/* Highlights */}
    - {service.highlights.map((highlight, i) => ( + {service.highlights.map((highlight) => (
  • {highlight} @@ -134,7 +128,6 @@ export async function Services() { ))}
- {/* Bottom CTA */}

{t("servicesHome.customSolution")}{" "} diff --git a/packages/ui/components/Layouts/Nodes/nodes-client.tsx b/packages/ui/components/Layouts/Nodes/nodes-client.tsx deleted file mode 100644 index 0771fb6..0000000 --- a/packages/ui/components/Layouts/Nodes/nodes-client.tsx +++ /dev/null @@ -1,508 +0,0 @@ -"use client" - -import { useState, useEffect, useSyncExternalStore } from "react" -import { - Server, - MapPin, - Info, - Globe, - Network, - Zap, - Cpu, - Activity, - MemoryStick, -} from "lucide-react" -import { Card, CardContent, CardHeader, CardTitle } from "@/packages/ui/components/ui/card" -import { Badge } from "@/packages/ui/components/ui/badge" -import { Button } from "@/packages/ui/components/ui/button" -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "@/packages/ui/components/ui/accordion" -import { Alert, AlertDescription } from "@/packages/ui/components/ui/alert" -import type { PublicNode } from "@/packages/core/constants/node-types" -import { NODE_DISPLAY_OVERRIDES, LOCATION_MONITOR_MAP } from "@/packages/core/constants/status-mapping" -import { useNodeStatus } from "@/packages/core/hooks/use-node-status" -import type { StatusApiMonitor } from "@/app/api/status/route" -import { cn } from "@/lib/utils" -import Link from "next/link" -import { LINKS } from "@/packages/core/constants/links" - -interface ExtendedNode extends PublicNode { - cpu?: string - ramType?: string - uptime?: number -} - -/** Build the node list from names discovered live from the status page, filling in any known display extras. */ -function buildNodes(nodeNames: string[]): ExtendedNode[] { - return nodeNames.map((name, i) => { - const overrides = NODE_DISPLAY_OVERRIDES[name] ?? {} - return { - id: i + 1, - name, - locationCode: overrides.locationCode ?? "", - isMaintenanceMode: false, - cpu: overrides.cpu, - ramType: overrides.ramType, - } - }) -} - -// ─── Location data ─────────────────────────────────────────────────────────── - -interface DataCentreLocation { - id: string - city: string - area?: string // datacenter suburb / campus name - country: string - flag: string - region: "Europe" | "Americas" | "Asia-Pacific" - primary?: boolean -} - -const LOCATIONS: DataCentreLocation[] = [ - // Europe — United Kingdom - { id: "ncl", city: "Newcastle", country: "United Kingdom", flag: "🇬🇧", region: "Europe", primary: true }, - { id: "lon", city: "London", area: "Erith", country: "United Kingdom", flag: "🇬🇧", region: "Europe" }, - // Europe — France - { id: "bor", city: "Bordeaux", country: "France", flag: "🇫🇷", region: "Europe" }, - { id: "cro", city: "Croix", country: "France", flag: "🇫🇷", region: "Europe" }, - { id: "gra", city: "Gravelines", country: "France", flag: "🇫🇷", region: "Europe" }, - { id: "grn", city: "Grenoble", country: "France", flag: "🇫🇷", region: "Europe" }, - { id: "par", city: "Paris", country: "France", flag: "🇫🇷", region: "Europe" }, - { id: "rbx", city: "Roubaix", country: "France", flag: "🇫🇷", region: "Europe" }, - { id: "sbg", city: "Strasbourg", country: "France", flag: "🇫🇷", region: "Europe" }, - { id: "tur", city: "Tours", country: "France", flag: "🇫🇷", region: "Europe" }, - // Europe — Germany - { id: "fal", city: "Falkenstein", area: "Limburg", country: "Germany", flag: "🇩🇪", region: "Europe" }, - { id: "fra", city: "Frankfurt", country: "Germany", flag: "🇩🇪", region: "Europe" }, - // Europe — Finland - { id: "hel", city: "Helsinki", country: "Finland", flag: "🇫🇮", region: "Europe", primary: true }, - // Europe — Italy - { id: "mil", city: "Milan", country: "Italy", flag: "🇮🇹", region: "Europe" }, - // Europe — Poland - { id: "waw", city: "Warsaw", area: "Ożarów", country: "Poland", flag: "🇵🇱", region: "Europe" }, - // Americas — Canada - { id: "bhs", city: "Montréal", area: "Beauharnois", country: "Canada", flag: "🇨🇦", region: "Americas" }, - { id: "tor", city: "Toronto", area: "Cambridge", country: "Canada", flag: "🇨🇦", region: "Americas" }, - // 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" }, - { id: "mum", city: "Mumbai", country: "India", flag: "🇮🇳", region: "Asia-Pacific" }, -] - -// ─── Helpers ──────────────────────────────────────────────────────────────── - -function formatSize(mib: number): string { - if (mib >= 1024 * 1024) return `${(mib / 1024 / 1024).toFixed(1)} TB` - if (mib >= 1024) return `${(mib / 1024).toFixed(0)} GB` - 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) { - if (!map.has(loc.country)) map.set(loc.country, []) - map.get(loc.country)!.push(loc) - } - return Array.from(map.entries()).map(([country, locs]) => ({ - country, - flag: locs[0].flag, - locations: locs, - })) -} - - -function NodeCard({ node, live }: { node: ExtendedNode; live: StatusApiMonitor | null }) { - const state = resolveNodeState(node, live) - const uptime = live?.uptime30dPct ?? node.uptime - - return ( - -

- - -
-
-
- -
-
- {node.name} - {node.locationCode && ( -
- - {node.locationCode} -
- )} -
-
- - - {state.label} - -
-
- - - {node.cpu && ( -
- - CPU - - {node.cpu} -
- )} - {node.ramType && ( -
- - RAM Type - - {node.ramType} -
- )} - {live?.latency && ( -
- - Ping - - {live.latency.avg}ms avg -
- )} - {uptime !== undefined && uptime !== null && ( -
- - Uptime - - = 99.9 ? "text-green-400" : "text-amber-400")}> - {uptime.toFixed(1)}% - -
- )} -
- - ) -} - -// ─── LocationCountryRow ────────────────────────────────────────────────────── - -function LocationCountryRow({ - country, - flag, - locations, - findMonitor, -}: { - country: string - flag: string - locations: DataCentreLocation[] - findMonitor: (name: string) => StatusApiMonitor | null -}) { - return ( -
- {flag} -
-

{country}

-
- {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 && } - - ) - })} -
-
-
- ) -} - -// ─── Content ────────────────────────────────────────────────────────────────── - -const FAQS = [ - { - question: "Where can I deploy my server?", - answer: - `We offer hosting across ${LOCATIONS.length} data centre locations in Europe (UK, France, Germany, Finland, Italy, Poland), the Americas (Canada, United States), and Asia-Pacific (Singapore, Australia, India). All game server and VPS plans let you select your preferred location at checkout.`, - }, - { - question: "Which location is best for me?", - answer: - "Choose the location closest to you or the majority of your users. For UK and European customers, Newcastle, London, or Paris work well. For North America, Montréal (Canada East) or Washington DC (US East) are great options. Singapore or Mumbai cover South and Southeast Asia, and Sydney covers Australia and Oceania.", - }, - { - question: "Can I change my server location after ordering?", - answer: - "Location changes after ordering require a fresh deployment. Open a support ticket and our team will help migrate your server to a new node in your preferred location.", - }, - { - question: "What does \u2018Maintenance\u2019 mean for a node?", - answer: - "When a node is in maintenance mode it is temporarily unavailable for new deployments while we perform scheduled upgrades or hardware work. Existing servers may be paused or migrated during this time. Affected customers are always notified in advance.", - }, - { - question: "How do you choose which node my server runs on?", - answer: - "After you select a data centre location at checkout, we automatically assign your server to the best available node in that location based on current capacity and load. You cannot select a specific node directly.", - }, - { - question: "Is DDoS protection included?", - answer: - "Yes. All services include DDoS protection as standard. Our mitigation automatically detects and absorbs attacks to keep your server online and running smoothly.", - }, -] - -// ─── Main export ───────────────────────────────────────────────────────────── - -interface NodesClientProps { - /** Node monitor names discovered live from the status page's "Nodes" group. */ - nodeNames: string[] -} - -export function NodesClient({ nodeNames }: NodesClientProps) { - const nodes = buildNodes(nodeNames) - const { findMonitor } = useNodeStatus() - const nodeLiveStates = nodes.map((node) => findMonitor(node.name)) - const onlineCount = nodeLiveStates.filter( - (live, i) => (live ? live.status === "up" : !nodes[i].isMaintenanceMode), - ).length - const maintenanceCount = nodes.length - onlineCount - const hydrated = useSyncExternalStore(() => () => {}, () => true, () => false) - - return ( -
- {/* ── Background ─────────────────────────────────────────────────────── */} -
-
-
- -
- - {/* ── Hero ───────────────────────────────────────────────────────────── */} -
-
- - Hosting Infrastructure -
-

- Nodes &{" "} - - Locations - -

-

- Our active server nodes and every data centre location available at checkout. -

- - {/* Stats */} -
- {[ - { 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: "10+" }, - ].map((stat) => ( -
-
{stat.value}
-
{stat.label}
-
- ))} -
-
- - {/* ── Node grid */} -
-
-
-

- - Active Nodes -

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

Node status is temporarily unavailable

-

Check the status page directly for the latest info.

-
- )} -
- - {/* ── Locations */} -
-
-
- - Available Locations -
-

- Deploy Anywhere{" "} - - Globally - -

-

- {LOCATIONS.length} data centre locations across 3 regions. Select your preferred location at checkout. -

-
- - {(["Europe", "Americas", "Asia-Pacific"] as const).map((region) => { - const regionLocs = LOCATIONS.filter((l) => l.region === region) - const countryGroups = groupByCountry(regionLocs) - return ( -
-
-
-

- {region} · {regionLocs.length} location{regionLocs.length !== 1 && "s"} -

-
-
-
- {countryGroups.map(({ country, flag, locations }) => ( - - ))} -
-
- ) - })} -
- - {/* ── FAQ ────────────────────────────────────────────────────────────── */} -
-
-
- - FAQ -
-

- Common{" "} - - Questions - -

-

- Everything about our nodes and choosing the right location for your service. -

-
- - {hydrated ? ( - - {FAQS.map((faq, i) => ( - - - {faq.question} - - - {faq.answer} - - - ))} - - ) : ( -
- {FAQS.map((faq, i) => ( -
- ))} -
- )} -
- - {/* ── CTA */} -
-
- - Get Started -
-

- Ready to deploy? Browse our plans and pick your preferred location at checkout. -

-
- - - - -
-
- -
-
- ) -} diff --git a/packages/ui/components/Layouts/ObjectStorage/object-storage-hub.tsx b/packages/ui/components/Layouts/ObjectStorage/object-storage-hub.tsx index ded96f4..df27917 100644 --- a/packages/ui/components/Layouts/ObjectStorage/object-storage-hub.tsx +++ b/packages/ui/components/Layouts/ObjectStorage/object-storage-hub.tsx @@ -14,7 +14,6 @@ import { X, ArrowRight, ChevronDown, - Star, Check, PackageX, } from "lucide-react" @@ -28,7 +27,8 @@ import { SelectTrigger, SelectValue, } from "@/packages/ui/components/ui/select" -import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/packages/ui/components/ui/collapsible" +import { Collapsible, CollapsibleContent } from "@/packages/ui/components/ui/collapsible" +import { PlanCard as PlanCardShell, type PlanSpec } from "@/packages/ui/components/ui/plan-card" 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" @@ -44,140 +44,47 @@ function formatStorage(gb: number): string { return `${tb} TB` } -// ─── PlanCard ───────────────────────────────────────────────────────────────── - function PlanCard({ plan }: { plan: ObjectStoragePlanSpec }) { - const outOfStock = plan.stock === "out_of_stock" - const [infoOpen, setInfoOpen] = useState(false) + const specs: PlanSpec[] = [ + { icon: HardDrive, value: formatStorage(plan.storageGB), label: plan.storageLabel ?? "Storage" }, + ] + if (plan.accessKeys) specs.push({ icon: Key, value: plan.accessKeys, label: "Access Keys" }) + if (plan.egress) specs.push({ icon: ArrowLeftRight, value: plan.egress, label: "Egress" }) + if (plan.apiRequests) specs.push({ icon: Zap, value: plan.apiRequests, label: "API Requests" }) + if (plan.archivePolicy) specs.push({ icon: Archive, value: plan.archivePolicy, label: "Auto-Archive" }) 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 && ( - - - - - - } /> - - - )} - - -
-
+ 0 && ( +
    + {plan.features.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+ ) + } + info={ + plan.setupFeeGBP > 0 && ( + } /> + ) + } + /> ) } -// ─── Hub ───────────────────────────────────────────────────────────────────── - interface ObjectStorageHubProps { plans: ObjectStoragePlanSpec[] } @@ -228,7 +135,6 @@ export function ObjectStorageHub({ plans }: ObjectStorageHubProps) { return (
- {/* Background */}
- {/* ── Hero ─────────────────────────────────────────────────────────── */}
@@ -254,7 +159,6 @@ export function ObjectStorageHub({ plans }: ObjectStorageHubProps) { 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" }, @@ -273,7 +177,6 @@ export function ObjectStorageHub({ plans }: ObjectStorageHubProps) {
- {/* ── Filter bar ───────────────────────────────────────────────────── */}
@@ -357,7 +260,6 @@ export function ObjectStorageHub({ plans }: ObjectStorageHubProps) {
- {/* ── Plan grid ────────────────────────────────────────────────────── */}
{plans.length === 0 ? (
@@ -377,7 +279,7 @@ export function ObjectStorageHub({ plans }: ObjectStorageHubProps) {

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

-
+
{filtered.map((plan) => ( ))} @@ -386,7 +288,6 @@ export function ObjectStorageHub({ plans }: ObjectStorageHubProps) { )}
- {/* ── Custom / Enterprise CTA ───────────────────────────────────────── */}
diff --git a/packages/ui/components/Layouts/VPS/vps-hub.tsx b/packages/ui/components/Layouts/VPS/vps-hub.tsx index be328f5..3657a38 100644 --- a/packages/ui/components/Layouts/VPS/vps-hub.tsx +++ b/packages/ui/components/Layouts/VPS/vps-hub.tsx @@ -13,7 +13,6 @@ import { X, ArrowRight, ChevronDown, - Star, PackageX, } from "lucide-react" import { Button } from "@/packages/ui/components/ui/button" @@ -26,7 +25,8 @@ import { SelectTrigger, SelectValue, } from "@/packages/ui/components/ui/select" -import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/packages/ui/components/ui/collapsible" +import { Collapsible, CollapsibleContent } from "@/packages/ui/components/ui/collapsible" +import { PlanCard as PlanCardShell } from "@/packages/ui/components/ui/plan-card" 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" @@ -62,8 +62,6 @@ const LINEUP_META = { }, } as const -// ─── Series metadata ────────────────────────────────────────────────────────── - const SERIES_META: Record = { RG1: { label: "RG1", fullName: "Ryzen 1000 Series", chip: "1700X", brand: "amd" }, RG3: { label: "RG3", fullName: "Ryzen 5000 Series", chip: "5900X", brand: "amd" }, @@ -84,169 +82,75 @@ type SortKey = | "storage-asc" | "storage-desc" | "cpu-asc" | "cpu-desc" -// ─── Helpers ───────────────────────────────────────────────────────────────── - function formatBandwidth(plan: VpsPlanSpec): string { if (!plan.bandwidth) return "Unmetered" return `${plan.bandwidth.amount} ${plan.bandwidth.unit}` } -// ─── PlanCard ───────────────────────────────────────────────────────────────── - function PlanCard({ plan }: { plan: VpsPlanSpec }) { const lineup = plan.lineup ? LINEUP_META[plan.lineup] ?? null : null const series = plan.series ? SERIES_META[plan.series] ?? null : null - const outOfStock = plan.stock === "out_of_stock" - const [infoOpen, setInfoOpen] = useState(false) return ( -
- {/* Popular ribbon */} - {plan.popular && ( -
- - Most Popular -
- )} - -
- {/* Header row */} -
-
-

- {plan.sku ?? plan.id} -

- {plan.cpuModel && ( -

{plan.cpuModel}

+ + {lineup && ( + + + {lineup.label} + )} -
-
- -

/month

-
-
- - {/* Badges */} -
- {lineup && ( - - - {lineup.label} - - )} - {series && ( - - {series.label} · {series.chip} - - )} - {outOfStock && ( - - Out of Stock - - )} -
- - {/* Divider */} -
- - {/* Specs grid */} -
-
- - {plan.cpu} vCPU -
-
- - {plan.ramGB} GB -
-
- - {plan.storageGB} GB -
-
- - {formatBandwidth(plan)} -
-
- - {/* Included features */} -
- {[ - { icon: Shield, text: "DDoS Protection" }, - { icon: Zap, text: "KVM" }, - { icon: Server, text: "Full Root" }, - ].map(({ icon: Icon, text }) => ( - - - {text} - - ))} -
- - {/* Server info */} - - - - - + {series && ( + + {series.label} · {series.chip} + + )} + + ) + } + specs={[ + { icon: Cpu, value: `${plan.cpu}`, label: "vCPU" }, + { icon: Server, value: `${plan.ramGB} GB`, label: "RAM" }, + { icon: HardDrive, value: `${plan.storageGB} GB`, label: "Storage" }, + { icon: Network, value: formatBandwidth(plan), label: "Bandwidth" }, + ]} + features={[ + { icon: Shield, text: "DDoS Protection" }, + { icon: Zap, text: "KVM" }, + { icon: Server, text: "Full Root" }, + ]} + info={ + <> + 0 ? : "None"} + /> + {series && } + {plan.uplink && } + {plan.ddos && ( 0 ? : "None"} + label="DDoS Layers" + value={`L${plan.ddos.layers.join("/")}${plan.ddos.autoOn ? " · Always-on" : ""}`} /> - {series && } - {plan.uplink && ( - - )} - {plan.ddos && ( - - )} - {plan.location && } - {plan.databases != null && } - {plan.backups && } - - - - {/* CTA */} - -
-
+ {plan.location && } + {plan.databases != null && } + {plan.backups && } + + } + /> ) } -// ─── Hub ───────────────────────────────────────────────────────────────────── - interface VpsHubProps { plans: VpsPlanSpec[] } @@ -262,9 +166,6 @@ export function VpsHub({ plans }: VpsHubProps) { const [sort, setSort] = useState("price-asc") const [filtersOpen, setFiltersOpen] = useState(false) - // Derive which lineups/series/RAM tiers actually exist in the plan list — - // reliable even for plans whose names don't follow the LINEUP-SERIES-RAM - // convention, since it's built from the live data, not assumed from it. const availableLineups = Array.from(new Set(plans.flatMap((p) => p.lineup ? [p.lineup] : []))) as Lineup[] const availableSeries = Array.from(new Set(plans.flatMap((p) => p.series ? [p.series] : []))) as Series[] const availableRam = Array.from(new Set(plans.map((p) => p.ramGB))).sort((a, b) => a - b) @@ -318,7 +219,6 @@ export function VpsHub({ plans }: VpsHubProps) { return (
- {/* Background */}
- {/* ── Hero ─────────────────────────────────────────────────────────── */}
@@ -345,9 +244,7 @@ export function VpsHub({ plans }: VpsHubProps) {

- {/* ── Filter bar ───────────────────────────────────────────────────── */}
- {/* Search + Sort + Filters toggle row */}
@@ -396,7 +293,6 @@ export function VpsHub({ plans }: VpsHubProps) { )}
- {/* Collapsible filter groups */}
@@ -471,7 +367,6 @@ export function VpsHub({ plans }: VpsHubProps) {
- {/* ── Plan grid ────────────────────────────────────────────────────── */}
{plans.length === 0 ? (
@@ -491,7 +386,7 @@ export function VpsHub({ plans }: VpsHubProps) {

Showing {filtered.length} of {plans.length} plans

-
+
{filtered.map((plan) => ( ))} @@ -500,7 +395,6 @@ export function VpsHub({ plans }: VpsHubProps) { )}
- {/* ── Custom plans CTA ─────────────────────────────────────────────── */}
diff --git a/packages/ui/components/Layouts/VPS/vps-pricing.tsx b/packages/ui/components/Layouts/VPS/vps-pricing.tsx deleted file mode 100644 index 63984d4..0000000 --- a/packages/ui/components/Layouts/VPS/vps-pricing.tsx +++ /dev/null @@ -1,448 +0,0 @@ -"use client" - -import { useState } from "react" -import { Button } from "@/packages/ui/components/ui/button" -import { Card } from "@/packages/ui/components/ui/card" -import { Input } from "@/packages/ui/components/ui/input" -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/packages/ui/components/ui/select" -import { Slider } from "@/packages/ui/components/ui/slider" -import { Cpu, Zap, PackageX, ExternalLink, ArrowRight, HardDrive, MemoryStick, Network, MapPin, Shield, Wifi, Search, X, Tag, Copy, Check } from "lucide-react" -import Link from "next/link" -import { cn } from "@/lib/utils" -import { Price } from "@/packages/ui/components/ui/price" -import { useTranslations } from "next-intl" -import { LINKS } from "@/packages/core/constants/links" -import type { VpsPlanSpec } from "@/packages/core/constants/vps" - -interface VpsPricingProps { - variant: "amd" | "intel" - plans: VpsPlanSpec[] - billingUrl: string -} - -const VARIANT_STYLES = { - amd: { - gradient: "from-red-500/20 via-orange-500/10 to-primary/5", - iconBg: "bg-red-500/10 text-red-500", - }, - intel: { - gradient: "from-blue-500/20 via-cyan-500/10 to-accent/5", - iconBg: "bg-blue-500/10 text-blue-500", - }, -} as const - -export function VpsPricing({ variant, plans, billingUrl }: VpsPricingProps) { - const t = useTranslations() - const isOutOfStock = plans.length === 0 || plans.every((p) => p.stock === "out_of_stock") - const variantName = variant === "amd" ? "AMD" : "Intel" - const styles = VARIANT_STYLES[variant] - - const priceBounds: [number, number] = plans.length > 0 - ? [Math.min(...plans.map(p => p.priceGBP)), Math.max(...plans.map(p => p.priceGBP))] - : [0, 100] - const cpuBounds: [number, number] = plans.length > 0 - ? [Math.min(...plans.map(p => p.cpu)), Math.max(...plans.map(p => p.cpu))] - : [1, 32] - const ramBounds: [number, number] = plans.length > 0 - ? [Math.min(...plans.map(p => p.ramGB)), Math.max(...plans.map(p => p.ramGB))] - : [1, 64] - const storageBounds: [number, number] = plans.length > 0 - ? [Math.min(...plans.map(p => p.storageGB)), Math.max(...plans.map(p => p.storageGB))] - : [10, 1000] - - const [search, setSearch] = useState("") - const [sortOrder, setSortOrder] = useState<"default" | "asc" | "desc">("default") - const [priceRange, setPriceRange] = useState<[number, number]>(priceBounds) - const [cpuRange, setCpuRange] = useState<[number, number]>(cpuBounds) - const [ramRange, setRamRange] = useState<[number, number]>(ramBounds) - const [storageRange, setStorageRange] = useState<[number, number]>(storageBounds) - const [copied, setCopied] = useState(false) - - function copyPromoCode() { - navigator.clipboard.writeText("VPSLAUNCH").then(() => { - setCopied(true) - setTimeout(() => setCopied(false), 2000) - }) - } - - const hasActiveFilters = - search !== "" || - sortOrder !== "default" || - priceRange[0] !== priceBounds[0] || priceRange[1] !== priceBounds[1] || - cpuRange[0] !== cpuBounds[0] || cpuRange[1] !== cpuBounds[1] || - ramRange[0] !== ramBounds[0] || ramRange[1] !== ramBounds[1] || - storageRange[0] !== storageBounds[0] || storageRange[1] !== storageBounds[1] - - const filteredPlans = [...plans] - .filter(p => - (!search || p.id.toLowerCase().includes(search.toLowerCase())) && - p.priceGBP >= priceRange[0] && p.priceGBP <= priceRange[1] && - p.cpu >= cpuRange[0] && p.cpu <= cpuRange[1] && - p.ramGB >= ramRange[0] && p.ramGB <= ramRange[1] && - p.storageGB >= storageRange[0] && p.storageGB <= storageRange[1] - ) - .sort((a, b) => { - if (sortOrder === "asc") return a.priceGBP - b.priceGBP - if (sortOrder === "desc") return b.priceGBP - a.priceGBP - return 0 - }) - - function clearFilters() { - setSearch("") - setSortOrder("default") - setPriceRange(priceBounds) - setCpuRange(cpuBounds) - setRamRange(ramBounds) - setStorageRange(storageBounds) - } - - return ( -
-
- -
- {/* Header */} -
-
- - {t("vps.pricing.badge")} -
-

- {variantName} VPS{" "} - - {t("vps.pricing.title")} - -

-

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

-
- - {isOutOfStock ? ( - -
-
- -
-

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

-

- {t("gamePage.pricing.outOfStockDesc", { game: `${variantName} VPS` })} -

-

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

- -
-
- ) : ( - <> - {/* Promo Banner */} -
-
-
-
- -
-
-

- 50% off your first month on all VPS plans -

-

- Use this code at checkout valid until March 22, 2026 -

-
-
- -
-
- - {/* Filter Panel */} -
- {/* Row 1: search + sort + clear */} -
-
- - setSearch(e.target.value)} - placeholder={t("vps.pricing.filters.search")} - className="pl-9 h-9 bg-background/50" - /> -
- - {hasActiveFilters && ( - - )} -
- - {/* Row 2: range sliders */} -
-
-
- {t("vps.pricing.filters.price")} - - £{priceRange[0]} – £{priceRange[1]}/mo - -
- setPriceRange(v as [number, number])} - /> -
-
-
- {t("vps.pricing.filters.cpu")} - {cpuRange[0]} – {cpuRange[1]} vCPU -
- setCpuRange(v as [number, number])} - /> -
-
-
- {t("vps.pricing.filters.ram")} - {ramRange[0]} – {ramRange[1]} GB -
- setRamRange(v as [number, number])} - /> -
-
-
- {t("vps.pricing.filters.storage")} - {storageRange[0]} – {storageRange[1]} GB -
- setStorageRange(v as [number, number])} - /> -
-
-
- - {/* No results */} - {filteredPlans.length === 0 ? ( - -
-
- -
-

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

-

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

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

{plan.id}

-
-

{t("vpsPage.startingAt")}

- -
-
- - {/* Description */} - {plan.description && ( -

{plan.description}

- )} - - {/* CPU model chip */} - {plan.cpuModel && ( -
- - {plan.cpuModel} -
- )} - - {/* Spec list */} -
    -
  • - - {plan.cpu} {plan.cpu === 1 ? t("vps.pricing.specs.vcpuSingle") : t("vps.pricing.specs.vcpuPlural")} -
  • -
  • - - {t("vps.pricing.specs.ram", { amount: plan.ramGB })} -
  • -
  • - - {t("vps.pricing.specs.storage", { amount: plan.storageGB })} -
  • -
  • - - - {plan.bandwidth === null - ? t("vps.pricing.specs.bandwidthUnmetered") - : t("vps.pricing.specs.bandwidth", { amount: plan.bandwidth.amount, unit: plan.bandwidth.unit })} - -
  • - {plan.uplink && ( -
  • - - {t("vps.pricing.specs.uplink", { amount: plan.uplink.amount, unit: plan.uplink.unit })} -
  • - )} - {plan.ddos && ( -
  • - - - {t("vps.pricing.specs.ddos", { layers: plan.ddos.layers.map(l => `L${l}`).join("/") })} - {plan.ddos.autoOn && ` (${t("vps.pricing.specs.ddosAlwaysOn")})`} - -
  • - )} - {plan.location && ( -
  • - - {plan.location} -
  • - )} -
- - {plan.stock === "out_of_stock" ? ( - - ) : plan.stock === "coming_soon" ? ( - - ) : ( - - )} -
- - ))} -
- )} - - {/* Footer CTA */} -
-

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

- -
- - )} -
-
- ) -} diff --git a/packages/ui/components/Static/footer.tsx b/packages/ui/components/Static/footer.tsx index 48017cf..074b65d 100644 --- a/packages/ui/components/Static/footer.tsx +++ b/packages/ui/components/Static/footer.tsx @@ -123,7 +123,7 @@ export function Footer() { { href: "/vps", label: t("footer.services.vpsServers") }, { href: "/dedicated", label: t("footer.services.dedicatedServers") }, { href: "/object-storage", label: t("footer.services.objectStorage") }, - { href: "/nodes", label: t("footer.services.nodes") }, + { href: "/discord-bots", label: t("footer.services.discordBots") }, ].map((link) => (
  • {link.href.startsWith("http") ? ( diff --git a/packages/ui/components/Static/navigation.tsx b/packages/ui/components/Static/navigation.tsx index 72f6bb7..cd40b9c 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, ExternalLink, ChevronRight, ChevronDown, Book, Mail, Users, Sparkles, Cpu, Network, Handshake, Cloud } from "lucide-react" +import { Server, Gamepad2, ExternalLink, ChevronRight, ChevronDown, Book, Mail, Users, Sparkles, Cpu, Handshake, Cloud, Bot } 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" @@ -33,17 +33,11 @@ export function Navigation() { }, { title: t("company.lookingGlass.title"), - href: "https://lg.nodebyte.host/", + href: LINKS.lookingGlass, description: t("company.lookingGlass.description"), icon: Sparkles, external: true, }, - { - title: t("company.network.title"), - href: "/nodes", - description: t("company.network.description"), - icon: Network, - }, { title: t("company.partners.title"), href: "/partners", @@ -83,6 +77,12 @@ export function Navigation() { description: t("services.objectStorage.description"), icon: Cloud, }, + { + title: t("services.discordBots.title"), + href: "/discord-bots", + description: t("services.discordBots.description"), + icon: Bot, + }, ] const resources = [ diff --git a/packages/ui/components/ui/plan-card.tsx b/packages/ui/components/ui/plan-card.tsx new file mode 100644 index 0000000..ec6116e --- /dev/null +++ b/packages/ui/components/ui/plan-card.tsx @@ -0,0 +1,169 @@ +"use client" + +import { useState } from "react" +import { ArrowRight, ChevronDown, Star, type LucideIcon } from "lucide-react" +import { Button } from "@/packages/ui/components/ui/button" +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/packages/ui/components/ui/collapsible" +import { Price } from "@/packages/ui/components/ui/price" +import { cn } from "@/lib/utils" + +export type PlanSpec = { + icon: LucideIcon + value: string + label: string +} + +export type PlanFeature = { + icon: LucideIcon + text: string +} + +type PlanCardProps = { + name: string + subtitle?: string | null + priceGBP: number + prices: Record + popular?: boolean + outOfStock?: boolean + url: string + badges?: React.ReactNode + specs: PlanSpec[] + features?: PlanFeature[] + extra?: React.ReactNode + info?: React.ReactNode + infoLabel?: string +} + +export function PlanCard({ + name, + subtitle, + priceGBP, + prices, + popular = false, + outOfStock = false, + url, + badges, + specs, + features = [], + extra, + info, + infoLabel = "Server Info", +}: PlanCardProps) { + const [infoOpen, setInfoOpen] = useState(false) + + return ( +
    + {popular && ( +
    + + Most Popular +
    + )} + +
    +
    +
    +

    + {name} +

    +

    + {subtitle} +

    +
    +
    + +

    /month

    +
    +
    + + {(badges || outOfStock) && ( +
    + {badges} + {outOfStock && ( + + Out of Stock + + )} +
    + )} + +
    + {specs.map((spec, index) => ( +
    + +
    +

    + {spec.value} +

    +

    + {spec.label} +

    +
    +
    + ))} +
    + + {features.length > 0 && ( +
    + {features.map(({ icon: Icon, text }) => ( + + + {text} + + ))} +
    + )} + + {extra} + +
    + {info && ( + + + + + {info} + + )} + + +
    +
    +
    + ) +} diff --git a/translations b/translations index 760c4f7..eeab4c9 160000 --- a/translations +++ b/translations @@ -1 +1 @@ -Subproject commit 760c4f78c36a073e2a373f7dfccf8fc29e1e0090 +Subproject commit eeab4c914d355670f643522c60b162293ca49588