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
15 changes: 11 additions & 4 deletions packages/core/lib/spec-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,9 @@
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, "&")

Check failure

Code scanning / CodeQL

Double escaping or unescaping High

This replacement may produce '&' characters that are double-unescaped
here
.
.replace(/&gt;/g, ">")
.replace(/&lt;/g, "<")
.replace(/&quot;/g, '"')
Expand Down Expand Up @@ -191,8 +191,9 @@
// "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" }
Expand All @@ -215,10 +216,16 @@
// "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(" / ")
}

Expand Down
104 changes: 97 additions & 7 deletions packages/ui/components/Layouts/Dedicated/dedicated-hub.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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<number | "ALL">("ALL")
const [priceMin, setPriceMin] = useState("")
const [priceMax, setPriceMax] = useState("")
const [sort, setSort] = useState<SortKey>("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()
Expand All @@ -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")
}

Expand Down Expand Up @@ -291,15 +325,40 @@ export function DedicatedHub({ plans }: DedicatedHubProps) {
/>
</div>
<Select value={sort} onValueChange={(v) => setSort(v as SortKey)}>
<SelectTrigger className="w-44 bg-card/30 border-border/50">
<SelectTrigger className="w-48 bg-card/30 border-border/50">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Sort: Default</SelectItem>
<SelectItem value="asc">Price: Low → High</SelectItem>
<SelectItem value="desc">Price: High → Low</SelectItem>
<SelectItem value="price-asc">Price: Low → High</SelectItem>
<SelectItem value="price-desc">Price: High → Low</SelectItem>
<SelectItem value="ram-asc">RAM: Low → High</SelectItem>
<SelectItem value="ram-desc">RAM: High → Low</SelectItem>
<SelectItem value="storage-asc">Storage: Low → High</SelectItem>
<SelectItem value="storage-desc">Storage: High → Low</SelectItem>
<SelectItem value="cpu-asc">CPU Cores: Low → High</SelectItem>
<SelectItem value="cpu-desc">CPU Cores: High → Low</SelectItem>
</SelectContent>
</Select>
<div className="flex items-center gap-1.5">
<Input
type="number"
inputMode="decimal"
placeholder="Min £"
value={priceMin}
onChange={(e) => setPriceMin(e.target.value)}
className="w-24 bg-card/30 border-border/50"
/>
<span className="text-muted-foreground text-sm">–</span>
<Input
type="number"
inputMode="decimal"
placeholder="Max £"
value={priceMax}
onChange={(e) => setPriceMax(e.target.value)}
className="w-24 bg-card/30 border-border/50"
/>
</div>

{/* Hardware filter */}
<div className="flex gap-1.5">
Expand All @@ -326,6 +385,37 @@ export function DedicatedHub({ plans }: DedicatedHubProps) {
</Button>
)}
</div>

{/* RAM tier row */}
<div className="flex flex-wrap gap-1.5 items-center">
<button
type="button"
onClick={() => setRam("ALL")}
className={cn(
"px-3 py-1 rounded-full text-xs font-medium border transition-all",
ram === "ALL"
? "border-primary/50 bg-primary/10 text-primary"
: "border-border/50 text-muted-foreground hover:border-border hover:text-foreground",
)}
>
All RAM
</button>
{availableRam.map((r) => (
<button
key={r}
type="button"
onClick={() => setRam(ram === r ? "ALL" : r)}
className={cn(
"px-3 py-1 rounded-full text-xs font-medium border transition-all",
ram === r
? "border-primary/50 bg-primary/10 text-primary"
: "border-border/50 text-muted-foreground hover:border-border hover:text-foreground",
)}
>
{r} GB
</button>
))}
</div>
</div>

