Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 1 addition & 9 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@ import { ThemeProvider } from "@/packages/ui/components/theme-provider"
import { CurrencyProvider } from "@/packages/core/hooks/use-currency"
import { LocaleProvider } from "@/packages/core/hooks/use-locale"
import { LayoutChrome } from "@/packages/ui/components/layout-chrome"
import { getCategoryHub } from "@/packages/core/lib/bytepay"
import { GAME_HUB_SLUGS } from "@/packages/core/constants/catalog-hubs"

const geist = Geist({
subsets: ["latin"],
Expand Down Expand Up @@ -95,12 +93,6 @@ export default async function RootLayout({
const locale = await getLocale()
const messages = await getMessages()

// Never let a billing-panel outage take down every page on the site — the
// nav just falls back to no games submenu entries if this fails.
const gamesNav = await getCategoryHub(GAME_HUB_SLUGS)
.then((hub) => (hub?.children ?? []).map((c) => ({ slug: c.slug, name: c.name })))
.catch(() => [])

const htmlClass = [geist.variable, geistMono.variable, themeClass].filter(Boolean).join(" ")

return (
Expand Down Expand Up @@ -156,7 +148,7 @@ export default async function RootLayout({
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<CurrencyProvider>
<LocaleProvider initialLocale={locale as any}>
<LayoutChrome gamesNav={gamesNav}>
<LayoutChrome>
{children}
</LayoutChrome>
</LocaleProvider>
Expand Down
11 changes: 11 additions & 0 deletions app/partners/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { PartnersPage } from "@/packages/ui/components/Layouts/Partners"
import type { Metadata } from "next"

export const metadata: Metadata = {
title: "Partners & Sponsors",
description: "Meet the communities, creators, and projects we partner with, and learn how to join the NodeByte Partnership Program.",
}

export default function Partners() {
return <PartnersPage />
}
1 change: 1 addition & 0 deletions app/sitemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const staticRoutes: Array<{
{ path: "/vps/amd", priority: 0.9, changeFrequency: "weekly" },
{ path: "/vps/intel", priority: 0.8, changeFrequency: "monthly" },
{ path: "/about", priority: 0.6, changeFrequency: "monthly" },
{ path: "/partners", priority: 0.5, changeFrequency: "monthly" },
{ path: "/contact", priority: 0.7, changeFrequency: "monthly" },
{ path: "/changelog", priority: 0.5, changeFrequency: "weekly" },
{ path: "/kb", priority: 0.7, changeFrequency: "weekly" },
Expand Down
42 changes: 42 additions & 0 deletions packages/core/constants/partners.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* Partners & sponsors shown on /partners.
*
* Add or remove entries here — the page groups them by `tier` automatically
* and shows an empty-state invite for any tier with zero entries, so this
* file can be edited freely without touching the page component.
*
* There's no real partner/sponsor data yet — the entries below are clearly
* marked placeholders. Replace them (or delete down to an empty array) once
* real partners sign on.
*/

export type PartnerTier = "sponsor" | "partner"

export interface PartnerEntry {
id: string
name: string
tier: PartnerTier
/** Path under /public, e.g. "/partners/example.svg" */
logo: string
url: string
description: string
}

export const PARTNERS: PartnerEntry[] = [
{
id: "embrly",
name: "Emberly",
tier: "partner",
logo: "https://embrly.ca/icon.svg",
url: "https://embrly.ca",
description: "The open-source platform for secure file sharing and team collaboration. Upload, manage, and share content with custom domains, rich embeds, and built-in talent discovery.",
},
{
id: "octo",
name: "octoflow",
tier: "partner",
logo: "https://octoflow.ca/logo.png",
url: "https://octoflow.ca",
description: "Keep your team connected to every commit, pull request, and deployment without ever leaving your Discord server.",
},
]
41 changes: 33 additions & 8 deletions packages/core/lib/bytepay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,13 +204,21 @@ async function fetchCategoryTree(): Promise<CategoryHub[]> {

return categories
.filter((cat) => !cat.parentId)
.map((hub) => ({
id: hub.id,
name: hub.name,
slug: hub.slug,
description: hub.description,
children: byParent.get(hub.id) ?? [],
}))
.map((hub) => {
const children = byParent.get(hub.id) ?? []
return {
id: hub.id,
name: hub.name,
slug: hub.slug,
description: hub.description,
// A hub with no sub-categories in Paymenter IS the leaf its products
// belong to directly (e.g. bare-metal servers filed straight under
// "Dedicated Servers" with no tiers underneath yet) — treat it as
// its own single child so pages that iterate `hub.children` still
// find those products instead of seeing an empty list.
children: children.length > 0 ? children : [{ id: hub.id, name: hub.name, slug: hub.slug, description: hub.description, parentId: null }],
}
})
}

export const getCachedCategoryTree = unstable_cache(fetchCategoryTree, ["billing-category-tree"], {
Expand All @@ -221,11 +229,28 @@ export const getCachedCategoryTree = unstable_cache(fetchCategoryTree, ["billing
* 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<CategoryHub | null> {
const aliases = Array.isArray(hubSlugOrAliases) ? hubSlugOrAliases : [hubSlugOrAliases]
const tree = await getCachedCategoryTree()
return tree.find((hub) => aliases.includes(hub.slug)) ?? null

const exact = tree.find((hub) => aliases.includes(hub.slug))
if (exact) return exact

const fuzzy = tree.find((hub) => aliases.some((alias) => hub.slug.includes(alias)))
if (fuzzy) {
console.warn(
`[bytepay] No category exactly matched hub aliases [${aliases.join(", ")}] — falling back to "${fuzzy.name}" (slug "${fuzzy.slug}") via substring match. Rename the Paymenter category, or add its slug to the alias list, to make this exact.`,
)
return fuzzy
}

return null
}

/**
Expand Down
110 changes: 71 additions & 39 deletions packages/core/lib/spec-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,23 +33,52 @@ function stripHtml(html: string): string {
return html.replace(/<[^>]*>/g, " ").replace(/&amp;/g, "&").replace(/\s+/g, " ").trim()
}

/** Split stripped text into individual bullet lines for more reliable matching. */
function bulletLines(text: string): string[] {
/**
* Split into individual bullet lines. Paymenter descriptions are usually a
* `<ul><li>` list (one bullet per `<li>`); older ones instead separate
* bullets with a "•" character or bare newlines. Insert a line break at each
* list-item/paragraph/`<br>` boundary before stripping tags so line-based
* extraction below sees one bullet per line regardless of which format the
* description actually uses.
*/
function bulletLines(html: string): string[] {
const withBreaks = html
.replace(/<\/(li|p|div|h[1-6])>/gi, "\n")
.replace(/<br\s*\/?>/gi, "\n")
const text = withBreaks
.replace(/<[^>]*>/g, " ")
.replace(/&amp;/g, "&")
.replace(/[ \t]+/g, " ")
return text
.split(/[•\n]/)
.map((s) => s.trim())
.filter(Boolean)
}

/** First "<number> GB|TB" in a line, converted to GB (TB × 1024). Returns undefined if the line has none. */
function firstSizeGB(line: string): number | undefined {
const m = line.match(/(\d+)\s*(GB|TB)\b/i)
if (!m) return undefined
const amount = parseInt(m[1])
return /tb/i.test(m[2]) ? amount * 1024 : amount
}
Comment on lines +58 to +64

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Parse decimal capacities instead of trailing digits.

Line 60 parses 1.92 TB as 92 TB, returning 94,208 GB instead of about 1,966 GB. This corrupts specs for valid decimal-capacity descriptions.

Proposed fix
 function firstSizeGB(line: string): number | undefined {
-  const m = line.match(/(\d+)\s*(GB|TB)\b/i)
+  const m = line.match(/(\d+(?:\.\d+)?)\s*(GB|TB)\b/i)
   if (!m) return undefined
-  const amount = parseInt(m[1])
+  const amount = Number.parseFloat(m[1])
   return /tb/i.test(m[2]) ? amount * 1024 : amount
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/** First "<number> GB|TB" in a line, converted to GB (TB × 1024). Returns undefined if the line has none. */
function firstSizeGB(line: string): number | undefined {
const m = line.match(/(\d+)\s*(GB|TB)\b/i)
if (!m) return undefined
const amount = parseInt(m[1])
return /tb/i.test(m[2]) ? amount * 1024 : amount
}
/** First "<number> GB|TB" in a line, converted to GB (TB × 1024). Returns undefined if the line has none. */
function firstSizeGB(line: string): number | undefined {
const m = line.match(/(\d+(?:\.\d+)?)\s*(GB|TB)\b/i)
if (!m) return undefined
const amount = Number.parseFloat(m[1])
return /tb/i.test(m[2]) ? amount * 1024 : amount
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/lib/spec-parser.ts` around lines 58 - 64, Update firstSizeGB to
capture decimal capacity values, not just digits, so inputs such as “1.92 TB”
parse as 1.92 before unit conversion. Preserve the existing GB/TB matching,
case-insensitive handling, and undefined result when no capacity is present.


export function parseDescriptionSpecs(html: string | null): ParsedSpecs {
if (!html) return {}

const text = stripHtml(html)
const lines = bulletLines(text)
const lines = bulletLines(html)

// ── 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"
// "ELITE Resource Allocation: 8 Dedicated/Pinned Physical Cores and 16 threads"
// Fallback: "8 cores and 16 threads", "Octa-Core", "Quad-Core", "2 vCPU"
//
// Deliberately NOT a "grab any number near the word core/cpu" fallback —
// descriptions also state the CPU *model* number in the same breath
// ("Ryzen™ 9 5900X", "Core Ultra 7 265"), and a wrong core count is worse
// than none: it ships incorrect specs instead of just dropping the plan
// (which surfaces via warnDroppedProduct so it gets fixed at the source).
const NAMED_CORES: Record<string, number> = { mono: 1, dual: 2, quad: 4, hexa: 6, octa: 8, deca: 10, dodeca: 12 }
let cpu: number | undefined
for (const line of lines) {
Expand All @@ -60,9 +89,18 @@ export function parseDescriptionSpecs(html: string | null): ParsedSpecs {
}
}
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])
// Number and "Cores" in the same bullet but not at the start of it — e.g.
// "ELITE Resource Allocation: 8 Dedicated/Pinned Physical Cores and 16
// threads". Allow up to 4 filler words between the number and "Cores",
// bounded per-line so it can't reach into an unrelated bullet.
// Requires the plural "Cores", not "Core" — singular "Core" shows up in
// non-count marketing phrases too ("Ryzen™ 9 5900X Core Processing",
// "Core Ultra 7 265"), where the preceding number is a CPU model number,
// not a core count, and guessing wrong is worse than leaving it unset.
for (const line of lines) {
const m = line.match(/(\d+)\s+(?:[\w/.-]+\s+){0,4}?[Cc]ores\b/)
if (m) { cpu = parseInt(m[1]); break }
}
}
if (!cpu) {
// "Octa-Core", "Quad-Core" etc
Expand All @@ -77,47 +115,39 @@ export function parseDescriptionSpecs(html: string | null): ParsedSpecs {
}

// ── RAM ────────────────────────────────────────────────────────────────────
// "2 GB DDR4 RAM", "4 GB ECC RAM", "8GB DDR4 RAM", "1 GB RAM"
// "4 GB High-Speed DDR4 RAM" — hyphenated adjectives allowed between the size and "RAM"
const ramMatch = text.match(/(\d+)\s*GB\s+(?:[\w-]+\s+)*?RAM\b/i)
const ramGB = ramMatch ? parseInt(ramMatch[1]) : undefined
const ramTypeMatch = ramMatch ? ramMatch[0].match(/DDR\s?([345])/i) : null
// Any bullet mentioning "RAM" or "memory" is classified as the RAM line;
// the first GB figure in that line is the amount, wherever it sits.
// "2 GB DDR4 RAM", "4 GB ECC RAM", "RAM: 16GB of fast memory"
const ramLine = lines.find((line) => /\bram\b|\bmemory\b/i.test(line))
const ramGB = ramLine ? firstSizeGB(ramLine) : undefined
const ramTypeMatch = ramLine ? ramLine.match(/DDR\s?([345])/i) : null
const ramType = ramTypeMatch ? `DDR${ramTypeMatch[1]}` : undefined

// ── Storage ────────────────────────────────────────────────────────────────
// "25 GB SSD", "40 GB NVMe SSD", "100 GB SSD Storage", "40GB Disk Storage"
// "80 GB Local NVMe Storage" (no SSD/HDD/Disk keyword)
// Also handles TB drives: "2 x 1 TB NVMe SSD", "4 x 16 TB SATA HDD"
const storageMatchGB = text.match(/(\d+)\s*GB\s+(?:Local\s+)?(?:NVMe\s+)?(?:SSD|Disk|HDD|Storage)\b/i)
const storageMatchTB = !storageMatchGB
? text.match(/(\d+)\s*TB\s+(?:NVMe\s+|Enterprise\s+|SATA\s+)?(?:SSD|HDD|Disk)/i)
: null
const storageGB = storageMatchGB
? parseInt(storageMatchGB[1])
: storageMatchTB
? parseInt(storageMatchTB[1]) * 1024
: undefined

const storageMatchText = storageMatchGB?.[0] ?? storageMatchTB?.[0]
const storageType: ParsedSpecs["storageType"] = storageMatchText
? /nvme/i.test(storageMatchText)
// Any bullet mentioning a storage-ish keyword is classified as the storage
// line; the first GB/TB figure in it is the amount. Tolerates arbitrary
// wording/ordering — "80 GB Local NVMe Storage", "Storage: 80GB of blazing
// SSD space", "2 x 1 TB NVMe SSD (RAID 1)" all resolve the same way.
const storageLine = lines.find((line) => /\b(?:ssd|hdd|nvme|storage|disk|drive)\b/i.test(line))
const storageGB = storageLine ? firstSizeGB(storageLine) : undefined

const storageType: ParsedSpecs["storageType"] = storageLine
? /nvme/i.test(storageLine)
? "nvme"
: /ssd/i.test(storageMatchText)
: /ssd/i.test(storageLine)
? "ssd"
: /hdd/i.test(storageMatchText)
: /hdd/i.test(storageLine)
? "hdd"
: "generic"
: 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
}
}
const storageDescription = storageLine
? (() => {
const beforeColon = storageLine.split(':')[0].trim()
return beforeColon.length > 4 && beforeColon.length < 80 ? beforeColon : undefined
})()
: undefined
Comment on lines 144 to +150

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Extract the storage configuration, not its label.

For Storage: 2 × 1 TB NVMe SSD (RAID 1), Line 147 returns Storage, discarding the raw configuration promised by storageDescription.

Proposed fix
   const storageDescription = storageLine
     ? (() => {
-        const beforeColon = storageLine.split(':')[0].trim()
-        return beforeColon.length > 4 && beforeColon.length < 80 ? beforeColon : undefined
+        const colonIndex = storageLine.indexOf(':')
+        const rawLabel = (
+          colonIndex === -1 ? storageLine : storageLine.slice(colonIndex + 1)
+        ).trim()
+        return rawLabel.length > 4 && rawLabel.length < 80 ? rawLabel : undefined
       })()
     : undefined
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 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
}
}
const storageDescription = storageLine
? (() => {
const beforeColon = storageLine.split(':')[0].trim()
return beforeColon.length > 4 && beforeColon.length < 80 ? beforeColon : undefined
})()
: undefined
// Raw storage label for multi-drive dedicated configs
const storageDescription = storageLine
? (() => {
const colonIndex = storageLine.indexOf(':')
const rawLabel = (
colonIndex === -1 ? storageLine : storageLine.slice(colonIndex + 1)
).trim()
return rawLabel.length > 4 && rawLabel.length < 80 ? rawLabel : undefined
})()
: undefined
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/lib/spec-parser.ts` around lines 144 - 150, Update the
storageDescription extraction in the spec parser to retain the raw storage
configuration after the “Storage:” label, such as “2 × 1 TB NVMe SSD (RAID 1)”,
rather than returning the text before the colon. Preserve the existing trimming
and length validation for the extracted configuration.


// ── Uplink ─────────────────────────────────────────────────────────────────
// "1 Gbps Network Port", "4 Gbps"
Expand Down Expand Up @@ -146,7 +176,9 @@ export function parseDescriptionSpecs(html: string | null): ParsedSpecs {
let cpuModel: string | undefined
let hardware: ParsedSpecs["hardware"]

const ryzenMatch = text.match(/AMD\s+Ryzen[™™]?\s+\d+(?:\s+(?:PRO\s+)?\d+\w*)?/i)
// "AMD Ryzen™ 9 5900X", "Ryzen 3700X" (single model token, no series digit,
// "AMD" prefix not always stated — "Ryzen" alone is unambiguously AMD)
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)
Expand Down
6 changes: 3 additions & 3 deletions packages/ui/components/Layouts/Dedicated/dedicated-hub.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ function PlanCard({ plan }: { plan: DedicatedPlanSpec }) {
return (
<div
className={cn(
"relative rounded-2xl border bg-card/30 backdrop-blur-sm transition-all duration-300",
"relative h-full flex flex-col rounded-2xl border bg-card/30 backdrop-blur-sm transition-all duration-300",
"hover:shadow-xl hover:shadow-primary/5",
plan.popular
? "border-primary/40 hover:border-primary/60"
Expand All @@ -67,7 +67,7 @@ function PlanCard({ plan }: { plan: DedicatedPlanSpec }) {
</div>
)}

<div className="p-5 space-y-4">
<div className="p-5 flex flex-col flex-1 gap-4">
{/* Header row */}
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="space-y-1.5">
Expand Down Expand Up @@ -149,7 +149,7 @@ function PlanCard({ plan }: { plan: DedicatedPlanSpec }) {
<Button
size="sm"
variant={outOfStock ? "outline" : "default"}
className="w-full gap-2 rounded-lg"
className="w-full gap-2 rounded-lg mt-auto"
disabled={outOfStock}
asChild={!outOfStock}
>
Expand Down
1 change: 1 addition & 0 deletions packages/ui/components/Layouts/Partners/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { PartnersPage } from "./partners-page"
Loading