Skip to content

Commit 8fcec41

Browse files
authored
Merge pull request #136 from NodeByteHosting/development
Development
2 parents a0dde70 + 0e2e8c0 commit 8fcec41

14 files changed

Lines changed: 391 additions & 185 deletions

File tree

‎app/layout.tsx‎

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,6 @@ import { ThemeProvider } from "@/packages/ui/components/theme-provider"
1414
import { CurrencyProvider } from "@/packages/core/hooks/use-currency"
1515
import { LocaleProvider } from "@/packages/core/hooks/use-locale"
1616
import { LayoutChrome } from "@/packages/ui/components/layout-chrome"
17-
import { getCategoryHub } from "@/packages/core/lib/bytepay"
18-
import { GAME_HUB_SLUGS } from "@/packages/core/constants/catalog-hubs"
1917

2018
const geist = Geist({
2119
subsets: ["latin"],
@@ -95,12 +93,6 @@ export default async function RootLayout({
9593
const locale = await getLocale()
9694
const messages = await getMessages()
9795

98-
// Never let a billing-panel outage take down every page on the site — the
99-
// nav just falls back to no games submenu entries if this fails.
100-
const gamesNav = await getCategoryHub(GAME_HUB_SLUGS)
101-
.then((hub) => (hub?.children ?? []).map((c) => ({ slug: c.slug, name: c.name })))
102-
.catch(() => [])
103-
10496
const htmlClass = [geist.variable, geistMono.variable, themeClass].filter(Boolean).join(" ")
10597

10698
return (
@@ -156,7 +148,7 @@ export default async function RootLayout({
156148
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
157149
<CurrencyProvider>
158150
<LocaleProvider initialLocale={locale as any}>
159-
<LayoutChrome gamesNav={gamesNav}>
151+
<LayoutChrome>
160152
{children}
161153
</LayoutChrome>
162154
</LocaleProvider>

‎app/partners/page.tsx‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { PartnersPage } from "@/packages/ui/components/Layouts/Partners"
2+
import type { Metadata } from "next"
3+
4+
export const metadata: Metadata = {
5+
title: "Partners & Sponsors",
6+
description: "Meet the communities, creators, and projects we partner with, and learn how to join the NodeByte Partnership Program.",
7+
}
8+
9+
export default function Partners() {
10+
return <PartnersPage />
11+
}

‎app/sitemap.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const staticRoutes: Array<{
1717
{ path: "/vps/amd", priority: 0.9, changeFrequency: "weekly" },
1818
{ path: "/vps/intel", priority: 0.8, changeFrequency: "monthly" },
1919
{ path: "/about", priority: 0.6, changeFrequency: "monthly" },
20+
{ path: "/partners", priority: 0.5, changeFrequency: "monthly" },
2021
{ path: "/contact", priority: 0.7, changeFrequency: "monthly" },
2122
{ path: "/changelog", priority: 0.5, changeFrequency: "weekly" },
2223
{ path: "/kb", priority: 0.7, changeFrequency: "weekly" },
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/**
2+
* Partners & sponsors shown on /partners.
3+
*
4+
* Add or remove entries here — the page groups them by `tier` automatically
5+
* and shows an empty-state invite for any tier with zero entries, so this
6+
* file can be edited freely without touching the page component.
7+
*
8+
* There's no real partner/sponsor data yet — the entries below are clearly
9+
* marked placeholders. Replace them (or delete down to an empty array) once
10+
* real partners sign on.
11+
*/
12+
13+
export type PartnerTier = "sponsor" | "partner"
14+
15+
export interface PartnerEntry {
16+
id: string
17+
name: string
18+
tier: PartnerTier
19+
/** Path under /public, e.g. "/partners/example.svg" */
20+
logo: string
21+
url: string
22+
description: string
23+
}
24+
25+
export const PARTNERS: PartnerEntry[] = [
26+
{
27+
id: "embrly",
28+
name: "Emberly",
29+
tier: "partner",
30+
logo: "https://embrly.ca/icon.svg",
31+
url: "https://embrly.ca",
32+
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.",
33+
},
34+
{
35+
id: "octo",
36+
name: "octoflow",
37+
tier: "partner",
38+
logo: "https://octoflow.ca/logo.png",
39+
url: "https://octoflow.ca",
40+
description: "Keep your team connected to every commit, pull request, and deployment without ever leaving your Discord server.",
41+
},
42+
]

‎packages/core/lib/bytepay.ts‎

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -204,13 +204,21 @@ async function fetchCategoryTree(): Promise<CategoryHub[]> {
204204

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

216224
export const getCachedCategoryTree = unstable_cache(fetchCategoryTree, ["billing-category-tree"], {
@@ -221,11 +229,28 @@ export const getCachedCategoryTree = unstable_cache(fetchCategoryTree, ["billing
221229
* Find a hub by its slugified name — accepts one or more acceptable aliases
222230
* (e.g. "vps-hosting" or "vps") since the exact parent category name is
223231
* whatever's configured in Paymenter.
232+
*
233+
* Falls back to a substring match (a hub whose slug *contains* one of the
234+
* aliases) if no exact match is found, so a rename like "VPS Servers" →
235+
* "VPS Plans 2026" doesn't silently empty out the whole section — it just
236+
* warns, since the fallback match is a guess rather than a guarantee.
224237
*/
225238
export async function getCategoryHub(hubSlugOrAliases: string | string[]): Promise<CategoryHub | null> {
226239
const aliases = Array.isArray(hubSlugOrAliases) ? hubSlugOrAliases : [hubSlugOrAliases]
227240
const tree = await getCachedCategoryTree()
228-
return tree.find((hub) => aliases.includes(hub.slug)) ?? null
241+
242+
const exact = tree.find((hub) => aliases.includes(hub.slug))
243+
if (exact) return exact
244+
245+
const fuzzy = tree.find((hub) => aliases.some((alias) => hub.slug.includes(alias)))
246+
if (fuzzy) {
247+
console.warn(
248+
`[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.`,
249+
)
250+
return fuzzy
251+
}
252+
253+
return null
229254
}
230255

231256
/**

‎packages/core/lib/spec-parser.ts‎

Lines changed: 71 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -33,23 +33,52 @@ function stripHtml(html: string): string {
3333
return html.replace(/<[^>]*>/g, " ").replace(/&amp;/g, "&").replace(/\s+/g, " ").trim()
3434
}
3535

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

58+
/** First "<number> GB|TB" in a line, converted to GB (TB × 1024). Returns undefined if the line has none. */
59+
function firstSizeGB(line: string): number | undefined {
60+
const m = line.match(/(\d+)\s*(GB|TB)\b/i)
61+
if (!m) return undefined
62+
const amount = parseInt(m[1])
63+
return /tb/i.test(m[2]) ? amount * 1024 : amount
64+
}
65+
4466
export function parseDescriptionSpecs(html: string | null): ParsedSpecs {
4567
if (!html) return {}
4668

4769
const text = stripHtml(html)
48-
const lines = bulletLines(text)
70+
const lines = bulletLines(html)
4971

5072
// ── CPU cores ──────────────────────────────────────────────────────────────
5173
// "1 Core of Ryzen 7 Power", "2 Cores of Ryzen 7", "2 Ampere® Altra® ARM64 Cores"
52-
// Fallback: "8 cores and 16 threads", "Octa-Core", "Quad-Core"
74+
// "ELITE Resource Allocation: 8 Dedicated/Pinned Physical Cores and 16 threads"
75+
// Fallback: "8 cores and 16 threads", "Octa-Core", "Quad-Core", "2 vCPU"
76+
//
77+
// Deliberately NOT a "grab any number near the word core/cpu" fallback —
78+
// descriptions also state the CPU *model* number in the same breath
79+
// ("Ryzen™ 9 5900X", "Core Ultra 7 265"), and a wrong core count is worse
80+
// than none: it ships incorrect specs instead of just dropping the plan
81+
// (which surfaces via warnDroppedProduct so it gets fixed at the source).
5382
const NAMED_CORES: Record<string, number> = { mono: 1, dual: 2, quad: 4, hexa: 6, octa: 8, deca: 10, dodeca: 12 }
5483
let cpu: number | undefined
5584
for (const line of lines) {
@@ -60,9 +89,18 @@ export function parseDescriptionSpecs(html: string | null): ParsedSpecs {
6089
}
6190
}
6291
if (!cpu) {
63-
// "8 cores and 16 threads" — number before "cores" anywhere in text
64-
const m = text.match(/\b(\d+)\s+[Cc]ores?\b/)
65-
if (m) cpu = parseInt(m[1])
92+
// Number and "Cores" in the same bullet but not at the start of it — e.g.
93+
// "ELITE Resource Allocation: 8 Dedicated/Pinned Physical Cores and 16
94+
// threads". Allow up to 4 filler words between the number and "Cores",
95+
// bounded per-line so it can't reach into an unrelated bullet.
96+
// Requires the plural "Cores", not "Core" — singular "Core" shows up in
97+
// non-count marketing phrases too ("Ryzen™ 9 5900X Core Processing",
98+
// "Core Ultra 7 265"), where the preceding number is a CPU model number,
99+
// not a core count, and guessing wrong is worse than leaving it unset.
100+
for (const line of lines) {
101+
const m = line.match(/(\d+)\s+(?:[\w/.-]+\s+){0,4}?[Cc]ores\b/)
102+
if (m) { cpu = parseInt(m[1]); break }
103+
}
66104
}
67105
if (!cpu) {
68106
// "Octa-Core", "Quad-Core" etc
@@ -77,47 +115,39 @@ export function parseDescriptionSpecs(html: string | null): ParsedSpecs {
77115
}
78116

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

87126
// ── Storage ────────────────────────────────────────────────────────────────
88-
// "25 GB SSD", "40 GB NVMe SSD", "100 GB SSD Storage", "40GB Disk Storage"
89-
// "80 GB Local NVMe Storage" (no SSD/HDD/Disk keyword)
90-
// Also handles TB drives: "2 x 1 TB NVMe SSD", "4 x 16 TB SATA HDD"
91-
const storageMatchGB = text.match(/(\d+)\s*GB\s+(?:Local\s+)?(?:NVMe\s+)?(?:SSD|Disk|HDD|Storage)\b/i)
92-
const storageMatchTB = !storageMatchGB
93-
? text.match(/(\d+)\s*TB\s+(?:NVMe\s+|Enterprise\s+|SATA\s+)?(?:SSD|HDD|Disk)/i)
94-
: null
95-
const storageGB = storageMatchGB
96-
? parseInt(storageMatchGB[1])
97-
: storageMatchTB
98-
? parseInt(storageMatchTB[1]) * 1024
99-
: undefined
100-
101-
const storageMatchText = storageMatchGB?.[0] ?? storageMatchTB?.[0]
102-
const storageType: ParsedSpecs["storageType"] = storageMatchText
103-
? /nvme/i.test(storageMatchText)
127+
// Any bullet mentioning a storage-ish keyword is classified as the storage
128+
// line; the first GB/TB figure in it is the amount. Tolerates arbitrary
129+
// wording/ordering — "80 GB Local NVMe Storage", "Storage: 80GB of blazing
130+
// SSD space", "2 x 1 TB NVMe SSD (RAID 1)" all resolve the same way.
131+
const storageLine = lines.find((line) => /\b(?:ssd|hdd|nvme|storage|disk|drive)\b/i.test(line))
132+
const storageGB = storageLine ? firstSizeGB(storageLine) : undefined
133+
134+
const storageType: ParsedSpecs["storageType"] = storageLine
135+
? /nvme/i.test(storageLine)
104136
? "nvme"
105-
: /ssd/i.test(storageMatchText)
137+
: /ssd/i.test(storageLine)
106138
? "ssd"
107-
: /hdd/i.test(storageMatchText)
139+
: /hdd/i.test(storageLine)
108140
? "hdd"
109141
: "generic"
110142
: undefined
111143

112144
// Raw storage label for multi-drive dedicated configs
113-
let storageDescription: string | undefined
114-
for (const line of lines) {
115-
if (/\b(?:NVMe|SSD|HDD)\b/i.test(line)) {
116-
const beforeColon = line.split(':')[0].trim()
117-
if (beforeColon.length > 4 && beforeColon.length < 80) storageDescription = beforeColon
118-
break
119-
}
120-
}
145+
const storageDescription = storageLine
146+
? (() => {
147+
const beforeColon = storageLine.split(':')[0].trim()
148+
return beforeColon.length > 4 && beforeColon.length < 80 ? beforeColon : undefined
149+
})()
150+
: undefined
121151

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

149-
const ryzenMatch = text.match(/AMD\s+Ryzen[™™]?\s+\d+(?:\s+(?:PRO\s+)?\d+\w*)?/i)
179+
// "AMD Ryzen™ 9 5900X", "Ryzen 3700X" (single model token, no series digit,
180+
// "AMD" prefix not always stated — "Ryzen" alone is unambiguously AMD)
181+
const ryzenMatch = text.match(/(?:AMD\s+)?Ryzen[™™]?\s+(?:\d+\s+)?(?:PRO\s+)?\d+\w*/i)
150182
const ampereMatch = text.match(/Ampere[®®]?\s+Altra[®®]?(?:\s+ARM64)?/i)
151183
// Intel: Xeon, Core Ultra, Core i-series
152184
const intelMatch = text.match(/Intel[®®]?\s+(?:Core[™™]?\s+Ultra\s+\d+(?:\s+\d+)?|Core[™™]?\s+i\d+[- ]\d+\w*|Xeon[®®]?(?:\s+\w+)*)/i)

‎packages/ui/components/Layouts/Dedicated/dedicated-hub.tsx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ function PlanCard({ plan }: { plan: DedicatedPlanSpec }) {
5252
return (
5353
<div
5454
className={cn(
55-
"relative rounded-2xl border bg-card/30 backdrop-blur-sm transition-all duration-300",
55+
"relative h-full flex flex-col rounded-2xl border bg-card/30 backdrop-blur-sm transition-all duration-300",
5656
"hover:shadow-xl hover:shadow-primary/5",
5757
plan.popular
5858
? "border-primary/40 hover:border-primary/60"
@@ -67,7 +67,7 @@ function PlanCard({ plan }: { plan: DedicatedPlanSpec }) {
6767
</div>
6868
)}
6969

70-
<div className="p-5 space-y-4">
70+
<div className="p-5 flex flex-col flex-1 gap-4">
7171
{/* Header row */}
7272
<div className="flex items-start justify-between gap-3 flex-wrap">
7373
<div className="space-y-1.5">
@@ -149,7 +149,7 @@ function PlanCard({ plan }: { plan: DedicatedPlanSpec }) {
149149
<Button
150150
size="sm"
151151
variant={outOfStock ? "outline" : "default"}
152-
className="w-full gap-2 rounded-lg"
152+
className="w-full gap-2 rounded-lg mt-auto"
153153
disabled={outOfStock}
154154
asChild={!outOfStock}
155155
>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { PartnersPage } from "./partners-page"

0 commit comments

Comments
 (0)