diff --git a/packages/core/lib/spec-parser.ts b/packages/core/lib/spec-parser.ts index d19f54b..f9943b0 100644 --- a/packages/core/lib/spec-parser.ts +++ b/packages/core/lib/spec-parser.ts @@ -191,8 +191,9 @@ export function parseDescriptionSpecs(html: string | null): ParsedSpecs { // "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) + // Intel: Xeon, Core Ultra, Core i-series — "Core" isn't always stated + // ("Intel i9-9900K" as shorthand for "Intel Core i9-9900K") + 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" } @@ -215,10 +216,16 @@ export function parseDescriptionSpecs(html: string | null): ParsedSpecs { // "Flexible Regional Hosting: ... choose between Newcastle, UK or New York, // US ..." — pull "City, XX" style place names out of any bullet mentioning // location/region, joining multiple choices with " / ". + // "Flexible Regional Hosting: ... Newcastle, UK or New York, US ..." (code) + // "Premium Central EU Infrastructure: Provisioned in Falkenstein, Germany + // ..." (full country name) — the trigger keyword and the place format both + // vary, so cast a wider net on both. let location: string | undefined - const locationLine = lines.find((line) => /\blocation|\bregion/i.test(line)) + const locationLine = lines.find((line) => + /\blocation|\bregion|\binfrastructure|\bprovisioned|\bhosted|\bdata\s*cent(?:er|re)/i.test(line), + ) if (locationLine) { - const places = locationLine.match(/\b[A-Z][a-zA-Z]+(?:\s[A-Z][a-zA-Z]+)*,\s*[A-Z]{2,3}\b/g) + const places = locationLine.match(/\b[A-Z][a-zA-Z]+(?:\s[A-Z][a-zA-Z]+)*,\s*[A-Z][a-zA-Z]+\b/g) if (places && places.length > 0) location = places.join(" / ") } diff --git a/packages/ui/components/Layouts/Dedicated/dedicated-hub.tsx b/packages/ui/components/Layouts/Dedicated/dedicated-hub.tsx index 4adc058..9446518 100644 --- a/packages/ui/components/Layouts/Dedicated/dedicated-hub.tsx +++ b/packages/ui/components/Layouts/Dedicated/dedicated-hub.tsx @@ -32,13 +32,26 @@ import { cn } from "@/lib/utils" import type { DedicatedPlanSpec } from "@/packages/core/types/servers/dedicated" import { Price } from "@/packages/ui/components/ui/price" -type SortKey = "default" | "asc" | "desc" +type SortKey = + | "default" + | "price-asc" | "price-desc" + | "ram-asc" | "ram-desc" + | "storage-asc" | "storage-desc" + | "cpu-asc" | "cpu-desc" function formatBandwidth(plan: DedicatedPlanSpec): string { if (!plan.bandwidth) return "Unmetered" return `${plan.bandwidth.amount} ${plan.bandwidth.unit}` } +/** Sort by a possibly-undefined numeric field — plans missing it (not listed in their description) always sort last, regardless of direction. */ +function compareNullable(a: number | undefined, b: number | undefined, dir: 1 | -1): number { + if (a == null && b == null) return 0 + if (a == null) return 1 + if (b == null) return -1 + return (a - b) * dir +} + function formatStorage(plan: DedicatedPlanSpec): string { if (plan.storageDescription) return plan.storageDescription if (plan.storageGB) { @@ -204,8 +217,14 @@ interface DedicatedHubProps { export function DedicatedHub({ plans }: DedicatedHubProps) { const [search, setSearch] = useState("") const [hardware, setHardware] = useState<"amd" | "intel" | "ALL">("ALL") + const [ram, setRam] = useState("ALL") + const [priceMin, setPriceMin] = useState("") + const [priceMax, setPriceMax] = useState("") const [sort, setSort] = useState("default") + // Derived from live plan data, not the server name — reliable regardless of naming. + const availableRam = Array.from(new Set(plans.map((p) => p.ramGB))).sort((a, b) => a - b) + const filtered = (() => { let result = [...plans] const q = search.trim().toLowerCase() @@ -218,16 +237,31 @@ export function DedicatedHub({ plans }: DedicatedHubProps) { ) } if (hardware !== "ALL") result = result.filter((p) => p.hardware === hardware) - if (sort === "asc") result.sort((a, b) => a.priceGBP - b.priceGBP) - if (sort === "desc") result.sort((a, b) => b.priceGBP - a.priceGBP) + if (ram !== "ALL") result = result.filter((p) => p.ramGB === ram) + const min = parseFloat(priceMin) + const max = parseFloat(priceMax) + if (!isNaN(min)) result = result.filter((p) => p.priceGBP >= min) + if (!isNaN(max)) result = result.filter((p) => p.priceGBP <= max) + if (sort === "price-asc") result.sort((a, b) => a.priceGBP - b.priceGBP) + if (sort === "price-desc") result.sort((a, b) => b.priceGBP - a.priceGBP) + if (sort === "ram-asc") result.sort((a, b) => a.ramGB - b.ramGB) + if (sort === "ram-desc") result.sort((a, b) => b.ramGB - a.ramGB) + if (sort === "storage-asc") result.sort((a, b) => compareNullable(a.storageGB, b.storageGB, 1)) + if (sort === "storage-desc") result.sort((a, b) => compareNullable(a.storageGB, b.storageGB, -1)) + if (sort === "cpu-asc") result.sort((a, b) => compareNullable(a.cores, b.cores, 1)) + if (sort === "cpu-desc") result.sort((a, b) => compareNullable(a.cores, b.cores, -1)) return result })() - const hasActiveFilters = hardware !== "ALL" || search !== "" + const hasActiveFilters = + hardware !== "ALL" || ram !== "ALL" || priceMin !== "" || priceMax !== "" || search !== "" function clearFilters() { setSearch("") setHardware("ALL") + setRam("ALL") + setPriceMin("") + setPriceMax("") setSort("default") } @@ -291,15 +325,40 @@ export function DedicatedHub({ plans }: DedicatedHubProps) { /> +
+ setPriceMin(e.target.value)} + className="w-24 bg-card/30 border-border/50" + /> + + setPriceMax(e.target.value)} + className="w-24 bg-card/30 border-border/50" + /> +
{/* Hardware filter */}
@@ -326,6 +385,37 @@ export function DedicatedHub({ plans }: DedicatedHubProps) { )}
+ + {/* RAM tier row */} +
+ + {availableRam.map((r) => ( + + ))} +
{/* ── Plan grid ────────────────────────────────────────────────────── */} diff --git a/packages/ui/components/Layouts/VPS/vps-hub.tsx b/packages/ui/components/Layouts/VPS/vps-hub.tsx index 5a5c06c..18d4218 100644 --- a/packages/ui/components/Layouts/VPS/vps-hub.tsx +++ b/packages/ui/components/Layouts/VPS/vps-hub.tsx @@ -76,7 +76,12 @@ const SERIES_META: Record("ALL") const [series, setSeries] = useState("ALL") const [hardware, setHardware] = useState<"amd" | "intel" | "arm" | "ALL">("ALL") + const [ram, setRam] = useState("ALL") + const [priceMin, setPriceMin] = useState("") + const [priceMax, setPriceMax] = useState("") const [sort, setSort] = useState("default") - // Derive which lineups/series actually exist in the plan list + // Derive which lineups/series/RAM tiers actually exist in the plan list — + // reliable even for plans whose names don't follow the LINEUP-SERIES-RAM + // convention, since it's built from the live data, not assumed from it. const availableLineups = Array.from(new Set(plans.flatMap((p) => p.lineup ? [p.lineup] : []))) as Lineup[] const availableSeries = Array.from(new Set(plans.flatMap((p) => p.series ? [p.series] : []))) as Series[] + const availableRam = Array.from(new Set(plans.map((p) => p.ramGB))).sort((a, b) => a - b) const filtered = (() => { let result = [...plans] @@ -270,18 +281,34 @@ export function VpsHub({ plans }: VpsHubProps) { if (lineup !== "ALL") result = result.filter((p) => p.lineup === lineup) if (series !== "ALL") result = result.filter((p) => p.series === series) if (hardware !== "ALL") result = result.filter((p) => p.hardware === hardware) - if (sort === "asc") result.sort((a, b) => a.priceGBP - b.priceGBP) - if (sort === "desc") result.sort((a, b) => b.priceGBP - a.priceGBP) + if (ram !== "ALL") result = result.filter((p) => p.ramGB === ram) + const min = parseFloat(priceMin) + const max = parseFloat(priceMax) + if (!isNaN(min)) result = result.filter((p) => p.priceGBP >= min) + if (!isNaN(max)) result = result.filter((p) => p.priceGBP <= max) + if (sort === "price-asc") result.sort((a, b) => a.priceGBP - b.priceGBP) + if (sort === "price-desc") result.sort((a, b) => b.priceGBP - a.priceGBP) + if (sort === "ram-asc") result.sort((a, b) => a.ramGB - b.ramGB) + if (sort === "ram-desc") result.sort((a, b) => b.ramGB - a.ramGB) + if (sort === "storage-asc") result.sort((a, b) => a.storageGB - b.storageGB) + if (sort === "storage-desc") result.sort((a, b) => b.storageGB - a.storageGB) + if (sort === "cpu-asc") result.sort((a, b) => a.cpu - b.cpu) + if (sort === "cpu-desc") result.sort((a, b) => b.cpu - a.cpu) return result })() - const hasActiveFilters = lineup !== "ALL" || series !== "ALL" || hardware !== "ALL" || search !== "" + const hasActiveFilters = + lineup !== "ALL" || series !== "ALL" || hardware !== "ALL" || ram !== "ALL" || + priceMin !== "" || priceMax !== "" || search !== "" function clearFilters() { setSearch("") setLineup("ALL") setSeries("ALL") setHardware("ALL") + setRam("ALL") + setPriceMin("") + setPriceMax("") setSort("default") } @@ -328,15 +355,40 @@ export function VpsHub({ plans }: VpsHubProps) { /> +
+ setPriceMin(e.target.value)} + className="w-24 bg-card/30 border-border/50" + /> + + setPriceMax(e.target.value)} + className="w-24 bg-card/30 border-border/50" + /> +
{hasActiveFilters && ( + {availableRam.map((r) => ( + + ))} + + + {/* Hardware brand */} {(["ALL", "amd", "intel", "arm"] as const).map((h) => (