polish(dedicated): drive bay title, HDD/SSD optgroups, OS expansion + grouping
Four customer-copy / UX cleanups bundled together:
1. Drive bay title strip — shortGroupLabel() collapses "LFF/SFF/NVMe
Drive Bays" to just "Drive Bays" everywhere it surfaces (rail
anchor, configurator section title, BuildSummary line item).
Each chassis only ever shows one drive bay group, so the
form-factor prefix was redundant noise.
2. HDD/SSD optgroups in Drive Selection — VSelect now interleaves
VListSubheader rows ("HDDs", "SSDs", "NVMe") between options.
Sentinel header values (`__hdr_<cat>`) are filtered in
onDriveChange so a stray header click can't propagate.
3. OS list expansion — went from 6 entries to 14: added AlmaLinux 8,
Rocky 8, Ubuntu 22.04 LTS, Debian 11, Fedora Server 41, FreeBSD 14,
Proxmox VE 8, Windows Server 2019 (BYOL). Default flipped from
"No OS" → "AlmaLinux 9" (matching what most dedicated buyers
actually want — flag and revert via seeder if you'd rather keep
bare-metal as the default).
4. OS picker grouped by distro — OsGroupSelector renders family
sections (AlmaLinux, Rocky Linux, Ubuntu, Debian, Fedora,
FreeBSD, Proxmox VE, Windows Server, Other) with a small
uppercase heading above each row of tiles. metaFor() helper
maps slug → family + logo path. New SVG logos for fedora,
freebsd, proxmox; refined geometry on almalinux + rocky + debian.
Reseeded the OS group (deleted old 6 values, recreated 14 with new
ordering). 20/20 dedicated tests still pass. `npm run build` clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@@ -447,13 +447,24 @@ class ConfigOptionSeeder extends Seeder
|
|||||||
);
|
);
|
||||||
|
|
||||||
$gen14OsOption = $this->seedRadioOption($gen14Os, 'Operating System', false, 1);
|
$gen14OsOption = $this->seedRadioOption($gen14Os, 'Operating System', false, 1);
|
||||||
|
// Order: enterprise Linux first (Alma / Rocky), then mainstream LTS,
|
||||||
|
// then specialty (Fedora / FreeBSD / Proxmox), then Windows BYOL,
|
||||||
|
// with "No OS" last so the picker reads as a positive distro choice.
|
||||||
$this->seedValues($gen14OsOption, [
|
$this->seedValues($gen14OsOption, [
|
||||||
['label' => 'No OS (BYO image / custom PXE)', 'value' => 'none', 'monthly' => 0, 'is_default' => true],
|
['label' => 'AlmaLinux 9', 'value' => 'alma9', 'monthly' => 0, 'is_default' => true],
|
||||||
['label' => 'AlmaLinux 9', 'value' => 'alma9', 'monthly' => 0],
|
['label' => 'AlmaLinux 8', 'value' => 'alma8', 'monthly' => 0],
|
||||||
['label' => 'Ubuntu 24.04 LTS', 'value' => 'ubuntu24', 'monthly' => 0],
|
|
||||||
['label' => 'Debian 12', 'value' => 'debian12', 'monthly' => 0],
|
|
||||||
['label' => 'Rocky Linux 9', 'value' => 'rocky9', 'monthly' => 0],
|
['label' => 'Rocky Linux 9', 'value' => 'rocky9', 'monthly' => 0],
|
||||||
|
['label' => 'Rocky Linux 8', 'value' => 'rocky8', 'monthly' => 0],
|
||||||
|
['label' => 'Ubuntu 24.04 LTS', 'value' => 'ubuntu24', 'monthly' => 0],
|
||||||
|
['label' => 'Ubuntu 22.04 LTS', 'value' => 'ubuntu22', 'monthly' => 0],
|
||||||
|
['label' => 'Debian 12', 'value' => 'debian12', 'monthly' => 0],
|
||||||
|
['label' => 'Debian 11', 'value' => 'debian11', 'monthly' => 0],
|
||||||
|
['label' => 'Fedora Server 41', 'value' => 'fedora41', 'monthly' => 0],
|
||||||
|
['label' => 'FreeBSD 14', 'value' => 'freebsd14', 'monthly' => 0],
|
||||||
|
['label' => 'Proxmox VE 8', 'value' => 'proxmox8', 'monthly' => 0],
|
||||||
['label' => 'Windows Server 2022 (BYOL)', 'value' => 'windows-2022-byol', 'monthly' => 0],
|
['label' => 'Windows Server 2022 (BYOL)', 'value' => 'windows-2022-byol', 'monthly' => 0],
|
||||||
|
['label' => 'Windows Server 2019 (BYOL)', 'value' => 'windows-2019-byol', 'monthly' => 0],
|
||||||
|
['label' => 'No OS (BYO image / custom PXE)', 'value' => 'none', 'monthly' => 0],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$gen14Os->plans()->syncWithoutDetaching($gen14AllPlans);
|
$gen14Os->plans()->syncWithoutDetaching($gen14AllPlans);
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="AlmaLinux">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="AlmaLinux">
|
||||||
<circle cx="32" cy="32" r="32" fill="#0E1F3D"/>
|
<circle cx="32" cy="32" r="32" fill="#0E1F3D"/>
|
||||||
<path d="M32 12 C 36 22, 44 26, 50 30 C 44 32, 36 38, 32 50 C 28 38, 20 32, 14 30 C 20 26, 28 22, 32 12 Z" fill="#FA9001"/>
|
<g transform="translate(32 32)">
|
||||||
<circle cx="32" cy="30" r="3.5" fill="#FFD200"/>
|
<circle r="14" fill="none" stroke="#FA9001" stroke-width="3"/>
|
||||||
|
<path d="M-9 -2 Q 0 -14, 9 -2 Q 0 4, -9 -2 Z" fill="#FA9001"/>
|
||||||
|
<path d="M-7 5 Q 0 -2, 7 5 Q 0 12, -7 5 Z" fill="#FFD200"/>
|
||||||
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 329 B After Width: | Height: | Size: 392 B |
@@ -1,5 +1,7 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Debian">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Debian">
|
||||||
<circle cx="32" cy="32" r="32" fill="#fff"/>
|
<circle cx="32" cy="32" r="32" fill="#fff"/>
|
||||||
<path d="M40 9 C 22 9, 9 22, 9 39 C 9 50, 16 58, 25 58 C 17 55, 13 47, 13 38 C 13 23, 25 11, 41 11 C 47 11, 52 13, 56 17 C 52 12, 47 9, 40 9 Z" fill="#D70751"/>
|
<g transform="translate(32 32)">
|
||||||
<path d="M30 23 C 38 23, 42 28, 42 33 C 42 39, 38 43, 31 43 C 28 42, 26 40, 26 38 C 28 39, 30 40, 32 40 C 36 40, 39 38, 39 33 C 39 28, 35 25, 30 25 Z" fill="#D70751"/>
|
<path d="M -2 -16 C -10 -16, -16 -10, -16 -2 C -16 6, -10 12, -2 12 C 4 12, 8 9, 9 5 C 6 8, 2 9, -2 8 C -8 7, -12 2, -12 -3 C -12 -8, -8 -13, -2 -13 C 3 -13, 7 -10, 8 -6 C 8 -12, 4 -16, -2 -16 Z" fill="#D70751"/>
|
||||||
|
<circle cx="9" cy="-6" r="1.4" fill="#D70751"/>
|
||||||
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 479 B After Width: | Height: | Size: 457 B |
7
website/public/img/os/fedora.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Fedora">
|
||||||
|
<circle cx="32" cy="32" r="32" fill="#294172"/>
|
||||||
|
<g transform="translate(32 32)">
|
||||||
|
<path d="M -2 -14 L 10 -14 L 10 -8 L 4 -8 L 4 -3 L 9 -3 L 9 3 L 4 3 L 4 14 L -2 14 L -2 -8 C -2 -12, -1 -14, 2 -14 Z" fill="#fff"/>
|
||||||
|
<circle cx="-6" cy="9" r="5" fill="none" stroke="#fff" stroke-width="2.5"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 407 B |
10
website/public/img/os/freebsd.svg
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="FreeBSD">
|
||||||
|
<circle cx="32" cy="32" r="32" fill="#AB2B28"/>
|
||||||
|
<g transform="translate(32 32)">
|
||||||
|
<circle r="15" fill="#fff"/>
|
||||||
|
<circle cx="-5" cy="-3" r="2.5" fill="#AB2B28"/>
|
||||||
|
<circle cx="5" cy="-3" r="2.5" fill="#AB2B28"/>
|
||||||
|
<path d="M -7 4 Q 0 11, 7 4" fill="none" stroke="#AB2B28" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
<path d="M -12 -10 L -8 -6 M 12 -10 L 8 -6" stroke="#AB2B28" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 543 B |
11
website/public/img/os/proxmox.svg
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Proxmox VE">
|
||||||
|
<circle cx="32" cy="32" r="32" fill="#E57000"/>
|
||||||
|
<g transform="translate(32 32)">
|
||||||
|
<rect x="-13" y="-13" width="26" height="26" rx="2" fill="#fff"/>
|
||||||
|
<rect x="-9" y="-9" width="6" height="6" fill="#E57000"/>
|
||||||
|
<rect x="3" y="-9" width="6" height="6" fill="#E57000"/>
|
||||||
|
<rect x="-9" y="3" width="6" height="6" fill="#E57000"/>
|
||||||
|
<rect x="3" y="3" width="6" height="6" fill="#E57000"/>
|
||||||
|
<rect x="-3" y="-3" width="6" height="6" fill="#E57000"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 571 B |
@@ -1,7 +1,9 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Rocky Linux">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Rocky Linux">
|
||||||
<circle cx="32" cy="32" r="32" fill="#10B981"/>
|
<circle cx="32" cy="32" r="32" fill="#10B981"/>
|
||||||
<path d="M8 50 L 22 26 L 32 38 L 42 22 L 56 50 Z" fill="#fff"/>
|
<g>
|
||||||
<path d="M22 26 L 32 38 L 27 38 Z" fill="#0F766E" opacity="0.45"/>
|
<path d="M14 46 L 26 30 L 32 38 L 42 22 L 50 33 L 50 46 Z" fill="#fff"/>
|
||||||
<path d="M42 22 L 32 38 L 37 38 Z" fill="#0F766E" opacity="0.45"/>
|
<path d="M26 30 L 32 38 L 28.5 38 Z" fill="#0F766E" opacity="0.35"/>
|
||||||
<circle cx="46" cy="20" r="3" fill="#FDE68A"/>
|
<path d="M42 22 L 50 33 L 45 33 Z" fill="#0F766E" opacity="0.35"/>
|
||||||
|
<circle cx="44" cy="20" r="3" fill="#FDE68A"/>
|
||||||
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 407 B After Width: | Height: | Size: 439 B |
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import type { DedicatedConfigGroup, DedicatedConfigOption, DedicatedConfigValue, DedicatedCycle } from '@/stores/dedicatedConfigurator'
|
import { shortGroupLabel, type DedicatedConfigGroup, type DedicatedConfigOption, type DedicatedConfigValue, type DedicatedCycle } from '@/stores/dedicatedConfigurator'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
group: DedicatedConfigGroup
|
group: DedicatedConfigGroup
|
||||||
@@ -69,24 +69,68 @@ interface DriveItem {
|
|||||||
isDefault: boolean
|
isDefault: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const driveItems = computed<DriveItem[]>(() =>
|
interface DriveHeader {
|
||||||
driveValues.value.map(v => ({
|
value: string // unique sentinel like "__hdr_hdds" so VSelect doesn't try to select it
|
||||||
|
title: string
|
||||||
|
isHeader: true
|
||||||
|
props: { disabled: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
type DriveListItem = DriveItem | DriveHeader
|
||||||
|
|
||||||
|
// Categorize a drive value slug → media class header label.
|
||||||
|
// "none" stays at the top with no header. Everything else gets one of:
|
||||||
|
// HDDs (sata-hdd-*, sas-hdd-*) / SSDs (sata-ssd-*, sas-ssd-*) / NVMe (u2-nvme-*).
|
||||||
|
function categoryFor(slug: string): string {
|
||||||
|
if (slug === 'none') return ''
|
||||||
|
if (slug.includes('hdd')) return 'HDDs'
|
||||||
|
if (slug.includes('nvme')) return 'NVMe'
|
||||||
|
if (slug.includes('ssd')) return 'SSDs'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the items list with subheader rows interleaved between media classes.
|
||||||
|
const driveItems = computed<DriveListItem[]>(() => {
|
||||||
|
const out: DriveListItem[] = []
|
||||||
|
let currentCategory = ''
|
||||||
|
for (const v of driveValues.value) {
|
||||||
|
const cat = categoryFor(v.value)
|
||||||
|
if (cat && cat !== currentCategory) {
|
||||||
|
out.push({
|
||||||
|
value: `__hdr_${cat.toLowerCase()}`,
|
||||||
|
title: cat,
|
||||||
|
isHeader: true,
|
||||||
|
props: { disabled: true },
|
||||||
|
})
|
||||||
|
currentCategory = cat
|
||||||
|
}
|
||||||
|
out.push({
|
||||||
value: v.value,
|
value: v.value,
|
||||||
label: v.label,
|
label: v.label,
|
||||||
price: priceFor(v),
|
price: priceFor(v),
|
||||||
isDefault: v.is_default,
|
isDefault: v.is_default,
|
||||||
})),
|
})
|
||||||
)
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
|
||||||
function driveItemLabel(item: DriveItem | { price?: number; isDefault?: boolean } | undefined): string {
|
function isHeaderItem(item: DriveListItem | undefined): item is DriveHeader {
|
||||||
const price = item?.price
|
return !!item && (item as DriveHeader).isHeader === true
|
||||||
const isDefault = item?.isDefault
|
}
|
||||||
|
|
||||||
|
function driveItemLabel(item: DriveListItem | { price?: number; isDefault?: boolean } | undefined): string {
|
||||||
|
if (item && (item as DriveHeader).isHeader) return ''
|
||||||
|
const price = (item as DriveItem | undefined)?.price
|
||||||
|
const isDefault = (item as DriveItem | undefined)?.isDefault
|
||||||
if (typeof price !== 'number' || price === 0) return isDefault ? 'no drives' : 'free'
|
if (typeof price !== 'number' || price === 0) return isDefault ? 'no drives' : 'free'
|
||||||
return `+$${price.toFixed(2)}${perDriveSuffix.value}`
|
return `+$${price.toFixed(2)}${perDriveSuffix.value}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDriveChange(v: unknown): void {
|
function onDriveChange(v: unknown): void {
|
||||||
if (typeof v === 'string') pickDrive(v)
|
// Guard against any future stray subheader-row click — those values start
|
||||||
|
// with "__hdr_" and should never propagate to selection state.
|
||||||
|
if (typeof v !== 'string' || v.startsWith('__hdr_')) return
|
||||||
|
pickDrive(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
const cycleSuffix = computed<string>(() =>
|
const cycleSuffix = computed<string>(() =>
|
||||||
@@ -130,7 +174,7 @@ function pickDrive(value: string): void {
|
|||||||
<template>
|
<template>
|
||||||
<div class="drive-bay-group">
|
<div class="drive-bay-group">
|
||||||
<div class="drive-bay-group__head">
|
<div class="drive-bay-group__head">
|
||||||
<h4 class="drive-bay-group__title">{{ group.name.replace('Dedicated 14th Gen — ', '') }}</h4>
|
<h4 class="drive-bay-group__title">{{ shortGroupLabel(group.name) }}</h4>
|
||||||
<p v-if="group.description" class="drive-bay-group__desc">{{ group.description }}</p>
|
<p v-if="group.description" class="drive-bay-group__desc">{{ group.description }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -160,7 +204,13 @@ function pickDrive(value: string): void {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #item="{ item, props: itemProps }">
|
<template #item="{ item, props: itemProps }">
|
||||||
<VListItem v-bind="itemProps" :title="undefined">
|
<VListSubheader
|
||||||
|
v-if="isHeaderItem(item.raw)"
|
||||||
|
class="drive-bay-group__subheader"
|
||||||
|
>
|
||||||
|
{{ item.raw.title }}
|
||||||
|
</VListSubheader>
|
||||||
|
<VListItem v-else v-bind="itemProps" :title="undefined">
|
||||||
<div class="drive-bay-group__row drive-bay-group__row--menu">
|
<div class="drive-bay-group__row drive-bay-group__row--menu">
|
||||||
<span class="drive-bay-group__row-label">{{ item.raw.label }}</span>
|
<span class="drive-bay-group__row-label">{{ item.raw.label }}</span>
|
||||||
<span
|
<span
|
||||||
@@ -345,4 +395,16 @@ function pickDrive(value: string): void {
|
|||||||
.drive-bay-group__menu .v-list-item {
|
.drive-bay-group__menu .v-list-item {
|
||||||
padding-block: 6px;
|
padding-block: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.drive-bay-group__menu .drive-bay-group__subheader {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(var(--v-theme-on-surface), 0.55);
|
||||||
|
padding-block: 8px;
|
||||||
|
padding-inline: 16px;
|
||||||
|
min-height: auto;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import type { DedicatedConfigGroup, DedicatedConfigValue, DedicatedCycle } from '@/stores/dedicatedConfigurator'
|
import { shortGroupLabel, type DedicatedConfigGroup, type DedicatedConfigValue, type DedicatedCycle } from '@/stores/dedicatedConfigurator'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
group: DedicatedConfigGroup
|
group: DedicatedConfigGroup
|
||||||
@@ -66,7 +66,7 @@ function onUpdate(v: unknown): void {
|
|||||||
<template>
|
<template>
|
||||||
<div class="option-group">
|
<div class="option-group">
|
||||||
<div class="option-group__head">
|
<div class="option-group__head">
|
||||||
<h4 class="option-group__title">{{ group.name.replace('Dedicated 14th Gen — ', '') }}</h4>
|
<h4 class="option-group__title">{{ shortGroupLabel(group.name) }}</h4>
|
||||||
<p v-if="group.description" class="option-group__desc">{{ group.description }}</p>
|
<p v-if="group.description" class="option-group__desc">{{ group.description }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import type { DedicatedConfigGroup, DedicatedConfigValue, DedicatedCycle } from '@/stores/dedicatedConfigurator'
|
import { shortGroupLabel, type DedicatedConfigGroup, type DedicatedConfigValue, type DedicatedCycle } from '@/stores/dedicatedConfigurator'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
group: DedicatedConfigGroup
|
group: DedicatedConfigGroup
|
||||||
@@ -16,19 +16,49 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const values = computed<DedicatedConfigValue[]>(() => props.group.options[0]?.values ?? [])
|
const values = computed<DedicatedConfigValue[]>(() => props.group.options[0]?.values ?? [])
|
||||||
|
|
||||||
// Map seeded value slugs → static SVG logos in /public/img/os.
|
interface FamilyMeta {
|
||||||
// Anything not in this map falls back to the generic terminal icon.
|
family: string
|
||||||
const logoMap: Record<string, string> = {
|
logo: string
|
||||||
none: '/img/os/no-os.svg',
|
// Lower number = earlier in the picker. Lets us order families
|
||||||
alma9: '/img/os/almalinux.svg',
|
// independently of the seeder's value order.
|
||||||
ubuntu24: '/img/os/ubuntu.svg',
|
rank: number
|
||||||
debian12: '/img/os/debian.svg',
|
|
||||||
rocky9: '/img/os/rocky.svg',
|
|
||||||
'windows-2022-byol': '/img/os/windows.svg',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Map a value slug to its distro family + logo path. Adding a new OS
|
||||||
|
// in the seeder means adding one row here so the picker knows where
|
||||||
|
// to slot it.
|
||||||
|
function metaFor(slug: string): FamilyMeta {
|
||||||
|
if (slug.startsWith('alma')) return { family: 'AlmaLinux', logo: '/img/os/almalinux.svg', rank: 10 }
|
||||||
|
if (slug.startsWith('rocky')) return { family: 'Rocky Linux', logo: '/img/os/rocky.svg', rank: 20 }
|
||||||
|
if (slug.startsWith('ubuntu')) return { family: 'Ubuntu', logo: '/img/os/ubuntu.svg', rank: 30 }
|
||||||
|
if (slug.startsWith('debian')) return { family: 'Debian', logo: '/img/os/debian.svg', rank: 40 }
|
||||||
|
if (slug.startsWith('fedora')) return { family: 'Fedora', logo: '/img/os/fedora.svg', rank: 50 }
|
||||||
|
if (slug.startsWith('freebsd')) return { family: 'FreeBSD', logo: '/img/os/freebsd.svg', rank: 60 }
|
||||||
|
if (slug.startsWith('proxmox')) return { family: 'Proxmox VE', logo: '/img/os/proxmox.svg', rank: 70 }
|
||||||
|
if (slug.startsWith('windows')) return { family: 'Windows Server', logo: '/img/os/windows.svg', rank: 80 }
|
||||||
|
return { family: 'Other', logo: '/img/os/no-os.svg', rank: 99 }
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FamilyGroup {
|
||||||
|
family: string
|
||||||
|
rank: number
|
||||||
|
values: DedicatedConfigValue[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const familyGroups = computed<FamilyGroup[]>(() => {
|
||||||
|
const map = new Map<string, FamilyGroup>()
|
||||||
|
for (const v of values.value) {
|
||||||
|
const meta = metaFor(v.value)
|
||||||
|
if (!map.has(meta.family)) {
|
||||||
|
map.set(meta.family, { family: meta.family, rank: meta.rank, values: [] })
|
||||||
|
}
|
||||||
|
map.get(meta.family)!.values.push(v)
|
||||||
|
}
|
||||||
|
return Array.from(map.values()).sort((a, b) => a.rank - b.rank)
|
||||||
|
})
|
||||||
|
|
||||||
function logoFor(slug: string): string {
|
function logoFor(slug: string): string {
|
||||||
return logoMap[slug] ?? '/img/os/no-os.svg'
|
return metaFor(slug).logo
|
||||||
}
|
}
|
||||||
|
|
||||||
function priceFor(v: DedicatedConfigValue): number {
|
function priceFor(v: DedicatedConfigValue): number {
|
||||||
@@ -62,13 +92,20 @@ function priceLabel(v: DedicatedConfigValue): string {
|
|||||||
<template>
|
<template>
|
||||||
<div class="os-group">
|
<div class="os-group">
|
||||||
<div class="os-group__head">
|
<div class="os-group__head">
|
||||||
<h4 class="os-group__title">{{ group.name.replace('Dedicated 14th Gen — ', '') }}</h4>
|
<h4 class="os-group__title">{{ shortGroupLabel(group.name) }}</h4>
|
||||||
<p v-if="group.description" class="os-group__desc">{{ group.description }}</p>
|
<p v-if="group.description" class="os-group__desc">{{ group.description }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="os-group__grid">
|
<div class="os-group__families">
|
||||||
|
<div
|
||||||
|
v-for="fam in familyGroups"
|
||||||
|
:key="fam.family"
|
||||||
|
class="os-family"
|
||||||
|
>
|
||||||
|
<div class="os-family__heading">{{ fam.family }}</div>
|
||||||
|
<div class="os-family__grid">
|
||||||
<button
|
<button
|
||||||
v-for="v in values"
|
v-for="v in fam.values"
|
||||||
:key="v.id"
|
:key="v.id"
|
||||||
type="button"
|
type="button"
|
||||||
class="os-tile"
|
class="os-tile"
|
||||||
@@ -95,11 +132,13 @@ function priceLabel(v: DedicatedConfigValue): string {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.os-group__head {
|
.os-group__head {
|
||||||
margin-bottom: 12px;
|
margin-bottom: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.os-group__title {
|
.os-group__title {
|
||||||
@@ -115,7 +154,22 @@ function priceLabel(v: DedicatedConfigValue): string {
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.os-group__grid {
|
.os-group__families {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.os-family__heading {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(var(--v-theme-on-surface), 0.55);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.os-family__grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|||||||
@@ -76,7 +76,11 @@ export function groupAnchorId(name: string): string {
|
|||||||
|
|
||||||
export function shortGroupLabel(name: string): string {
|
export function shortGroupLabel(name: string): string {
|
||||||
// Strip the leading "Dedicated 14th Gen — " for compact rail/anchor display.
|
// Strip the leading "Dedicated 14th Gen — " for compact rail/anchor display.
|
||||||
return name.replace(/^Dedicated 14th Gen — /, '')
|
const trimmed = name.replace(/^Dedicated 14th Gen — /, '')
|
||||||
|
// Drive bay groups: every chassis gets exactly one (LFF / SFF / NVMe based
|
||||||
|
// on bay type), so the form-factor prefix is redundant in the customer UI.
|
||||||
|
if (trimmed.endsWith('Drive Bays')) return 'Drive Bays'
|
||||||
|
return trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
function isDriveBaySelection(sel: DedicatedSelection | undefined): sel is DriveBaySelection {
|
function isDriveBaySelection(sel: DedicatedSelection | undefined): sel is DriveBaySelection {
|
||||||
|
|||||||