diff --git a/app/layout.tsx b/app/layout.tsx index 5f03d09..9c353b4 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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"], @@ -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 ( @@ -156,7 +148,7 @@ export default async function RootLayout({ - + {children} diff --git a/app/partners/page.tsx b/app/partners/page.tsx new file mode 100644 index 0000000..cc76176 --- /dev/null +++ b/app/partners/page.tsx @@ -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 +} diff --git a/app/sitemap.ts b/app/sitemap.ts index b0c3e7d..8a2e2ad 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -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" }, diff --git a/packages/core/constants/partners.ts b/packages/core/constants/partners.ts new file mode 100644 index 0000000..0c5d753 --- /dev/null +++ b/packages/core/constants/partners.ts @@ -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.", + }, +] diff --git a/packages/core/lib/bytepay.ts b/packages/core/lib/bytepay.ts index 7a60723..be34034 100644 --- a/packages/core/lib/bytepay.ts +++ b/packages/core/lib/bytepay.ts @@ -204,13 +204,21 @@ async function fetchCategoryTree(): Promise { 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"], { @@ -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 { 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 } /** diff --git a/packages/core/lib/spec-parser.ts b/packages/core/lib/spec-parser.ts index 8fd7928..21999d4 100644 --- a/packages/core/lib/spec-parser.ts +++ b/packages/core/lib/spec-parser.ts @@ -33,23 +33,52 @@ 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[] { +/** + * Split into individual bullet lines. Paymenter descriptions are usually a + * `
  • ` list (one bullet per `
  • `); older ones instead separate + * bullets with a "•" character or bare newlines. Insert a line break at each + * list-item/paragraph/`
    ` 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(//gi, "\n") + const text = withBreaks + .replace(/<[^>]*>/g, " ") + .replace(/&/g, "&") + .replace(/[ \t]+/g, " ") return text .split(/[•\n]/) .map((s) => s.trim()) .filter(Boolean) } +/** First " GB|TB" in a line, converted to GB (TB × 1024). Returns undefined if the line has none. */ +function firstSizeGB(line: string): number | undefined { + const m = line.match(/(\d+)\s*(GB|TB)\b/i) + if (!m) return undefined + const amount = parseInt(m[1]) + return /tb/i.test(m[2]) ? amount * 1024 : amount +} + 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 = { mono: 1, dual: 2, quad: 4, hexa: 6, octa: 8, deca: 10, dodeca: 12 } let cpu: number | undefined for (const line of lines) { @@ -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 @@ -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 // ── Uplink ───────────────────────────────────────────────────────────────── // "1 Gbps Network Port", "4 Gbps" @@ -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) diff --git a/packages/ui/components/Layouts/Dedicated/dedicated-hub.tsx b/packages/ui/components/Layouts/Dedicated/dedicated-hub.tsx index 6a89abd..32bc72a 100644 --- a/packages/ui/components/Layouts/Dedicated/dedicated-hub.tsx +++ b/packages/ui/components/Layouts/Dedicated/dedicated-hub.tsx @@ -52,7 +52,7 @@ function PlanCard({ plan }: { plan: DedicatedPlanSpec }) { return (
    )} -
    +
    {/* Header row */}
    @@ -149,7 +149,7 @@ function PlanCard({ plan }: { plan: DedicatedPlanSpec }) { + + ) +} + +function TierSection({ + tier, + title, + badge, + icon: Icon, + entries, + emptyLabel, +}: { + tier: PartnerTier + title: string + badge: string + icon: typeof Handshake + entries: PartnerEntry[] + emptyLabel: string +}) { + const filtered = entries.filter((e) => e.tier === tier) + + return ( +
    +
    +
    +
    + + {badge} +
    +

    {title}

    +
    + + {filtered.length > 0 ? ( +
    + {filtered.map((entry) => ( + + ))} +
    + ) : ( +
    + +
    + )} +
    +
    + ) +} + +export function PartnersPage() { + const t = useTranslations() + + return ( +
    + {/* Hero */} +
    +
    +
    +
    +
    +
    + + {t("partnersPage.badge")} +
    +

    + {t("partnersPage.hero.title")}{" "} + + {t("partnersPage.hero.titleHighlight")} + +

    +

    + {t("partnersPage.hero.description")} +

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

    {t("partnersPage.cta.title")}

    +

    {t("partnersPage.cta.description")}

    +
    + + +
    +
    + +
    +
    +
    + ) +} diff --git a/packages/ui/components/Layouts/VPS/vps-hub.tsx b/packages/ui/components/Layouts/VPS/vps-hub.tsx index 85610e9..1c83d28 100644 --- a/packages/ui/components/Layouts/VPS/vps-hub.tsx +++ b/packages/ui/components/Layouts/VPS/vps-hub.tsx @@ -92,7 +92,7 @@ function PlanCard({ plan }: { plan: VpsPlanSpec }) { return (
    )} -
    +
    {/* Header row */}
    @@ -186,7 +186,7 @@ function PlanCard({ plan }: { plan: VpsPlanSpec }) {
  • - @@ -234,7 +234,15 @@ export function Footer() {
  • - + {t("footer.company.partners")} + +
  • +
  • + ({ - title: game.name, - href: `/games/${game.slug}`, - description: `${game.name} server hosting`, - icon: Gamepad2, - section: "game" as const, - })), { title: t("services.gameServers.title"), href: "/games", description: t("services.gameServers.description"), - icon: Server, - section: "game", + icon: Gamepad2, }, { title: t("services.allVps.title"), href: "/vps", description: t("services.allVps.description"), icon: Server, - section: "vps", }, { title: t("services.dedicated.title"), href: "/dedicated", description: t("services.dedicated.description"), icon: Cpu, - section: "dedicated", }, ] @@ -332,62 +323,12 @@ export function Navigation({ gamesNav = [] }: NavigationProps) { closeDropdown(setServicesOpen)} > - {/* Game Servers section */} -
    -

    Game Servers

    -
    - {services.filter(s => s.section === "game").map((service) => ( - - -
    - -
    -
    - {service.title} -

    - {service.description} -

    -
    - -
    - ))} - {/* VPS section divider */} -
    -
    -

    VPS Servers

    -
    - {services.filter(s => s.section === "vps").map((service) => ( - - -
    - -
    -
    - {service.title} -

    - {service.description} -

    -
    - -
    - ))} - {/* Dedicated section divider */} -
    -
    -

    Dedicated Servers

    -
    - {services.filter(s => s.section === "dedicated").map((service) => ( + {services.map((service) => (
    - {/* Game Servers */} -

    Game Servers

    - {services.filter(s => s.section === "game").map((service) => ( - setIsMobileMenuOpen(false)} - > -
    - -
    -
    -
    {service.title}
    -

    - {service.description} -

    -
    - - - ))} - {/* VPS Servers */} -
    -

    VPS Servers

    - {services.filter(s => s.section === "vps").map((service) => ( - setIsMobileMenuOpen(false)} - > -
    - -
    -
    -
    {service.title}
    -

    - {service.description} -

    -
    - - - ))} - {/* Dedicated Servers */} -
    -

    Dedicated Servers

    - {services.filter(s => s.section === "dedicated").map((service) => ( + {services.map((service) => ( - +
    {children}
    diff --git a/public/partners/placeholder.svg b/public/partners/placeholder.svg new file mode 100644 index 0000000..2e7e38f --- /dev/null +++ b/public/partners/placeholder.svg @@ -0,0 +1,4 @@ + + + +