{/* ── Plan grid ────────────────────────────────────────────────────── */}
Expand Down
99 changes: 91 additions & 8 deletions packages/ui/components/Layouts/VPS/vps-hub.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,12 @@ const SERIES_META: Record<string, { label: string; fullName: string; chip: strin

type Lineup = keyof typeof LINEUP_META
type Series = string
type SortKey = "default" | "asc" | "desc"
type SortKey =
| "default"
| "price-asc" | "price-desc"
| "ram-asc" | "ram-desc"
| "storage-asc" | "storage-desc"
| "cpu-asc" | "cpu-desc"

// ─── Helpers ─────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -250,11 +255,17 @@ export function VpsHub({ plans }: VpsHubProps) {
const [lineup, setLineup] = useState<Lineup | "ALL">("ALL")
const [series, setSeries] = useState<Series | "ALL">("ALL")
const [hardware, setHardware] = useState<"amd" | "intel" | "arm" | "ALL">("ALL")
const [ram, setRam] = useState<number | "ALL">("ALL")
const [priceMin, setPriceMin] = useState("")
const [priceMax, setPriceMax] = useState("")
const [sort, setSort] = useState<SortKey>("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]
Expand All @@ -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")
}

Expand Down Expand Up @@ -328,15 +355,40 @@ export function VpsHub({ plans }: VpsHubProps) {
/>
</div>
<Select value={sort} onValueChange={(v) => setSort(v as SortKey)}>
<SelectTrigger className="w-44 bg-card/30 border-border/50">
<SelectTrigger className="w-48 bg-card/30 border-border/50">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Sort: Default</SelectItem>
<SelectItem value="asc">Price: Low → High</SelectItem>
<SelectItem value="desc">Price: High → Low</SelectItem>
<SelectItem value="price-asc">Price: Low → High</SelectItem>
<SelectItem value="price-desc">Price: High → Low</SelectItem>
<SelectItem value="ram-asc">RAM: Low → High</SelectItem>
<SelectItem value="ram-desc">RAM: High → Low</SelectItem>
<SelectItem value="storage-asc">Storage: Low → High</SelectItem>
<SelectItem value="storage-desc">Storage: High → Low</SelectItem>
<SelectItem value="cpu-asc">CPU Cores: Low → High</SelectItem>
<SelectItem value="cpu-desc">CPU Cores: High → Low</SelectItem>
</SelectContent>
</Select>
<div className="flex items-center gap-1.5">
<Input
type="number"
inputMode="decimal"
placeholder="Min £"
value={priceMin}
onChange={(e) => setPriceMin(e.target.value)}
className="w-24 bg-card/30 border-border/50"
/>
<span className="text-muted-foreground text-sm">–</span>
<Input
type="number"
inputMode="decimal"
placeholder="Max £"
value={priceMax}
onChange={(e) => setPriceMax(e.target.value)}
className="w-24 bg-card/30 border-border/50"
/>
</div>
{hasActiveFilters && (
<Button variant="ghost" size="sm" onClick={clearFilters} className="gap-1.5 text-muted-foreground">
<X className="w-3.5 h-3.5" /> Clear
Expand All @@ -350,6 +402,37 @@ export function VpsHub({ plans }: VpsHubProps) {
<SlidersHorizontal className="w-3.5 h-3.5" /> Filter:
</span>

{/* RAM tier — derived from live plan data, always reliable regardless of naming */}
<button
type="button"
onClick={() => setRam("ALL")}
className={cn(
"px-3 py-1 rounded-full text-xs font-medium border transition-all",
ram === "ALL"
? "border-primary/50 bg-primary/10 text-primary"
: "border-border/50 text-muted-foreground hover:border-border hover:text-foreground",
)}
>
All RAM
</button>
{availableRam.map((r) => (
<button
key={r}
type="button"
onClick={() => setRam(ram === r ? "ALL" : r)}
className={cn(
"px-3 py-1 rounded-full text-xs font-medium border transition-all",
ram === r
? "border-primary/50 bg-primary/10 text-primary"
: "border-border/50 text-muted-foreground hover:border-border hover:text-foreground",
)}
>
{r} GB
</button>
))}

<span className="w-px h-4 bg-border/50 mx-1" />

{/* Hardware brand */}
{(["ALL", "amd", "intel", "arm"] as const).map((h) => (
<button
Expand Down