feat(dedicated): dropdowns for radio groups, card-grid OS picker
Replaces stacked radio cards with VSelect dropdowns across CPU, RAM,
Bandwidth, IPv4, Private Networking, PCIe NVMe Add-in, and the Drive
Selection control inside each drive bay group. Major space savings —
the LFF Drive Selection alone goes from 15 stacked cards to one row
on screen, with the active price still visible at a glance via the
selection slot's right-aligned chip.
OS group becomes a tile-grid picker (`OsGroupSelector.vue`): 6 cards
with brand logos, distro name, and price chip. Logos shipped as
hand-authored SVGs at public/img/os/{ubuntu,debian,almalinux,rocky,
windows,no-os}.svg — no new npm dependency.
- Synchronous Pinia store init: moved store.init() out of onMounted
into setup so children's `selected` props are populated on first
render. Without this VSelect's selection slot fires with a stub
item before init completes and the whole tree throws on a
defensive `.toFixed` access.
- Defensive priceLabel guards in both OptionGroupSelector and
DriveBayGroupSelector for any future re-render where the slot's
raw item is incomplete.
- isOperatingSystemGroup() helper alongside isDriveBayGroup() in the
store; configurator switches OS → OsGroupSelector, drive bays →
DriveBayGroupSelector, everything else → OptionGroupSelector.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
5
website/public/img/os/almalinux.svg
Normal file
5
website/public/img/os/almalinux.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<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"/>
|
||||
<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"/>
|
||||
<circle cx="32" cy="30" r="3.5" fill="#FFD200"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 329 B |
5
website/public/img/os/debian.svg
Normal file
5
website/public/img/os/debian.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<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"/>
|
||||
<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"/>
|
||||
<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"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 479 B |
10
website/public/img/os/no-os.svg
Normal file
10
website/public/img/os/no-os.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="No OS">
|
||||
<circle cx="32" cy="32" r="32" fill="#374151"/>
|
||||
<rect x="14" y="20" width="36" height="24" rx="3" fill="none" stroke="#fff" stroke-width="2.5"/>
|
||||
<line x1="14" y1="26" x2="50" y2="26" stroke="#fff" stroke-width="2.5"/>
|
||||
<circle cx="18.5" cy="23" r="1" fill="#fff"/>
|
||||
<circle cx="22" cy="23" r="1" fill="#fff"/>
|
||||
<circle cx="25.5" cy="23" r="1" fill="#fff"/>
|
||||
<path d="M21 33 L 26 36 L 21 39" fill="none" stroke="#fff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<line x1="30" y1="39" x2="42" y2="39" stroke="#fff" stroke-width="2.2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 691 B |
7
website/public/img/os/rocky.svg
Normal file
7
website/public/img/os/rocky.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<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"/>
|
||||
<path d="M8 50 L 22 26 L 32 38 L 42 22 L 56 50 Z" fill="#fff"/>
|
||||
<path d="M22 26 L 32 38 L 27 38 Z" fill="#0F766E" opacity="0.45"/>
|
||||
<path d="M42 22 L 32 38 L 37 38 Z" fill="#0F766E" opacity="0.45"/>
|
||||
<circle cx="46" cy="20" r="3" fill="#FDE68A"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 407 B |
7
website/public/img/os/ubuntu.svg
Normal file
7
website/public/img/os/ubuntu.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Ubuntu">
|
||||
<circle cx="32" cy="32" r="32" fill="#E95420"/>
|
||||
<circle cx="32" cy="9.5" r="4.5" fill="#fff"/>
|
||||
<circle cx="51.5" cy="42" r="4.5" fill="#fff"/>
|
||||
<circle cx="12.5" cy="42" r="4.5" fill="#fff"/>
|
||||
<circle cx="32" cy="32" r="9.5" fill="none" stroke="#fff" stroke-width="3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 377 B |
7
website/public/img/os/windows.svg
Normal file
7
website/public/img/os/windows.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Windows Server">
|
||||
<circle cx="32" cy="32" r="32" fill="#0078D4"/>
|
||||
<rect x="15.5" y="15.5" width="13.5" height="13.5" fill="#fff"/>
|
||||
<rect x="35" y="15.5" width="13.5" height="13.5" fill="#fff"/>
|
||||
<rect x="15.5" y="35" width="13.5" height="13.5" fill="#fff"/>
|
||||
<rect x="35" y="35" width="13.5" height="13.5" fill="#fff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 417 B |
@@ -46,17 +46,47 @@ function priceFor(v: DedicatedConfigValue): number {
|
||||
return raw ? parseFloat(raw) : 0
|
||||
}
|
||||
|
||||
function priceLabel(v: DedicatedConfigValue): string {
|
||||
const p = priceFor(v)
|
||||
if (p === 0) return v.is_default ? 'no drives' : 'free'
|
||||
const suffix = props.cycle === 'monthly'
|
||||
const perDriveSuffix = computed<string>(() =>
|
||||
props.cycle === 'monthly'
|
||||
? '/drive/mo'
|
||||
: props.cycle === 'quarterly'
|
||||
? '/drive/qtr'
|
||||
: props.cycle === 'semi_annual'
|
||||
? '/drive/6mo'
|
||||
: '/drive/yr'
|
||||
return `+$${p.toFixed(2)}${suffix}`
|
||||
: '/drive/yr',
|
||||
)
|
||||
|
||||
function priceLabel(v: DedicatedConfigValue): string {
|
||||
const p = priceFor(v)
|
||||
if (p === 0) return v.is_default ? 'no drives' : 'free'
|
||||
return `+$${p.toFixed(2)}${perDriveSuffix.value}`
|
||||
}
|
||||
|
||||
interface DriveItem {
|
||||
value: string
|
||||
label: string
|
||||
price: number
|
||||
isDefault: boolean
|
||||
}
|
||||
|
||||
const driveItems = computed<DriveItem[]>(() =>
|
||||
driveValues.value.map(v => ({
|
||||
value: v.value,
|
||||
label: v.label,
|
||||
price: priceFor(v),
|
||||
isDefault: v.is_default,
|
||||
})),
|
||||
)
|
||||
|
||||
function driveItemLabel(item: DriveItem | { price?: number; isDefault?: boolean } | undefined): string {
|
||||
const price = item?.price
|
||||
const isDefault = item?.isDefault
|
||||
if (typeof price !== 'number' || price === 0) return isDefault ? 'no drives' : 'free'
|
||||
return `+$${price.toFixed(2)}${perDriveSuffix.value}`
|
||||
}
|
||||
|
||||
function onDriveChange(v: unknown): void {
|
||||
if (typeof v === 'string') pickDrive(v)
|
||||
}
|
||||
|
||||
const cycleSuffix = computed<string>(() =>
|
||||
@@ -105,32 +135,44 @@ function pickDrive(value: string): void {
|
||||
</div>
|
||||
|
||||
<div class="drive-bay-group__sub">Drive selection</div>
|
||||
<div class="drive-bay-group__list">
|
||||
<button
|
||||
v-for="v in driveValues"
|
||||
:key="v.id"
|
||||
type="button"
|
||||
class="drive-bay-group__option"
|
||||
:class="{ 'drive-bay-group__option--active': drive === v.value }"
|
||||
@click="pickDrive(v.value)"
|
||||
>
|
||||
<VIcon
|
||||
:icon="drive === v.value ? 'tabler-circle-check-filled' : 'tabler-circle'"
|
||||
:color="drive === v.value ? 'primary' : undefined"
|
||||
size="22"
|
||||
class="me-3 mt-1 flex-shrink-0"
|
||||
/>
|
||||
<div class="flex-grow-1">
|
||||
<div class="font-weight-bold">{{ v.label }}</div>
|
||||
<VSelect
|
||||
:model-value="drive"
|
||||
:items="driveItems"
|
||||
item-title="label"
|
||||
item-value="value"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
menu-icon="tabler-chevron-down"
|
||||
hide-details
|
||||
class="drive-bay-group__select"
|
||||
:menu-props="{ contentClass: 'drive-bay-group__menu' }"
|
||||
@update:model-value="onDriveChange"
|
||||
>
|
||||
<template #selection="{ item }">
|
||||
<div class="drive-bay-group__row">
|
||||
<span class="drive-bay-group__row-label">{{ item.raw.label }}</span>
|
||||
<span
|
||||
class="drive-bay-group__row-price"
|
||||
:class="item.raw.price === 0 ? 'drive-bay-group__row-price--zero' : 'drive-bay-group__row-price--paid'"
|
||||
>
|
||||
{{ driveItemLabel(item.raw) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="ms-3 flex-shrink-0 font-weight-bold"
|
||||
:class="priceFor(v) === 0 ? 'text-medium-emphasis text-caption' : 'text-primary'"
|
||||
>
|
||||
{{ priceLabel(v) }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<template #item="{ item, props: itemProps }">
|
||||
<VListItem v-bind="itemProps" :title="undefined">
|
||||
<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-price"
|
||||
:class="item.raw.price === 0 ? 'drive-bay-group__row-price--zero' : 'drive-bay-group__row-price--paid'"
|
||||
>
|
||||
{{ driveItemLabel(item.raw) }}
|
||||
</span>
|
||||
</div>
|
||||
</VListItem>
|
||||
</template>
|
||||
</VSelect>
|
||||
|
||||
<div v-if="showQuantity" class="drive-bay-group__qty">
|
||||
<div class="drive-bay-group__sub">Drives in chassis (max {{ maxQty }})</div>
|
||||
@@ -195,33 +237,59 @@ function pickDrive(value: string): void {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.drive-bay-group__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.drive-bay-group__select {
|
||||
:deep(.v-field) {
|
||||
border-radius: 12px;
|
||||
background: rgba(var(--v-theme-on-surface), 0.03);
|
||||
}
|
||||
|
||||
.drive-bay-group__option {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
background: rgba(var(--v-theme-on-surface), 0.03);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
:deep(.v-field--variant-outlined) {
|
||||
--v-field-border-opacity: 0.16;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(var(--v-theme-primary), 0.45);
|
||||
background: rgba(var(--v-theme-primary), 0.06);
|
||||
:deep(.v-field--focused .v-field__outline) {
|
||||
--v-field-border-opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.drive-bay-group__option--active {
|
||||
border-color: rgb(var(--v-theme-primary));
|
||||
background: rgba(var(--v-theme-primary), 0.1);
|
||||
.drive-bay-group__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.drive-bay-group__row-label {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: rgba(var(--v-theme-on-surface), 0.95);
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.drive-bay-group__row--menu .drive-bay-group__row-label {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.drive-bay-group__row-price {
|
||||
flex-shrink: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.drive-bay-group__row-price--zero {
|
||||
color: rgba(var(--v-theme-on-surface), 0.5);
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.drive-bay-group__row-price--paid {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
.drive-bay-group__qty {
|
||||
@@ -272,3 +340,9 @@ function pickDrive(value: string): void {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.drive-bay-group__menu .v-list-item {
|
||||
padding-block: 6px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -14,7 +14,12 @@ const emit = defineEmits<{
|
||||
'update:selected': [value: string]
|
||||
}>()
|
||||
|
||||
const values = computed<DedicatedConfigValue[]>(() => props.group.options[0]?.values ?? [])
|
||||
interface SelectItem {
|
||||
value: string
|
||||
label: string
|
||||
price: number
|
||||
isDefault: boolean
|
||||
}
|
||||
|
||||
function priceFor(v: DedicatedConfigValue): number {
|
||||
const raw = props.cycle === 'monthly'
|
||||
@@ -27,11 +32,34 @@ function priceFor(v: DedicatedConfigValue): number {
|
||||
return raw ? parseFloat(raw) : 0
|
||||
}
|
||||
|
||||
function priceLabel(v: DedicatedConfigValue): string {
|
||||
const p = priceFor(v)
|
||||
if (p === 0) return v.is_default ? 'included' : 'no extra cost'
|
||||
const suffix = props.cycle === 'monthly' ? '/mo' : props.cycle === 'quarterly' ? '/qtr' : props.cycle === 'semi_annual' ? '/6mo' : '/yr'
|
||||
return `+$${p.toFixed(2)}${suffix}`
|
||||
const items = computed<SelectItem[]>(() =>
|
||||
(props.group.options[0]?.values ?? []).map(v => ({
|
||||
value: v.value,
|
||||
label: v.label,
|
||||
price: priceFor(v),
|
||||
isDefault: v.is_default,
|
||||
})),
|
||||
)
|
||||
|
||||
const cycleSuffix = computed<string>(() =>
|
||||
props.cycle === 'monthly'
|
||||
? '/mo'
|
||||
: props.cycle === 'quarterly'
|
||||
? '/qtr'
|
||||
: props.cycle === 'semi_annual'
|
||||
? '/6mo'
|
||||
: '/yr',
|
||||
)
|
||||
|
||||
function priceLabel(price: number | undefined, isDefault: boolean | undefined): string {
|
||||
// Defensive: VSelect's slots can fire with a stub item when model-value
|
||||
// doesn't yet match any option (during hydration / async re-render).
|
||||
if (typeof price !== 'number' || price === 0) return isDefault ? 'included' : 'no extra cost'
|
||||
return `+$${price.toFixed(2)}${cycleSuffix.value}`
|
||||
}
|
||||
|
||||
function onUpdate(v: unknown): void {
|
||||
if (typeof v === 'string') emit('update:selected', v)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -42,38 +70,50 @@ function priceLabel(v: DedicatedConfigValue): string {
|
||||
<p v-if="group.description" class="option-group__desc">{{ group.description }}</p>
|
||||
</div>
|
||||
|
||||
<div class="option-group__list">
|
||||
<button
|
||||
v-for="v in values"
|
||||
:key="v.id"
|
||||
type="button"
|
||||
class="option-group__option"
|
||||
:class="{ 'option-group__option--active': selected === v.value }"
|
||||
@click="emit('update:selected', v.value)"
|
||||
>
|
||||
<VIcon
|
||||
:icon="selected === v.value ? 'tabler-circle-check-filled' : 'tabler-circle'"
|
||||
:color="selected === v.value ? 'primary' : undefined"
|
||||
size="22"
|
||||
class="me-3 mt-1 flex-shrink-0"
|
||||
/>
|
||||
<div class="flex-grow-1">
|
||||
<div class="font-weight-bold">{{ v.label }}</div>
|
||||
<VSelect
|
||||
:model-value="selected"
|
||||
:items="items"
|
||||
item-title="label"
|
||||
item-value="value"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
menu-icon="tabler-chevron-down"
|
||||
hide-details
|
||||
class="option-group__select"
|
||||
:menu-props="{ contentClass: 'option-group__menu' }"
|
||||
@update:model-value="onUpdate"
|
||||
>
|
||||
<template #selection="{ item }">
|
||||
<div class="option-group__row">
|
||||
<span class="option-group__label">{{ item.raw.label }}</span>
|
||||
<span
|
||||
class="option-group__price"
|
||||
:class="item.raw.price === 0 ? 'option-group__price--zero' : 'option-group__price--paid'"
|
||||
>
|
||||
{{ priceLabel(item.raw.price, item.raw.isDefault) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="ms-3 flex-shrink-0 font-weight-bold"
|
||||
:class="priceFor(v) === 0 ? 'text-medium-emphasis text-caption' : 'text-primary'"
|
||||
>
|
||||
{{ priceLabel(v) }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<template #item="{ item, props: itemProps }">
|
||||
<VListItem v-bind="itemProps" :title="undefined">
|
||||
<div class="option-group__row option-group__row--menu">
|
||||
<span class="option-group__label">{{ item.raw.label }}</span>
|
||||
<span
|
||||
class="option-group__price"
|
||||
:class="item.raw.price === 0 ? 'option-group__price--zero' : 'option-group__price--paid'"
|
||||
>
|
||||
{{ priceLabel(item.raw.price, item.raw.isDefault) }}
|
||||
</span>
|
||||
</div>
|
||||
</VListItem>
|
||||
</template>
|
||||
</VSelect>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.option-group__head {
|
||||
margin-bottom: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.option-group__title {
|
||||
@@ -89,32 +129,66 @@ function priceLabel(v: DedicatedConfigValue): string {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.option-group__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.option-group__select {
|
||||
:deep(.v-field) {
|
||||
border-radius: 12px;
|
||||
background: rgba(var(--v-theme-on-surface), 0.03);
|
||||
}
|
||||
|
||||
.option-group__option {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
background: rgba(var(--v-theme-on-surface), 0.03);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
:deep(.v-field--variant-outlined) {
|
||||
--v-field-border-opacity: 0.16;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(var(--v-theme-primary), 0.45);
|
||||
background: rgba(var(--v-theme-primary), 0.06);
|
||||
:deep(.v-field--focused .v-field__outline) {
|
||||
--v-field-border-opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.option-group__option--active {
|
||||
border-color: rgb(var(--v-theme-primary));
|
||||
background: rgba(var(--v-theme-primary), 0.10);
|
||||
.option-group__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.option-group__label {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: rgba(var(--v-theme-on-surface), 0.95);
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.option-group__row--menu .option-group__label {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.option-group__price {
|
||||
flex-shrink: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.option-group__price--zero {
|
||||
color: rgba(var(--v-theme-on-surface), 0.5);
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.option-group__price--paid {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
// Global scope so the menu (rendered into <body> via teleport) inherits
|
||||
// the row styling without scoped-style hashing breaking the selector.
|
||||
.option-group__menu .v-list-item {
|
||||
padding-block: 6px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import type { DedicatedConfigGroup, DedicatedConfigValue, DedicatedCycle } from '@/stores/dedicatedConfigurator'
|
||||
|
||||
interface Props {
|
||||
group: DedicatedConfigGroup
|
||||
selected: string
|
||||
cycle: DedicatedCycle
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:selected': [value: string]
|
||||
}>()
|
||||
|
||||
const values = computed<DedicatedConfigValue[]>(() => props.group.options[0]?.values ?? [])
|
||||
|
||||
// Map seeded value slugs → static SVG logos in /public/img/os.
|
||||
// Anything not in this map falls back to the generic terminal icon.
|
||||
const logoMap: Record<string, string> = {
|
||||
none: '/img/os/no-os.svg',
|
||||
alma9: '/img/os/almalinux.svg',
|
||||
ubuntu24: '/img/os/ubuntu.svg',
|
||||
debian12: '/img/os/debian.svg',
|
||||
rocky9: '/img/os/rocky.svg',
|
||||
'windows-2022-byol': '/img/os/windows.svg',
|
||||
}
|
||||
|
||||
function logoFor(slug: string): string {
|
||||
return logoMap[slug] ?? '/img/os/no-os.svg'
|
||||
}
|
||||
|
||||
function priceFor(v: DedicatedConfigValue): number {
|
||||
const raw = props.cycle === 'monthly'
|
||||
? v.monthly_price
|
||||
: props.cycle === 'quarterly'
|
||||
? v.quarterly_price
|
||||
: props.cycle === 'semi_annual'
|
||||
? v.semi_annual_price
|
||||
: v.annual_price
|
||||
return raw ? parseFloat(raw) : 0
|
||||
}
|
||||
|
||||
const cycleSuffix = computed<string>(() =>
|
||||
props.cycle === 'monthly'
|
||||
? '/mo'
|
||||
: props.cycle === 'quarterly'
|
||||
? '/qtr'
|
||||
: props.cycle === 'semi_annual'
|
||||
? '/6mo'
|
||||
: '/yr',
|
||||
)
|
||||
|
||||
function priceLabel(v: DedicatedConfigValue): string {
|
||||
const p = priceFor(v)
|
||||
if (p === 0) return v.is_default ? 'default' : 'included'
|
||||
return `+$${p.toFixed(2)}${cycleSuffix.value}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="os-group">
|
||||
<div class="os-group__head">
|
||||
<h4 class="os-group__title">{{ group.name.replace('Dedicated 14th Gen — ', '') }}</h4>
|
||||
<p v-if="group.description" class="os-group__desc">{{ group.description }}</p>
|
||||
</div>
|
||||
|
||||
<div class="os-group__grid">
|
||||
<button
|
||||
v-for="v in values"
|
||||
:key="v.id"
|
||||
type="button"
|
||||
class="os-tile"
|
||||
:class="{ 'os-tile--active': selected === v.value }"
|
||||
:aria-pressed="selected === v.value"
|
||||
@click="emit('update:selected', v.value)"
|
||||
>
|
||||
<div v-if="selected === v.value" class="os-tile__check">
|
||||
<VIcon icon="tabler-circle-check-filled" color="primary" size="18" />
|
||||
</div>
|
||||
<img
|
||||
:src="logoFor(v.value)"
|
||||
:alt="v.label"
|
||||
class="os-tile__logo"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div class="os-tile__name">{{ v.label }}</div>
|
||||
<div
|
||||
class="os-tile__price"
|
||||
:class="priceFor(v) === 0 ? 'os-tile__price--zero' : 'os-tile__price--paid'"
|
||||
>
|
||||
{{ priceLabel(v) }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.os-group__head {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.os-group__title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.os-group__desc {
|
||||
font-size: 13px;
|
||||
color: rgba(var(--v-theme-on-surface), 0.6);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.os-group__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.os-tile {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 18px 12px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
background: rgba(var(--v-theme-on-surface), 0.03);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
text-align: center;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(var(--v-theme-primary), 0.45);
|
||||
background: rgba(var(--v-theme-primary), 0.06);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
.os-tile--active {
|
||||
border-color: rgb(var(--v-theme-primary));
|
||||
background: rgba(var(--v-theme-primary), 0.1);
|
||||
box-shadow: 0 0 0 1px rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
.os-tile__check {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
}
|
||||
|
||||
.os-tile__logo {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
object-fit: contain;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.os-tile__name {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: rgba(var(--v-theme-on-surface), 0.92);
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.os-tile__price {
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.os-tile__price--zero {
|
||||
color: rgba(var(--v-theme-on-surface), 0.5);
|
||||
}
|
||||
|
||||
.os-tile__price--paid {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, watch } from 'vue'
|
||||
import { watch } from 'vue'
|
||||
import {
|
||||
isDriveBayGroup,
|
||||
isOperatingSystemGroup,
|
||||
useDedicatedConfiguratorStore,
|
||||
type DedicatedConfigGroup,
|
||||
type DedicatedCycle,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
type DriveBaySelection,
|
||||
} from '@/stores/dedicatedConfigurator'
|
||||
import OptionGroupSelector from './OptionGroupSelector.vue'
|
||||
import OsGroupSelector from './OsGroupSelector.vue'
|
||||
import DriveBayGroupSelector from './DriveBayGroupSelector.vue'
|
||||
import CycleToggle from './CycleToggle.vue'
|
||||
|
||||
@@ -62,16 +64,18 @@ function driveBaySelection(groupName: string): DriveBaySelection {
|
||||
return { drive: 'none', quantity: 0 }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
store.init({
|
||||
plan: props.plan,
|
||||
configGroups: props.configGroups,
|
||||
accountUrl: props.accountUrl,
|
||||
})
|
||||
if (typeof window !== 'undefined') {
|
||||
store.hydrateFromUrl(window.location.search)
|
||||
}
|
||||
// Initialize synchronously in setup so the children's `selected` props are
|
||||
// always populated on first render — without this, VSelect renders with
|
||||
// model-value="" and its scoped slots can fire with a stub item where
|
||||
// .price is undefined.
|
||||
store.init({
|
||||
plan: props.plan,
|
||||
configGroups: props.configGroups,
|
||||
accountUrl: props.accountUrl,
|
||||
})
|
||||
if (typeof window !== 'undefined') {
|
||||
store.hydrateFromUrl(window.location.search)
|
||||
}
|
||||
|
||||
watch(() => props.plan?.id, () => {
|
||||
store.init({
|
||||
@@ -100,6 +104,13 @@ watch(() => props.plan?.id, () => {
|
||||
@update:drive="(v: string) => onDriveChange(group.name, v)"
|
||||
@update:quantity="(q: number) => onQuantityChange(group.name, q)"
|
||||
/>
|
||||
<OsGroupSelector
|
||||
v-else-if="isOperatingSystemGroup(group.name)"
|
||||
:group="group"
|
||||
:selected="(typeof store.selections[group.name] === 'string' ? store.selections[group.name] : '') as string"
|
||||
:cycle="store.cycle"
|
||||
@update:selected="(v: string) => onSelectionChange(group.name, v)"
|
||||
/>
|
||||
<OptionGroupSelector
|
||||
v-else
|
||||
:group="group"
|
||||
|
||||
@@ -63,6 +63,10 @@ export function isDriveBayGroup(name: string): boolean {
|
||||
return name.includes('Drive Bays')
|
||||
}
|
||||
|
||||
export function isOperatingSystemGroup(name: string): boolean {
|
||||
return name.includes('Operating System')
|
||||
}
|
||||
|
||||
function isDriveBaySelection(sel: DedicatedSelection | undefined): sel is DriveBaySelection {
|
||||
return typeof sel === 'object' && sel !== null && 'drive' in sel
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user