From e88f59ab232cbb8a4395039ce6d1113688614fb9 Mon Sep 17 00:00:00 2001 From: TheRealToxicDev Date: Sat, 4 Jul 2026 23:36:11 -0600 Subject: [PATCH 1/3] fix: knowledgebase and more --- app/api/billing-debug/route.ts | 31 ++ app/dedicated/page.tsx | 14 + app/games/hytale/page.tsx | 6 +- app/games/minecraft/page.tsx | 6 +- app/games/rust/page.tsx | 6 +- app/kb/[...path]/page.tsx | 255 +++++++++++++ app/kb/[category]/[article]/page.tsx | 160 -------- app/kb/[category]/page.tsx | 123 ------- app/kb/page.tsx | 3 +- app/vps/page.tsx | 10 +- packages/core/constants/game/hytale.ts | 6 +- packages/core/constants/links.ts | 1 + packages/core/constants/product-overrides.ts | 29 ++ packages/core/constants/services.ts | 21 +- packages/core/lib/bytepay.ts | 251 +++++++++++++ packages/core/lib/spec-parser.ts | 166 +++++++++ packages/core/products/billing-service.ts | 122 +++++++ packages/core/types/servers/dedicated.ts | 36 ++ packages/core/types/servers/game.ts | 2 + packages/core/types/servers/vps.ts | 6 +- packages/kb/components/kb-article-card.tsx | 4 +- packages/kb/components/kb-article.tsx | 7 +- packages/kb/components/kb-category-card.tsx | 86 ++--- packages/kb/components/kb-search.tsx | 6 +- packages/kb/components/kb-sidebar.tsx | 237 ++++++------ packages/kb/content/billing/_meta.json | 2 +- .../{ => games}/hytale/operating-yourself.md | 0 .../{ => games}/hytale/setting-motd.md | 0 .../{ => games}/hytale/setting-server-name.md | 0 .../hytale/setting-server-password.md | 0 .../{ => games}/hytale/setting-up-hytale.md | 0 .../kb/content/{ => games}/rust/_meta.json | 0 .../{ => games}/rust/add-additional-ports.md | 0 .../{ => games}/rust/connect-to-server.md | 0 .../{ => games}/rust/enabling-rust+.md | 0 .../{ => games}/rust/getting-started.md | 0 .../{ => games}/rust/oxide-installation.md | 0 packages/kb/lib/kb.ts | 309 +++++++++++----- .../components/Layouts/About/about-page.tsx | 11 +- .../ui/components/Layouts/Contact/contact.tsx | 10 +- .../Layouts/Dedicated/dedicated-hub.tsx | 344 ++++++++++++++++++ .../components/Layouts/Error/error-page.tsx | 11 +- .../Layouts/Error/not-found-page.tsx | 11 +- .../components/Layouts/Games/game-pricing.tsx | 4 +- packages/ui/components/Layouts/Home/hero.tsx | 11 +- .../components/Layouts/Nodes/nodes-client.tsx | 6 +- .../ui/components/Layouts/VPS/vps-hero.tsx | 22 +- .../ui/components/Layouts/VPS/vps-hub.tsx | 74 ++-- packages/ui/components/Static/footer.tsx | 154 +------- packages/ui/components/Static/navigation.tsx | 54 ++- packages/ui/components/theme-toggle.tsx | 4 +- packages/ui/components/ui/price.tsx | 18 +- 52 files changed, 1833 insertions(+), 806 deletions(-) create mode 100644 app/api/billing-debug/route.ts create mode 100644 app/dedicated/page.tsx create mode 100644 app/kb/[...path]/page.tsx delete mode 100644 app/kb/[category]/[article]/page.tsx delete mode 100644 app/kb/[category]/page.tsx create mode 100644 packages/core/constants/product-overrides.ts create mode 100644 packages/core/lib/bytepay.ts create mode 100644 packages/core/lib/spec-parser.ts create mode 100644 packages/core/products/billing-service.ts create mode 100644 packages/core/types/servers/dedicated.ts rename packages/kb/content/{ => games}/hytale/operating-yourself.md (100%) rename packages/kb/content/{ => games}/hytale/setting-motd.md (100%) rename packages/kb/content/{ => games}/hytale/setting-server-name.md (100%) rename packages/kb/content/{ => games}/hytale/setting-server-password.md (100%) rename packages/kb/content/{ => games}/hytale/setting-up-hytale.md (100%) rename packages/kb/content/{ => games}/rust/_meta.json (100%) rename packages/kb/content/{ => games}/rust/add-additional-ports.md (100%) rename packages/kb/content/{ => games}/rust/connect-to-server.md (100%) rename packages/kb/content/{ => games}/rust/enabling-rust+.md (100%) rename packages/kb/content/{ => games}/rust/getting-started.md (100%) rename packages/kb/content/{ => games}/rust/oxide-installation.md (100%) create mode 100644 packages/ui/components/Layouts/Dedicated/dedicated-hub.tsx diff --git a/app/api/billing-debug/route.ts b/app/api/billing-debug/route.ts new file mode 100644 index 0000000..674dcba --- /dev/null +++ b/app/api/billing-debug/route.ts @@ -0,0 +1,31 @@ +import { fetchAllBillingProducts } from "@/packages/core/lib/bytepay" + +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, + })) + + return Response.json({ count: summary.length, products: summary }) +} diff --git a/app/dedicated/page.tsx b/app/dedicated/page.tsx new file mode 100644 index 0000000..a12908f --- /dev/null +++ b/app/dedicated/page.tsx @@ -0,0 +1,14 @@ +import type { Metadata } from "next" +import { DedicatedHub } from "@/packages/ui/components/Layouts/Dedicated/dedicated-hub" +import { getDedicatedPlans } from "@/packages/core/products/billing-service" + +export const metadata: Metadata = { + title: "Dedicated Servers", + description: + "Physical bare-metal servers with fully dedicated CPU cores, enterprise storage, and IPMI out-of-band access. Zero resource contention, maximum performance.", +} + +export default async function DedicatedPage() { + const plans = await getDedicatedPlans("dedicated-servers") + return +} diff --git a/app/games/hytale/page.tsx b/app/games/hytale/page.tsx index b4c1391..7af1cf6 100644 --- a/app/games/hytale/page.tsx +++ b/app/games/hytale/page.tsx @@ -7,7 +7,6 @@ import type { Metadata } from "next" import { getTranslations } from "next-intl/server" import { LINKS } from "@/packages/core/constants/links" import { - HYTALE_PLANS, HYTALE_PLAN_DISPLAY, HYTALE_PLAN_STATIC_FEATURES, HYTALE_FEATURES, @@ -15,7 +14,7 @@ import { HYTALE_HERO_FEATURES, HYTALE_CONFIG, } from "@/packages/core/constants/game" -import { applyGamePlanOverrides } from "@/packages/core/products/server" +import { getGamePlans } from "@/packages/core/products/billing-service" export const metadata: Metadata = { title: "Hytale Server Hosting", @@ -25,12 +24,13 @@ export const metadata: Metadata = { export default async function HytalePage() { const t = await getTranslations() - const plans = applyGamePlanOverrides("hytale", HYTALE_PLANS).map((plan) => { + const plans = (await getGamePlans("hytale")).map((plan) => { const display = HYTALE_PLAN_DISPLAY[plan.id as keyof typeof HYTALE_PLAN_DISPLAY] return { name: display.name, description: display.description, priceGBP: plan.priceGBP, + prices: plan.prices, period: t("pricing.perMonth"), popular: plan.popular, url: plan.url, diff --git a/app/games/minecraft/page.tsx b/app/games/minecraft/page.tsx index 3f9b2ca..0ddc6cb 100644 --- a/app/games/minecraft/page.tsx +++ b/app/games/minecraft/page.tsx @@ -7,14 +7,13 @@ import { GameFAQ } from "@/packages/ui/components/Layouts/Games/game-faq" import { getTranslations } from "next-intl/server" import { LINKS } from "@/packages/core/constants/links" import { - MINECRAFT_PLANS, MINECRAFT_PLAN_FEATURE_KEYS, MINECRAFT_FEATURE_KEYS, MINECRAFT_FAQ_KEYS, MINECRAFT_HERO_FEATURES, MINECRAFT_CONFIG, } from "@/packages/core/constants/game" -import { applyGamePlanOverrides } from "@/packages/core/products/server" +import { getGamePlans } from "@/packages/core/products/billing-service" export const metadata: Metadata = { title: "Minecraft Server Hosting", @@ -24,10 +23,11 @@ export const metadata: Metadata = { export default async function MinecraftPage() { const t = await getTranslations() - const plans = applyGamePlanOverrides("minecraft", MINECRAFT_PLANS).map((plan) => ({ + const plans = (await getGamePlans("minecraft")).map((plan) => ({ name: t(`games.minecraft.plans.${plan.id}.name`), description: t(`games.minecraft.plans.${plan.id}.description`), priceGBP: plan.priceGBP, + prices: plan.prices, period: t("pricing.perMonth"), popular: plan.popular, location: plan.location, diff --git a/app/games/rust/page.tsx b/app/games/rust/page.tsx index e663412..1eb4aa2 100644 --- a/app/games/rust/page.tsx +++ b/app/games/rust/page.tsx @@ -7,14 +7,13 @@ import { GameFAQ } from "@/packages/ui/components/Layouts/Games/game-faq" import { getTranslations } from "next-intl/server" import { LINKS } from "@/packages/core/constants/links" import { - RUST_PLANS, RUST_PLAN_FEATURE_KEYS, RUST_FEATURE_KEYS, RUST_FAQ_KEYS, RUST_HERO_FEATURES, RUST_CONFIG, } from "@/packages/core/constants/game" -import { applyGamePlanOverrides } from "@/packages/core/products/server" +import { getGamePlans } from "@/packages/core/products/billing-service" export const metadata: Metadata = { title: "Rust Server Hosting", @@ -24,10 +23,11 @@ export const metadata: Metadata = { export default async function RustPage() { const t = await getTranslations() - const plans = applyGamePlanOverrides("rust", RUST_PLANS).map((plan) => ({ + const plans = (await getGamePlans("rust")).map((plan) => ({ name: t(`games.rust.plans.${plan.id}.name`), description: t(`games.rust.plans.${plan.id}.description`), priceGBP: plan.priceGBP, + prices: plan.prices, period: t("pricing.perMonth"), popular: plan.popular, location: plan.location, diff --git a/app/kb/[...path]/page.tsx b/app/kb/[...path]/page.tsx new file mode 100644 index 0000000..97c649b --- /dev/null +++ b/app/kb/[...path]/page.tsx @@ -0,0 +1,255 @@ +import { Metadata } from "next" +import { notFound } from "next/navigation" +import { getTranslations } from "next-intl/server" +import { + resolvePath, + getAllPaths, + getCategoryAtPath, + getArticlesByCategory, + getArticle, + extractHeadings, + getSidebarTree, + type KBCategory, +} from "@/packages/kb/lib/kb" +import { KBBreadcrumb } from "@/packages/kb/components/kb-breadcrumb" +import { KBArticleList } from "@/packages/kb/components/kb-article-card" +import { KBCategoryGrid } from "@/packages/kb/components/kb-category-card" +import { KBArticle } from "@/packages/kb/components/kb-article" +import { KBTableOfContents } from "@/packages/kb/components/kb-toc" +import { KBSidebar } from "@/packages/kb/components/kb-sidebar" +import { + Rocket, + Gamepad2, + CreditCard, + Users, + Shield, + Settings, + HelpCircle, + Server, + FileText, + Blocks, + Wrench, + BookOpen, + Network, + type LucideIcon, +} from "lucide-react" + +const iconMap: Record = { + Rocket, Gamepad2, CreditCard, Users, Shield, Settings, + HelpCircle, Server, FileText, Blocks, Wrench, BookOpen, Network, +} + +interface KBDynamicPageProps { + params: Promise<{ path: string[] }> +} + +// ─── Breadcrumbs ────────────────────────────────────────────────────────────── + +async function buildBreadcrumbs( + segments: string[], + articleTitle?: string, +): Promise<{ label: string; href?: string }[]> { + const items: { label: string; href?: string }[] = [] + + for (let i = 0; i < segments.length; i++) { + const isLast = i === segments.length - 1 + const partialSegments = segments.slice(0, i + 1) + const href = isLast ? undefined : `/kb/${partialSegments.join("/")}` + + if (!isLast || resolvePath(partialSegments) === "category") { + // Category segment — fetch its title + const cat = await getCategoryAtPath(partialSegments) + items.push({ label: cat?.title ?? segments[i], href }) + } else { + // Last segment is an article — use provided title or fallback + items.push({ label: articleTitle ?? segments[i] }) + } + } + + return items +} + +// ─── Static params ──────────────────────────────────────────────────────────── + +export async function generateStaticParams() { + const paths = getAllPaths() + return paths.map((p) => ({ path: p })) +} + +// ─── Metadata ───────────────────────────────────────────────────────────────── + +export async function generateMetadata({ params }: KBDynamicPageProps): Promise { + const { path: segments } = await params + const kind = resolvePath(segments) + + if (kind === "category") { + const cat = await getCategoryAtPath(segments) + if (!cat) return { title: "Not Found | Knowledge Base" } + return { + title: `${cat.title} | Knowledge Base`, + description: cat.description, + } + } + + if (kind === "article") { + const categoryPath = segments.slice(0, -1).join("/") + const articleSlug = segments[segments.length - 1] + const article = await getArticle(categoryPath, articleSlug) + if (!article) return { title: "Not Found | Knowledge Base" } + return { + title: `${article.meta.title} | Knowledge Base`, + description: article.meta.description, + keywords: article.meta.tags, + } + } + + return { title: "Not Found | Knowledge Base" } +} + +// ─── Page ───────────────────────────────────────────────────────────────────── + +export default async function KBDynamicPage({ params }: KBDynamicPageProps) { + const { path: segments } = await params + const kind = resolvePath(segments) + + if (kind === null) notFound() + + // ── Category page ────────────────────────────────────────────────────────── + if (kind === "category") { + const [t, categoryData, articles] = await Promise.all([ + getTranslations(), + getCategoryAtPath(segments), + getArticlesByCategory(segments.join("/")), + ]) + + if (!categoryData) notFound() + + const breadcrumbs = await buildBreadcrumbs(segments) + const Icon = iconMap[categoryData.icon] ?? HelpCircle + + return ( +
+
+ + + {/* Category header */} +
+
+
+ +
+
+

+ {categoryData.title} +

+ {categoryData.description && ( +

{categoryData.description}

+ )} +
+
+

+ {categoryData.totalCount} {t("kb.articles")} +

+
+ + {/* Subcategories */} + {categoryData.subcategories.length > 0 && ( +
+ {articles.length > 0 && ( +

Categories

+ )} + +
+ )} + + {/* Direct articles */} + {articles.length > 0 && ( +
+ {categoryData.subcategories.length > 0 && ( +

Articles

+ )} + +
+ )} + + {/* Empty state */} + {articles.length === 0 && categoryData.subcategories.length === 0 && ( +
+ +

{t("kb.empty.title")}

+

{t("kb.empty.description")}

+
+ )} +
+
+ ) + } + + // ── Article page ─────────────────────────────────────────────────────────── + const categorySegments = segments.slice(0, -1) + const articleSlug = segments[segments.length - 1] + const categoryPath = categorySegments.join("/") + + const [t, articleData, sidebarTree, articlesInCategory] = await Promise.all([ + getTranslations(), + getArticle(categoryPath, articleSlug), + getSidebarTree(), + getArticlesByCategory(categoryPath), + ]) + + if (!articleData) notFound() + + const breadcrumbs = await buildBreadcrumbs(segments, articleData.meta.title) + const headings = extractHeadings(articleData.content) + + const sortedArticles = articlesInCategory.toSorted((a, b) => a.order - b.order) + const currentIndex = sortedArticles.findIndex((a) => a.slug === articleSlug) + const previousArticle = currentIndex > 0 + ? { slug: sortedArticles[currentIndex - 1].slug, categoryPath, title: sortedArticles[currentIndex - 1].title } + : null + const nextArticle = currentIndex < sortedArticles.length - 1 + ? { slug: sortedArticles[currentIndex + 1].slug, categoryPath, title: sortedArticles[currentIndex + 1].title } + : null + + return ( +
+
+ + +
+ {/* Sidebar */} + + + {/* Main content */} +
+ +
+ + {/* Table of contents */} + {headings.length > 2 && ( + + )} +
+
+
+ ) +} diff --git a/app/kb/[category]/[article]/page.tsx b/app/kb/[category]/[article]/page.tsx deleted file mode 100644 index 1705b05..0000000 --- a/app/kb/[category]/[article]/page.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import { Metadata } from "next"; -import { notFound } from "next/navigation"; -import { getTranslations } from "next-intl/server"; -import { - getCategories, - getArticle, - getArticlesByCategory, - extractHeadings, - getAllArticles, -} from "@/packages/kb/lib/kb"; -import { KBBreadcrumb } from "@/packages/kb/components/kb-breadcrumb"; -import { KBArticle } from "@/packages/kb/components/kb-article"; -import { KBTableOfContents } from "@/packages/kb/components/kb-toc"; -import { KBSidebar, SidebarCategory } from "@/packages/kb/components/kb-sidebar"; - -interface ArticlePageProps { - params: Promise<{ - category: string; - article: string; - }>; -} - -export async function generateMetadata({ - params, -}: ArticlePageProps): Promise { - const { category, article } = await params; - const articleData = await getArticle(category, article); - - if (!articleData) { - return { - title: "Article Not Found | Knowledge Base", - }; - } - - return { - title: `${articleData.meta.title} | Knowledge Base`, - description: articleData.meta.description, - keywords: articleData.meta.tags, - }; -} - -export async function generateStaticParams() { - const articles = await getAllArticles(); - return articles.map((article) => ({ - category: article.categorySlug, - article: article.slug, - })); -} - -export default async function ArticlePage({ params }: ArticlePageProps) { - const { category, article } = await params; - const [t, articleData, categories, articlesInCategory] = await Promise.all([ - getTranslations(), - getArticle(category, article), - getCategories(), - getArticlesByCategory(category), - ]); - - if (!articleData) { - notFound(); - } - - const categoryData = categories.find((c) => c.slug === category); - - // Prepare sidebar data - const sidebarCategories: SidebarCategory[] = await Promise.all( - categories.map(async (cat) => { - const catArticles = await getArticlesByCategory(cat.slug); - return { - slug: cat.slug, - title: cat.title, - icon: cat.icon, - order: cat.order, - articles: catArticles.map((a) => ({ - slug: a.slug, - title: a.title, - order: a.order, - })), - }; - }) - ); - - // Extract headings for TOC - const headings = extractHeadings(articleData.content); - - // Find adjacent articles for navigation - const sortedArticles = articlesInCategory.toSorted((a, b) => a.order - b.order); - const currentIndex = sortedArticles.findIndex((a) => a.slug === article); - const previousArticle = - currentIndex > 0 - ? { - slug: sortedArticles[currentIndex - 1].slug, - category, - title: sortedArticles[currentIndex - 1].title, - } - : null; - const nextArticle = - currentIndex < sortedArticles.length - 1 - ? { - slug: sortedArticles[currentIndex + 1].slug, - category, - title: sortedArticles[currentIndex + 1].title, - } - : null; - - return ( -
-
- {/* Breadcrumb */} - - -
- {/* Sidebar - Hidden on mobile */} - - - {/* Main Content */} -
- -
- - {/* Table of Contents - Hidden on mobile and tablet */} - {headings.length > 2 && ( - - )} -
-
-
- ); -} diff --git a/app/kb/[category]/page.tsx b/app/kb/[category]/page.tsx deleted file mode 100644 index 92e6127..0000000 --- a/app/kb/[category]/page.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { Metadata } from "next"; -import { notFound } from "next/navigation"; -import { getTranslations } from "next-intl/server"; -import { getCategories, getArticlesByCategory } from "@/packages/kb/lib/kb"; -import { KBBreadcrumb } from "@/packages/kb/components/kb-breadcrumb"; -import { KBArticleList } from "@/packages/kb/components/kb-article-card"; -import { - Rocket, - Gamepad2, - CreditCard, - Users, - Shield, - Settings, - HelpCircle, - Server, -} from "lucide-react"; - -// Icon mapping -const iconMap: Record> = { - Rocket, - Gamepad2, - CreditCard, - Users, - Shield, - Settings, - HelpCircle, - Server, -}; - -interface CategoryPageProps { - params: Promise<{ - category: string; - }>; -} - -export async function generateMetadata({ - params, -}: CategoryPageProps): Promise { - const [{ category }, categories] = await Promise.all([ - params, - getCategories(), - ]); - - const categoryData = categories.find((c) => c.slug === category); - - if (!categoryData) { - return { - title: "Category Not Found | Knowledge Base", - }; - } - - return { - title: `${categoryData.title} | Knowledge Base`, - description: categoryData.description, - }; -} - -export async function generateStaticParams() { - const categories = await getCategories(); - return categories.map((category) => ({ - category: category.slug, - })); -} - -export default async function CategoryPage({ params }: CategoryPageProps) { - const { category } = await params; - const [t, categories, articles] = await Promise.all([ - getTranslations(), - getCategories(), - getArticlesByCategory(category), - ]); - const categoryData = categories.find((c) => c.slug === category); - - if (!categoryData) { - notFound(); - } - const Icon = iconMap[categoryData.icon] || HelpCircle; - - return ( -
-
- {/* Breadcrumb */} - - - {/* Category Header */} -
-
-
- -
-
-

- {categoryData.title} -

-

- {categoryData.description} -

-
-
-

- {articles.length} {t("kb.articles")} -

-
- - {/* Articles List */} - {articles.length > 0 ? ( - - ) : ( -
- -

{t("kb.empty.title")}

-

- {t("kb.empty.description")} -

-
- )} -
-
- ); -} diff --git a/app/kb/page.tsx b/app/kb/page.tsx index 01a5203..c86c811 100644 --- a/app/kb/page.tsx +++ b/app/kb/page.tsx @@ -43,7 +43,8 @@ export default async function KnowledgeBasePage() { // Prepare search data const searchArticles = allArticles.map((article) => ({ slug: article.slug, - category: article.categorySlug, + category: article.category, + categoryPath: article.categoryPath, title: article.title, description: article.description, excerpt: article.excerpt || "", diff --git a/app/vps/page.tsx b/app/vps/page.tsx index ddf8acc..64e0a6f 100644 --- a/app/vps/page.tsx +++ b/app/vps/page.tsx @@ -1,6 +1,6 @@ import type { Metadata } from "next" import { VpsHub } from "@/packages/ui/components/Layouts/VPS/vps-hub" -import { ALL_VPS_PLANS } from "@/packages/core/constants/vps" +import { getVpsPlans } from "@/packages/core/products/billing-service" export const metadata: Metadata = { title: "VPS Hosting", @@ -8,7 +8,11 @@ export const metadata: Metadata = { "Enterprise KVM virtual servers across AMD and Intel hardware lineups. Full root access, NVMe SSD, DDoS protection, and instant deployment.", } -export default function VpsPage() { - return +export default async function VpsPage() { + const [sharedPlans, dedicatedPlans] = await Promise.all([ + getVpsPlans("shared-cpu"), + getVpsPlans("dedicated-cpu"), + ]) + return } diff --git a/packages/core/constants/game/hytale.ts b/packages/core/constants/game/hytale.ts index 6e45ef4..f5e0e95 100644 --- a/packages/core/constants/game/hytale.ts +++ b/packages/core/constants/game/hytale.ts @@ -31,15 +31,15 @@ export const HYTALE_PLANS: GamePlanSpec[] = [ * here directly instead of being keyed through i18n. */ export const HYTALE_PLAN_DISPLAY = { - starter: { + "hytale-starter": { name: "Starter", description: "Perfect for small communities and testing.", }, - standard: { + "hytale-standard": { name: "Standard", description: "Perfect for growing communities and performance.", }, - performance: { + "hytale-performance": { name: "Performance", description: "Perfect for large communities and high performance.", }, diff --git a/packages/core/constants/links.ts b/packages/core/constants/links.ts index 470046b..75d9294 100644 --- a/packages/core/constants/links.ts +++ b/packages/core/constants/links.ts @@ -11,6 +11,7 @@ export const LINKS = { twitter: "https://twitter.com/NodeByteHosting", trustpilot: "https://uk.trustpilot.com/review/nodebyte.host", status: "https://status.nodebyte.host", + network: "https://lg.nodebyte.host", contact: "/contact", billing: { root: "https://billing.nodebyte.host", diff --git a/packages/core/constants/product-overrides.ts b/packages/core/constants/product-overrides.ts new file mode 100644 index 0000000..c3db30b --- /dev/null +++ b/packages/core/constants/product-overrides.ts @@ -0,0 +1,29 @@ +/** + * The only things that cannot be parsed from billing panel descriptions: + * + * - POPULAR_SLUGS — which plans get the "popular" highlight badge + * - SERIES_LOCATION — maps the series code in a SKU to a datacenter city + * + * Add new series here when a new datacenter location goes live. + * Keys are the second segment of the product name, e.g. "BASE-RG1-2GB" → "RG1". + */ + +/** "{categorySlug}/{productSlug}" pairs that should show the popular badge. */ +export const POPULAR_SLUGS = new Set([ + // Minecraft + "minecraft/inferno", + "minecraft/firestorm", + "minecraft/supernova", + + // Rust + "rust/standard", + + // Hytale + "hytale/hytale-performance", + + // VPS + "shared-cpu/comp-rg1-8gb", +]) + +/** Applied to every VPS plan — override here if individual plans ever differ. */ +export const DEFAULT_DDOS = { layers: [3, 4, 7] as number[], autoOn: true } diff --git a/packages/core/constants/services.ts b/packages/core/constants/services.ts index 8df1098..2a8b2ab 100644 --- a/packages/core/constants/services.ts +++ b/packages/core/constants/services.ts @@ -1,4 +1,4 @@ -import { Gamepad2, Server, type LucideIcon } from "lucide-react" +import { Gamepad2, Server, Cpu, type LucideIcon } from "lucide-react" /** * ServiceCategory defines a top-level service hub offered by NodeByte. @@ -70,4 +70,23 @@ export const SERVICE_CATEGORIES: ServiceCategory[] = [ ], enabled: true, }, + { + id: "dedicated", + name: "Dedicated Servers", + description: + "Physical bare-metal servers with fully dedicated CPU cores, enterprise storage, and IPMI out-of-band access. Zero resource contention and maximum raw performance.", + href: "/dedicated", + icon: Cpu, + gradient: "from-amber-600/25 via-amber-500/8 to-transparent", + iconColor: "text-amber-400", + accentBorder: "hover:border-amber-400/40", + startingPriceGBP: 50, + highlights: [ + "100% dedicated CPU cores", + "IPMI out-of-band access", + "Enterprise storage", + "Enterprise DDoS protection", + ], + enabled: true, + }, ] diff --git a/packages/core/lib/bytepay.ts b/packages/core/lib/bytepay.ts new file mode 100644 index 0000000..6221cb1 --- /dev/null +++ b/packages/core/lib/bytepay.ts @@ -0,0 +1,251 @@ +/** + * 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. + */ + +const BYTEPAY_HOST = process.env.BYTEPAY_HOST! +const BYTEPAY_TOKEN = process.env.BYTEPAY_TOKEN! + +// ─── JSON:API wire types ─────────────────────────────────────────────────── + +interface JsonApiRelRef { + type: string + id: string +} + +interface JsonApiResource { + type: string + id: string + attributes: Record + relationships?: Record +} + +interface JsonApiResponse { + data: JsonApiResource[] + included?: JsonApiResource[] + links?: { next?: string | null } + meta?: Record +} + +// ─── Normalised types ────────────────────────────────────────────────────── + +export interface BillingPrice { + price: number + setupFee: number + currencyCode: string +} + +export interface BillingPlan { + id: string + name: string | null + type: "free" | "one-time" | "recurring" + billingPeriod: number | null + billingUnit: string | null + sort: number + prices: BillingPrice[] +} + +export interface BillingProduct { + id: string + 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 + 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, "") +} + +/** + * Derive the category slug from the included map. + * Paymenter's API exposes `name` on categories but not a `slug` attribute. + * Uses `full_slug` if the version of Paymenter provides it, otherwise falls + * back to slugifying the category name. + */ +function resolveCategorySlug(catId: string, map: Map): string { + const cat = map.get(`categories:${catId}`) + if (!cat) return "" + if (typeof cat.attributes.full_slug === "string") return cat.attributes.full_slug + return nameToSlug((cat.attributes.name as string) ?? "") +} + +function normalisePage( + data: JsonApiResource[], + map: Map, +): BillingProduct[] { + return data.map((product): BillingProduct => { + const attr = product.attributes + + const catRef = product.relationships?.category?.data as JsonApiRelRef | null | undefined + const categorySlug = catRef ? resolveCategorySlug(catRef.id, map) : "" + + const 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) + + 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, + } + }) +} + +async function fetchPage( + page: number, +): Promise<{ products: BillingProduct[]; hasNext: boolean }> { + const url = new URL(`${BYTEPAY_HOST}/api/v1/admin/products`) + url.searchParams.set("include", "category,plans,plans.prices") + url.searchParams.set("filter[hidden]", "0") + url.searchParams.set("page", String(page)) + + const res = await fetch(url.toString(), { + headers: { Authorization: `Bearer ${BYTEPAY_TOKEN}` }, + next: { revalidate: 300 }, + }) + + 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), + hasNext: Boolean(json.links?.next), + } +} + +// ─── Public API ──────────────────────────────────────────────────────────── + +/** Fetch every non-hidden product across all pagination pages. */ +export async function fetchAllBillingProducts(): Promise { + const all: BillingProduct[] = [] + let page = 1 + + while (true) { + const { products, hasNext } = await fetchPage(page) + 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, +): BillingProduct[] { + return products + .filter((p) => p.categorySlug === categorySlug) + .sort((a, b) => { + if (a.sort === b.sort) return parseInt(a.id) - parseInt(b.id) + if (a.sort === null) return 1 + if (b.sort === null) return -1 + return a.sort - b.sort + }) +} + +/** Resolve the GBP monthly price from the product's recurring plan. */ +export function getGbpPrice(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")?.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) { + map[price.currencyCode] = price.price + } + return map +} + +/** Map billing stock to the website StockStatus type. */ +export function getStockStatus( + product: BillingProduct, +): "in_stock" | "out_of_stock" | "coming_soon" { + if (product.stock === 0) return "out_of_stock" + 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}` +} diff --git a/packages/core/lib/spec-parser.ts b/packages/core/lib/spec-parser.ts new file mode 100644 index 0000000..75383d6 --- /dev/null +++ b/packages/core/lib/spec-parser.ts @@ -0,0 +1,166 @@ +/** + * Parses hardware specs out of a Paymenter product description (HTML). + * + * Descriptions follow consistent bullet-point patterns, e.g.: + * "2 Cores of Ryzen 7 Power: ..." + * "4 GB DDR4 RAM: ..." + * "100 GB SSD Storage: ..." + * "1 Gbps Network Port: ... 1 TB included outbound ..." + * + * Everything extractable comes from here; the only things left in config + * are `popular` flags and the series→location map. + */ + +export interface ParsedSpecs { + cpu?: number + ramGB?: number + storageGB?: number + /** Raw storage label extracted from the description, e.g. "2 × 1 TB NVMe SSD (RAID 1)" */ + storageDescription?: string + bandwidth?: { amount: number; unit: "MB" | "GB" | "TB" } | null + uplink?: { amount: number; unit: "Mbps" | "Gbps" } + cpuModel?: string + hardware?: "amd" | "intel" | "arm" + /** Short description extracted from the first bullet's subtitle. */ + description?: string +} + +function stripHtml(html: string): string { + return html.replace(/<[^>]*>/g, " ").replace(/&/g, "&").replace(/\s+/g, " ").trim() +} + +/** Split stripped text into individual bullet lines for more reliable matching. */ +function bulletLines(text: string): string[] { + return text + .split(/[•\n]/) + .map((s) => s.trim()) + .filter(Boolean) +} + +export function parseDescriptionSpecs(html: string | null): ParsedSpecs { + if (!html) return {} + + const text = stripHtml(html) + const lines = bulletLines(text) + + // ── CPU cores ────────────────────────────────────────────────────────────── + // "1 Core of Ryzen 7 Power", "2 Cores of Ryzen 7", "2 Ampere® Altra® ARM64 Cores" + // Fallback: "8 cores and 16 threads", "Octa-Core", "Quad-Core" + const NAMED_CORES: Record = { mono: 1, dual: 2, quad: 4, hexa: 6, octa: 8, deca: 10, dodeca: 12 } + let cpu: number | undefined + for (const line of lines) { + const m = line.match(/^(\d+)\s+(?:\S+\s+)*?[Cc]ores?/i) + if (m && !line.match(/^(\d+)\s+GB/i)) { + cpu = parseInt(m[1]) + break + } + } + if (!cpu) { + // "8 cores and 16 threads" — number before "cores" anywhere in text + const m = text.match(/\b(\d+)\s+[Cc]ores?\b/) + if (m) cpu = parseInt(m[1]) + } + if (!cpu) { + // "Octa-Core", "Quad-Core" etc + for (const [name, count] of Object.entries(NAMED_CORES)) { + if (new RegExp(`\\b${name}[- ]?[Cc]ore\\b`, 'i').test(text)) { cpu = count; break } + } + } + + // ── RAM ──────────────────────────────────────────────────────────────────── + // "2 GB DDR4 RAM", "4 GB ECC RAM", "8GB DDR4 RAM", "1 GB RAM" + const ramMatch = text.match(/(\d+)\s*GB\s+(?:\w+\s+)*?RAM\b/i) + const ramGB = ramMatch ? parseInt(ramMatch[1]) : undefined + + // ── Storage ──────────────────────────────────────────────────────────────── + // "25 GB SSD", "40 GB NVMe SSD", "100 GB SSD Storage", "40GB Disk Storage" + // Also handles TB drives: "2 x 1 TB NVMe SSD", "4 x 16 TB SATA HDD" + const storageMatchGB = text.match(/(\d+)\s*GB\s+(?:NVMe\s+)?(?:SSD|Disk|HDD)(?:\s+Storage)?/i) + const storageMatchTB = !storageMatchGB + ? text.match(/(\d+)\s*TB\s+(?:NVMe\s+|Enterprise\s+|SATA\s+)?(?:SSD|HDD|Disk)/i) + : null + const storageGB = storageMatchGB + ? parseInt(storageMatchGB[1]) + : storageMatchTB + ? parseInt(storageMatchTB[1]) * 1024 + : undefined + + // Raw storage label for multi-drive dedicated configs + let storageDescription: string | undefined + for (const line of lines) { + if (/\b(?:NVMe|SSD|HDD)\b/i.test(line)) { + const beforeColon = line.split(':')[0].trim() + if (beforeColon.length > 4 && beforeColon.length < 80) storageDescription = beforeColon + break + } + } + + // ── Uplink ───────────────────────────────────────────────────────────────── + // "1 Gbps Network Port", "4 Gbps" + const uplinkMatch = text.match(/(\d+)\s*Gbps/i) + const uplink: ParsedSpecs["uplink"] = uplinkMatch + ? { amount: parseInt(uplinkMatch[1]), unit: "Gbps" } + : { amount: 1, unit: "Gbps" } + + // ── Bandwidth ────────────────────────────────────────────────────────────── + // "1 TB included outbound", "20 TB Outbound Traffic Pool", "4 TB of poolable outbound" + // "Unlimited Outbound Bandwidth" → null (unmetered) + // Avoids false positives from storage descriptions ("4 x 16 TB Enterprise SATA HDDs") + const unlimitedBw = /unlimited\s+(?:\w+\s+)?bandwidth/i.test(text) + const bwTbMatch = + text.match(/(\d+)\s+TB\s+(?:included\s+)?outbound/i) || + text.match(/(\d+)\s+TB\s+of\s+(?:poolable\s+)?outbound/i) || + text.match(/(\d+)\s+TB\s+Outbound/i) || + text.match(/(\d+)\s+TB\b[^.]*?(?:traffic|bandwidth)/i) + const bandwidth: ParsedSpecs["bandwidth"] = unlimitedBw + ? null + : bwTbMatch + ? { amount: parseInt(bwTbMatch[1]), unit: "TB" } + : null + + // ── CPU model & hardware ─────────────────────────────────────────────────── + let cpuModel: string | undefined + let hardware: ParsedSpecs["hardware"] + + const ryzenMatch = text.match(/AMD\s+Ryzen[™™]?\s+\d+(?:\s+(?:PRO\s+)?\d+\w*)?/i) + const ampereMatch = text.match(/Ampere[®®]?\s+Altra[®®]?(?:\s+ARM64)?/i) + // Intel: Xeon, Core Ultra, Core i-series + const intelMatch = text.match(/Intel[®®]?\s+(?:Core[™™]?\s+Ultra\s+\d+(?:\s+\d+)?|Core[™™]?\s+i\d+[- ]\d+\w*|Xeon[®®]?(?:\s+\w+)*)/i) + + if (ryzenMatch) { cpuModel = ryzenMatch[0].trim(); hardware = "amd" } + else if (/\bamd\b/i.test(text)) { hardware = "amd" } + + if (ampereMatch) { cpuModel = "Ampere® Altra® ARM64"; hardware = "arm" } + else if (/\barm64?\b/i.test(text) && !ryzenMatch) { hardware = "arm" } + + if (intelMatch) { cpuModel = intelMatch[0].trim(); hardware = "intel" } + else if (/\bintel\b/i.test(text) && !ryzenMatch && !ampereMatch) { hardware = "intel" } + + // ── Short description ────────────────────────────────────────────────────── + // Pull the subtitle from the first bullet: "Spec Title: ." + let description: string | undefined + for (const line of lines) { + const m = line.match(/^.+?:\s+(.{20,}?\.)/) + if (m) { description = m[1].trim(); break } + } + + return { cpu, ramGB, storageGB, storageDescription, bandwidth, uplink, cpuModel, hardware, description } +} + +/** + * Derive SKU, lineup, and series from the billing product name. + * e.g. "COMP-RG1-8GB" → { sku: "COMP-RG1-8GB", lineup: "COMP", series: "RG1" } + */ +export function parseProductName(name: string): { + sku: string + lineup?: "BASE" | "COMP" | "GAME" | "ELITE" + series?: string +} { + const sku = name.toUpperCase().trim() + const parts = sku.split("-") + const LINEUPS = ["BASE", "COMP", "GAME", "ELITE"] as const + type Lineup = (typeof LINEUPS)[number] + const lineup = LINEUPS.includes(parts[0] as Lineup) ? (parts[0] as Lineup) : undefined + const series = parts[1] ?? undefined + return { sku, lineup, series } +} diff --git a/packages/core/products/billing-service.ts b/packages/core/products/billing-service.ts new file mode 100644 index 0000000..4bfb52b --- /dev/null +++ b/packages/core/products/billing-service.ts @@ -0,0 +1,122 @@ +import { unstable_cache } from "next/cache" +import type { GamePlanSpec } from "@/packages/core/types/servers/game" +import type { VpsPlanSpec } from "@/packages/core/types/servers/vps" +import type { DedicatedPlanSpec } from "@/packages/core/types/servers/dedicated" +import { + fetchAllBillingProducts, + getProductsByCategory, + getGbpPrice, + getPricesMap, + getStockStatus, + getBillingUrl, +} from "@/packages/core/lib/bytepay" +import { parseDescriptionSpecs, parseProductName } from "@/packages/core/lib/spec-parser" +import { POPULAR_SLUGS, DEFAULT_DDOS } from "@/packages/core/constants/product-overrides" + +const getCachedProducts = unstable_cache( + fetchAllBillingProducts, + ["billing-products"], + { revalidate: 300 }, +) + +/** + * 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) => { + const parsed = parseDescriptionSpecs(product.description) + + if (!parsed.ramGB || !parsed.storageGB) return [] + + return [ + { + id: product.slug, + ramGB: parsed.ramGB, + storageGB: parsed.storageGB, + bandwidth: parsed.bandwidth ?? null, + popular: POPULAR_SLUGS.has(`${categorySlug}/${product.slug}`), + priceGBP: getGbpPrice(product), + prices: getPricesMap(product), + stock: getStockStatus(product), + url: getBillingUrl(categorySlug, product.slug), + } satisfies GamePlanSpec, + ] + }) +} + +/** + * Returns live-priced dedicated server plans for the given billing category slug. + * All specs are parsed from the billing panel description automatically. + * Plans missing cores/ram/storage in their description are skipped. + */ +export async function getDedicatedPlans(categorySlug: string): Promise { + const all = await getCachedProducts() + return getProductsByCategory(all, categorySlug).flatMap((product) => { + const parsed = parseDescriptionSpecs(product.description) + + if (!parsed.ramGB) return [] + + return [ + { + id: product.slug, + hardware: parsed.hardware as DedicatedPlanSpec["hardware"], + cpuModel: parsed.cpuModel, + description: parsed.description, + cores: parsed.cpu, + ramGB: parsed.ramGB, + storageGB: parsed.storageGB, + storageDescription: parsed.storageDescription, + bandwidth: parsed.bandwidth ?? null, + uplink: parsed.uplink, + popular: POPULAR_SLUGS.has(`${categorySlug}/${product.slug}`), + priceGBP: getGbpPrice(product), + prices: getPricesMap(product), + stock: getStockStatus(product), + url: getBillingUrl(categorySlug, product.slug), + } satisfies DedicatedPlanSpec, + ] + }) +} + +/** + * Returns live-priced VPS plans for the given billing category slug. + * All specs are parsed from the billing panel description automatically. + * Series→location mapping and popular flags come from product-overrides.ts. + * Plans missing cpu/ram/storage in their description are skipped. + */ +export async function getVpsPlans(categorySlug: string): Promise { + const all = await getCachedProducts() + return getProductsByCategory(all, categorySlug).flatMap((product) => { + const parsed = parseDescriptionSpecs(product.description) + const { sku, lineup, series } = parseProductName(product.name) + + if (!parsed.cpu || !parsed.ramGB || !parsed.storageGB) return [] + + return [ + { + id: product.slug, + sku, + lineup, + series: series as VpsPlanSpec["series"], + hardware: parsed.hardware, + cpuModel: parsed.cpuModel, + location: undefined, + description: parsed.description, + cpu: parsed.cpu, + ramGB: parsed.ramGB, + storageGB: parsed.storageGB, + bandwidth: parsed.bandwidth ?? null, + uplink: parsed.uplink, + ddos: DEFAULT_DDOS, + popular: POPULAR_SLUGS.has(`${categorySlug}/${product.slug}`), + priceGBP: getGbpPrice(product), + prices: getPricesMap(product), + stock: getStockStatus(product), + url: getBillingUrl(categorySlug, product.slug), + } satisfies VpsPlanSpec, + ] + }) +} diff --git a/packages/core/types/servers/dedicated.ts b/packages/core/types/servers/dedicated.ts new file mode 100644 index 0000000..af15269 --- /dev/null +++ b/packages/core/types/servers/dedicated.ts @@ -0,0 +1,36 @@ +/** + * Shared interface for all dedicated (bare-metal) server plan specs. + * @param {string} id - Unique plan slug + * @param {string} [description] - Short marketing description + * @param {string} [cpuModel] - CPU model name e.g. "Intel® Xeon® E-2388G" + * @param {number} priceGBP - Monthly price in GBP (base currency) + * @param {number} cores - Physical CPU cores + * @param {number} ramGB - Allocated RAM in gigabytes + * @param {number} storageGB - Primary storage in gigabytes + * @param {{ amount: number; unit: "MB" | "GB" | "TB" } | null} bandwidth - Bandwidth allowance; null = unmetered + * @param {{ amount: number; unit: "Mbps" | "Gbps" }} [uplink] - Port speed + * @param {string} [location] - Data centre location + * @param {boolean} [popular] - Highlights the plan as a recommended/popular choice + * @param {string} url - Direct order URL on the billing portal + */ +export interface DedicatedPlanSpec { + id: string + description?: string + cpuModel?: string + hardware?: "amd" | "intel" + priceGBP: number + /** Physical CPU cores. May be undefined if not listed in the product description. */ + cores?: number + ramGB: number + storageGB?: number + /** Raw storage label, e.g. "2 × 1 TB NVMe SSD (RAID 1)" */ + storageDescription?: string + bandwidth: { amount: number; unit: "MB" | "GB" | "TB" } | null + uplink?: { amount: number; unit: "Mbps" | "Gbps" } + location?: string + popular?: boolean + url: string + stock?: "in_stock" | "out_of_stock" | "coming_soon" + /** Native billing prices per currency code, e.g. { GBP: 80, EUR: 92, USD: 99 } */ + prices?: Record +} diff --git a/packages/core/types/servers/game.ts b/packages/core/types/servers/game.ts index 9845bc1..dc99167 100644 --- a/packages/core/types/servers/game.ts +++ b/packages/core/types/servers/game.ts @@ -29,4 +29,6 @@ export interface GamePlanSpec { url?: string /** Availability status. Defaults to "in_stock" when omitted. */ stock?: "in_stock" | "out_of_stock" | "coming_soon" + /** Native billing prices per currency code, e.g. { GBP: 4, EUR: 4.59, USD: 5.37 } */ + prices?: Record } \ No newline at end of file diff --git a/packages/core/types/servers/vps.ts b/packages/core/types/servers/vps.ts index 7b6c5c8..d57769b 100644 --- a/packages/core/types/servers/vps.ts +++ b/packages/core/types/servers/vps.ts @@ -38,7 +38,9 @@ export interface VpsPlanSpec { /** QoS / resource-priority tier */ lineup?: "BASE" | "COMP" | "GAME" | "ELITE" /** Hardware generation identifier */ - series?: "RG1" | "RG3" | "RG4" | "IG3" | "IX1" + series?: "RG1" | "RG3" | "RG4" | "IG3" | "IX1" | "LND" | "ARM1" | "HZ3" /** CPU brand family */ - hardware?: "amd" | "intel" + hardware?: "amd" | "intel" | "arm" + /** Native billing prices per currency code, e.g. { GBP: 10, EUR: 11.50, USD: 12.75 } */ + prices?: Record } \ No newline at end of file diff --git a/packages/kb/components/kb-article-card.tsx b/packages/kb/components/kb-article-card.tsx index 500779f..b644875 100644 --- a/packages/kb/components/kb-article-card.tsx +++ b/packages/kb/components/kb-article-card.tsx @@ -7,6 +7,8 @@ export interface Article { slug: string; category: string; categorySlug: string; + /** Full relative path used for URL construction, e.g. "games/minecraft" */ + categoryPath: string; title: string; description: string; tags?: string[]; @@ -32,7 +34,7 @@ export function KBArticleCard({ translations, }: KBArticleCardProps) { return ( - +
{previousArticle ? (
@@ -162,7 +163,7 @@ export function KBArticle({ {nextArticle ? (
diff --git a/packages/kb/components/kb-category-card.tsx b/packages/kb/components/kb-category-card.tsx index ce0eedb..391acd2 100644 --- a/packages/kb/components/kb-category-card.tsx +++ b/packages/kb/components/kb-category-card.tsx @@ -1,60 +1,39 @@ -import Link from "next/link"; +import Link from "next/link" import { - Rocket, - Gamepad2, - CreditCard, - Users, - Shield, - Settings, - HelpCircle, - Server, + Rocket, Gamepad2, CreditCard, Users, Shield, Settings, + HelpCircle, Server, FileText, Blocks, Wrench, BookOpen, Network, type LucideIcon, -} from "lucide-react"; -import { cn } from "@/packages/core/lib/utils"; +} from "lucide-react" +import { cn } from "@/packages/core/lib/utils" import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/packages/ui/components/ui/card"; -import { Badge } from "@/packages/ui/components/ui/badge"; + Card, CardContent, CardDescription, CardHeader, CardTitle, +} from "@/packages/ui/components/ui/card" +import { Badge } from "@/packages/ui/components/ui/badge" +import type { KBCategory } from "@/packages/kb/lib/kb" -// Icon mapping for dynamic icons from _meta.json const iconMap: Record = { - Rocket, - Gamepad2, - CreditCard, - Users, - Shield, - Settings, - HelpCircle, - Server, -}; - -export interface Category { - slug: string; - title: string; - description: string; - icon: string; - order: number; - articleCount: number; + Rocket, Gamepad2, CreditCard, Users, Shield, Settings, + HelpCircle, Server, FileText, Blocks, Wrench, BookOpen, Network, } +// Re-export for callers that imported the old local Category type +export type { KBCategory as Category } + interface KBCategoryCardProps { - category: Category; - className?: string; + category: KBCategory + className?: string } export function KBCategoryCard({ category, className }: KBCategoryCardProps) { - const Icon = iconMap[category.icon] || HelpCircle; + const Icon = iconMap[category.icon] ?? HelpCircle + const count = category.totalCount return ( - + @@ -63,7 +42,7 @@ export function KBCategoryCard({ category, className }: KBCategoryCardProps) {
- {category.articleCount} {category.articleCount === 1 ? "article" : "articles"} + {count} {count === 1 ? "article" : "articles"}
@@ -80,29 +59,24 @@ export function KBCategoryCard({ category, className }: KBCategoryCardProps) { - ); + ) } interface KBCategoryGridProps { - categories: Category[]; - className?: string; + categories: KBCategory[] + className?: string } export function KBCategoryGrid({ categories, className }: KBCategoryGridProps) { - const sortedCategories = categories.toSorted((a, b) => a.order - b.order); + const sorted = categories.toSorted((a, b) => a.order - b.order) return ( -
- {sortedCategories.map((category) => ( - +
+ {sorted.map((category) => ( + ))}
- ); + ) } -export default KBCategoryCard; +export default KBCategoryCard diff --git a/packages/kb/components/kb-search.tsx b/packages/kb/components/kb-search.tsx index f7a805a..6dec81f 100644 --- a/packages/kb/components/kb-search.tsx +++ b/packages/kb/components/kb-search.tsx @@ -19,6 +19,8 @@ import { cn } from "@/packages/core/lib/utils"; interface SearchResult { slug: string; category: string; + /** Full relative path for URL construction, e.g. "games/minecraft" */ + categoryPath: string; title: string; description: string; excerpt: string; @@ -78,8 +80,8 @@ function SearchContent({
{results.map((result) => ( diff --git a/packages/kb/components/kb-sidebar.tsx b/packages/kb/components/kb-sidebar.tsx index 822927e..9bb0cf7 100644 --- a/packages/kb/components/kb-sidebar.tsx +++ b/packages/kb/components/kb-sidebar.tsx @@ -1,143 +1,136 @@ -"use client"; +"use client" -import Link from "next/link"; -import { usePathname } from "next/navigation"; +import Link from "next/link" +import { usePathname } from "next/navigation" import { - Rocket, - Gamepad2, - CreditCard, - Users, - Shield, - Settings, - HelpCircle, - Server, - ChevronDown, - FileText, - type LucideIcon, -} from "lucide-react"; -import { cn } from "@/packages/core/lib/utils"; + Rocket, Gamepad2, CreditCard, Users, Shield, Settings, + HelpCircle, Server, FileText, Blocks, Wrench, BookOpen, Network, + ChevronDown, type LucideIcon, +} from "lucide-react" +import { cn } from "@/packages/core/lib/utils" import { Collapsible, CollapsibleContent, CollapsibleTrigger, -} from "@/packages/ui/components/ui/collapsible"; -import { ScrollArea } from "@/packages/ui/components/ui/scroll-area"; -import { useState } from "react"; +} from "@/packages/ui/components/ui/collapsible" +import { ScrollArea } from "@/packages/ui/components/ui/scroll-area" +import { useState } from "react" +import type { SidebarItem } from "@/packages/kb/lib/kb" -// Icon mapping for dynamic icons const iconMap: Record = { - Rocket, - Gamepad2, - CreditCard, - Users, - Shield, - Settings, - HelpCircle, - Server, -}; - -export interface SidebarCategory { - slug: string; - title: string; - icon: string; - order: number; - articles: { - slug: string; - title: string; - order: number; - }[]; + Rocket, Gamepad2, CreditCard, Users, Shield, Settings, + HelpCircle, Server, FileText, Blocks, Wrench, BookOpen, Network, } +// Re-export for backward compatibility +export type { SidebarItem as SidebarCategory } + interface KBSidebarProps { - categories: SidebarCategory[]; - className?: string; + categories: SidebarItem[] + className?: string +} + +// ─── Recursive node renderer ────────────────────────────────────────────────── + +interface SidebarNodeProps { + item: SidebarItem + pathname: string + depth?: number +} + +function SidebarNode({ item, pathname, depth = 0 }: SidebarNodeProps) { + const Icon = iconMap[item.icon] ?? HelpCircle + const categoryPrefix = `/kb/${item.path}` + const isCategoryActive = pathname.startsWith(categoryPrefix) + + const [isOpen, setIsOpen] = useState(() => isCategoryActive) + + const sortedArticles = item.articles.toSorted((a, b) => a.order - b.order) + const sortedSubs = item.subcategories.toSorted((a, b) => a.order - b.order) + const hasChildren = sortedArticles.length > 0 || sortedSubs.length > 0 + + return ( + + 0 && "text-sm font-medium", + isCategoryActive && "bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary", + )} + style={{ paddingLeft: depth > 0 ? `${0.75 + depth * 0.75}rem` : undefined }} + > + + + {item.title} + + {hasChildren && ( + + )} + + + {hasChildren && ( + +
    0 ? `${1 + depth * 0.75}rem` : undefined }} + > + {/* Subcategories first */} + {sortedSubs.map((sub) => ( +
  • + +
  • + ))} + + {/* Then direct articles */} + {sortedArticles.map((article) => { + const articlePath = `/kb/${article.categoryPath}/${article.slug}` + const isActive = pathname === articlePath + + return ( +
  • + + + {article.title} + +
  • + ) + })} +
+
+ )} +
+ ) } +// ─── Sidebar ────────────────────────────────────────────────────────────────── + export function KBSidebar({ categories, className }: KBSidebarProps) { - const pathname = usePathname(); - const [openCategories, setOpenCategories] = useState(() => { - // Open the category that contains the current article - const currentCategory = categories.find((cat) => - pathname.includes(`/kb/${cat.slug}`) - ); - return currentCategory ? [currentCategory.slug] : []; - }); - - const toggleCategory = (slug: string) => { - setOpenCategories((prev) => - prev.includes(slug) - ? prev.filter((s) => s !== slug) - : [...prev, slug] - ); - }; - - const sortedCategories = categories.toSorted((a, b) => a.order - b.order); + const pathname = usePathname() + const sorted = categories.toSorted((a, b) => a.order - b.order) return ( -