feat(dedicated): drive bay Option B restructure (per-drive × quantity)

Replaces the flat-radio combo pattern in LFF/SFF/NVMe drive bay groups
with a Drive Selection (radio, per-drive cost) + Drive Quantity
(stepper) composite. Adds SAS HDD/SSD variants on LFF and SFF, and
collapses NVMe to enterprise U.2 sizes only.

- Seeder: rewrites the 3 drive bay groups; LFF goes from 35 flat combo
  values to 15 per-drive selections, SFF 8 → 8, NVMe 7 → 4. Adds
  SAS HDD (12/16 TB) and SAS SSD (1.92/3.84/7.68 TB) on LFF, SAS SSD
  trio on SFF, and 7.68 TB SATA SSD on both.
- Store: selections become Record<string, string | {drive,quantity}>;
  driveBayCost computed as drive_selection × quantity.
- DriveBayGroupSelector.vue: new composite component with stepper.
- BuildSummary: renders drive bay rows as "N× <drive> = $Y".
- Route filter: clamps Drive Quantity max_qty to chassis bay_count
  instead of filtering value slugs.
- URL contract: drive bay groups serialize as <prefix>_drive +
  <prefix>_qty (lff/sff/nvme).
- Tests: rewrites bay-count filter test, adds 5 new tests covering
  the two-option structure, SAS variants on LFF/SFF, NVMe enterprise
  sizes, and per-drive pricing alignment with the spec table.

Implements docs/superpowers/specs/2026-04-26-dedicated-drive-bays-option-b-design.md.

20/20 dedicated tests pass; 30/30 marketing tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-26 20:19:41 -04:00
parent c9e0c8826f
commit 4833d667e3
7 changed files with 718 additions and 142 deletions

View File

@@ -580,10 +580,16 @@ class ConfigOptionSeeder extends Seeder
$gen14PciNvme->plans()->syncWithoutDetaching($gen14PciNvmePlans); $gen14PciNvme->plans()->syncWithoutDetaching($gen14PciNvmePlans);
// ─── Dedicated 14th Gen — LFF Drive Bays ──────────────────────── // ─── Dedicated 14th Gen — LFF Drive Bays ────────────────────────
// New shape (Option B): two options per group — Drive Selection (radio,
// PER-DRIVE cost) + Drive Quantity (stepper). Total = drive × quantity,
// computed in the frontend. Per-drive economics keep the catalog short
// and let us add a new size or interface (SAS) without exploding into
// 4-N more flat-radio rows. SAS HDD/SSD variants offer a 12 Gb/s
// dual-port path for customers running clustered storage / HA pairs.
$gen14Lff = PlanConfigGroup::updateOrCreate( $gen14Lff = PlanConfigGroup::updateOrCreate(
['name' => 'Dedicated 14th Gen — LFF Drive Bays'], ['name' => 'Dedicated 14th Gen — LFF Drive Bays'],
[ [
'description' => 'Pick a starter drive configuration for the 3.5" LFF bays — HDDs or SSDs (in 3.5" carriers). Customers needing a heterogeneous setup (mixed sizes per bay, hot spares, etc.) can request a custom layout via the post-order ticket flow.', 'description' => 'Pick a drive type, then choose how many to populate. SATA covers the price-sensitive cases; SAS variants add 12 Gb/s + dual-port for clustered storage workloads. Mixed-size setups via post-order ticket.',
'mode' => 'preset', 'mode' => 'preset',
'service_type' => 'dedicated', 'service_type' => 'dedicated',
'is_active' => true, 'is_active' => true,
@@ -591,45 +597,30 @@ class ConfigOptionSeeder extends Seeder
], ],
); );
$gen14LffOption = $this->seedRadioOption($gen14Lff, 'LFF Drive Bays', false, 1); $gen14LffDriveOption = $this->seedRadioOption($gen14Lff, 'Drive Selection', false, 1);
$this->seedValues($gen14LffOption, [ $this->seedValues($gen14LffDriveOption, [
['label' => 'No drives — configure via ticket', 'value' => 'none', 'monthly' => 0, 'is_default' => true], ['label' => 'No drives — configure via ticket', 'value' => 'none', 'monthly' => 0, 'is_default' => true],
['label' => '2× 4 TB SATA HDD', 'value' => '2x4tb-hdd', 'monthly' => 24.00], ['label' => '4 TB SATA HDD', 'value' => 'sata-hdd-4tb', 'monthly' => 12.00],
['label' => '4× 4 TB SATA HDD', 'value' => '4x4tb-hdd', 'monthly' => 48.00], ['label' => '8 TB SATA HDD', 'value' => 'sata-hdd-8tb', 'monthly' => 20.00],
['label' => '2× 8 TB SATA HDD', 'value' => '2x8tb-hdd', 'monthly' => 40.00], ['label' => '12 TB Enterprise SATA HDD', 'value' => 'sata-hdd-12tb', 'monthly' => 45.00],
['label' => '4× 8 TB SATA HDD', 'value' => '4x8tb-hdd', 'monthly' => 80.00], ['label' => '20 TB Enterprise SATA HDD', 'value' => 'sata-hdd-20tb', 'monthly' => 55.00],
['label' => '8× 8 TB SATA HDD', 'value' => '8x8tb-hdd', 'monthly' => 160.00], ['label' => '24 TB Enterprise SATA HDD', 'value' => 'sata-hdd-24tb', 'monthly' => 75.00],
['label' => '12× 8 TB SATA HDD', 'value' => '12x8tb-hdd', 'monthly' => 240.00], ['label' => '12 TB Enterprise SAS HDD', 'value' => 'sas-hdd-12tb', 'monthly' => 50.00],
['label' => '2× 12 TB Enterprise HDD', 'value' => '2x12tb-hdd', 'monthly' => 90.00], ['label' => '16 TB Enterprise SAS HDD', 'value' => 'sas-hdd-16tb', 'monthly' => 55.00],
['label' => '4× 12 TB Enterprise HDD', 'value' => '4x12tb-hdd', 'monthly' => 180.00], ['label' => '480 GB SATA SSD (LFF carrier)', 'value' => 'sata-ssd-480gb-lff', 'monthly' => 10.00],
['label' => '8× 12 TB Enterprise HDD', 'value' => '8x12tb-hdd', 'monthly' => 360.00], ['label' => '1.92 TB SATA SSD (LFF carrier)', 'value' => 'sata-ssd-1920gb-lff', 'monthly' => 18.00],
['label' => '12× 12 TB Enterprise HDD', 'value' => '12x12tb-hdd', 'monthly' => 540.00], ['label' => '3.84 TB SATA SSD (LFF carrier)', 'value' => 'sata-ssd-3840gb-lff', 'monthly' => 45.00],
['label' => '2× 20 TB Enterprise HDD', 'value' => '2x20tb-hdd', 'monthly' => 110.00], ['label' => '7.68 TB SATA SSD (LFF carrier)', 'value' => 'sata-ssd-7680gb-lff', 'monthly' => 100.00],
['label' => '4× 20 TB Enterprise HDD', 'value' => '4x20tb-hdd', 'monthly' => 220.00], ['label' => '1.92 TB SAS SSD (LFF carrier)', 'value' => 'sas-ssd-1920gb-lff', 'monthly' => 40.00],
['label' => '8× 20 TB Enterprise HDD', 'value' => '8x20tb-hdd', 'monthly' => 440.00], ['label' => '3.84 TB SAS SSD (LFF carrier)', 'value' => 'sas-ssd-3840gb-lff', 'monthly' => 80.00],
['label' => '12× 20 TB Enterprise HDD', 'value' => '12x20tb-hdd', 'monthly' => 660.00], ['label' => '7.68 TB SAS SSD (LFF carrier)', 'value' => 'sas-ssd-7680gb-lff', 'monthly' => 200.00],
['label' => '2× 24 TB Enterprise HDD', 'value' => '2x24tb-hdd', 'monthly' => 150.00],
['label' => '4× 24 TB Enterprise HDD', 'value' => '4x24tb-hdd', 'monthly' => 300.00],
['label' => '8× 24 TB Enterprise HDD', 'value' => '8x24tb-hdd', 'monthly' => 600.00],
['label' => '12× 24 TB Enterprise HDD', 'value' => '12x24tb-hdd', 'monthly' => 900.00],
['label' => '2× 480 GB SATA SSD (LFF carrier)', 'value' => '2x480gb-ssd-lff', 'monthly' => 20.00],
['label' => '4× 480 GB SATA SSD (LFF carrier)', 'value' => '4x480gb-ssd-lff', 'monthly' => 40.00],
['label' => '8× 480 GB SATA SSD (LFF carrier)', 'value' => '8x480gb-ssd-lff', 'monthly' => 80.00],
['label' => '12× 480 GB SATA SSD (LFF carrier)', 'value' => '12x480gb-ssd-lff', 'monthly' => 120.00],
['label' => '2× 1.92 TB SATA SSD (LFF carrier)', 'value' => '2x1920gb-ssd-lff', 'monthly' => 36.00],
['label' => '4× 1.92 TB SATA SSD (LFF carrier)', 'value' => '4x1920gb-ssd-lff', 'monthly' => 72.00],
['label' => '8× 1.92 TB SATA SSD (LFF carrier)', 'value' => '8x1920gb-ssd-lff', 'monthly' => 144.00],
['label' => '12× 1.92 TB SATA SSD (LFF carrier)', 'value' => '12x1920gb-ssd-lff', 'monthly' => 216.00],
['label' => '2× 3.84 TB SATA SSD (LFF carrier)', 'value' => '2x3840gb-ssd-lff', 'monthly' => 90.00],
['label' => '4× 3.84 TB SATA SSD (LFF carrier)', 'value' => '4x3840gb-ssd-lff', 'monthly' => 180.00],
['label' => '8× 3.84 TB SATA SSD (LFF carrier)', 'value' => '8x3840gb-ssd-lff', 'monthly' => 360.00],
['label' => '12× 3.84 TB SATA SSD (LFF carrier)', 'value' => '12x3840gb-ssd-lff', 'monthly' => 540.00],
['label' => '2× 7.68 TB SAS SSD (LFF carrier)', 'value' => '2x7680gb-ssd-lff', 'monthly' => 200.00],
['label' => '4× 7.68 TB SAS SSD (LFF carrier)', 'value' => '4x7680gb-ssd-lff', 'monthly' => 400.00],
['label' => '8× 7.68 TB SAS SSD (LFF carrier)', 'value' => '8x7680gb-ssd-lff', 'monthly' => 800.00],
['label' => '12× 7.68 TB SAS SSD (LFF carrier)', 'value' => '12x7680gb-ssd-lff', 'monthly' => 1200.00],
]); ]);
// monthly_price = 0 on the stepper itself — actual cost is computed in
// the frontend as drive_selection.monthly_price × quantity. The route
// filter clamps max_qty down to chassis bay_count at request time.
$this->seedQuantityOption($gen14Lff, 'Drive Quantity', 0, 12, 'drives', 0.00, 2);
$gen14LffPlanSlugs = ['r440-4lff', 'r540-8lff', 'r740xd-12lff']; $gen14LffPlanSlugs = ['r440-4lff', 'r540-8lff', 'r740xd-12lff'];
$gen14LffPlans = Plan::query()->whereIn('slug', $gen14LffPlanSlugs)->pluck('id'); $gen14LffPlans = Plan::query()->whereIn('slug', $gen14LffPlanSlugs)->pluck('id');
$gen14Lff->plans()->syncWithoutDetaching($gen14LffPlans); $gen14Lff->plans()->syncWithoutDetaching($gen14LffPlans);
@@ -638,7 +629,7 @@ class ConfigOptionSeeder extends Seeder
$gen14Sff = PlanConfigGroup::updateOrCreate( $gen14Sff = PlanConfigGroup::updateOrCreate(
['name' => 'Dedicated 14th Gen — SFF Drive Bays'], ['name' => 'Dedicated 14th Gen — SFF Drive Bays'],
[ [
'description' => 'Pick a starter drive configuration for the 2.5" SFF bays. Mixed-size setups via post-order ticket.', 'description' => 'Pick a 2.5" drive type, then set quantity. SAS SSD variants add 12 Gb/s + dual-port for HA / clustered storage. Mixed-size setups via post-order ticket.',
'mode' => 'preset', 'mode' => 'preset',
'service_type' => 'dedicated', 'service_type' => 'dedicated',
'is_active' => true, 'is_active' => true,
@@ -646,18 +637,20 @@ class ConfigOptionSeeder extends Seeder
], ],
); );
$gen14SffOption = $this->seedRadioOption($gen14Sff, 'SFF Drive Bays', false, 1); $gen14SffDriveOption = $this->seedRadioOption($gen14Sff, 'Drive Selection', false, 1);
$this->seedValues($gen14SffOption, [ $this->seedValues($gen14SffDriveOption, [
['label' => 'No drives — configure via ticket', 'value' => 'none', 'monthly' => 0, 'is_default' => true], ['label' => 'No drives — configure via ticket', 'value' => 'none', 'monthly' => 0, 'is_default' => true],
['label' => '2× 480 GB SATA SSD', 'value' => '2x480gb-ssd', 'monthly' => 20.00], ['label' => '480 GB SATA SSD', 'value' => 'sata-ssd-480gb', 'monthly' => 10.00],
['label' => '4× 480 GB SATA SSD', 'value' => '4x480gb-ssd', 'monthly' => 40.00], ['label' => '1.92 TB SATA SSD', 'value' => 'sata-ssd-1920gb', 'monthly' => 18.00],
['label' => '2× 1.92 TB SATA SSD', 'value' => '2x1920gb-ssd', 'monthly' => 36.00], ['label' => '3.84 TB SATA SSD', 'value' => 'sata-ssd-3840gb', 'monthly' => 45.00],
['label' => '4× 1.92 TB SATA SSD', 'value' => '4x1920gb-ssd', 'monthly' => 72.00], ['label' => '7.68 TB SATA SSD', 'value' => 'sata-ssd-7680gb', 'monthly' => 100.00],
['label' => '8× 1.92 TB SATA SSD', 'value' => '8x1920gb-ssd', 'monthly' => 144.00], ['label' => '1.92 TB SAS SSD', 'value' => 'sas-ssd-1920gb', 'monthly' => 40.00],
['label' => '16× 1.92 TB SATA SSD', 'value' => '16x1920gb-ssd', 'monthly' => 288.00], ['label' => '3.84 TB SAS SSD', 'value' => 'sas-ssd-3840gb', 'monthly' => 80.00],
['label' => '24× 1.92 TB SATA SSD', 'value' => '24x1920gb-ssd', 'monthly' => 432.00], ['label' => '7.68 TB SAS SSD', 'value' => 'sas-ssd-7680gb', 'monthly' => 200.00],
]); ]);
$this->seedQuantityOption($gen14Sff, 'Drive Quantity', 0, 24, 'drives', 0.00, 2);
$gen14SffPlanSlugs = ['r640-8sff', 'r740-16sff', 'r740xd-24sff']; $gen14SffPlanSlugs = ['r640-8sff', 'r740-16sff', 'r740xd-24sff'];
$gen14SffPlans = Plan::query()->whereIn('slug', $gen14SffPlanSlugs)->pluck('id'); $gen14SffPlans = Plan::query()->whereIn('slug', $gen14SffPlanSlugs)->pluck('id');
$gen14Sff->plans()->syncWithoutDetaching($gen14SffPlans); $gen14Sff->plans()->syncWithoutDetaching($gen14SffPlans);
@@ -666,7 +659,7 @@ class ConfigOptionSeeder extends Seeder
$gen14Nvme = PlanConfigGroup::updateOrCreate( $gen14Nvme = PlanConfigGroup::updateOrCreate(
['name' => 'Dedicated 14th Gen — NVMe Drive Bays'], ['name' => 'Dedicated 14th Gen — NVMe Drive Bays'],
[ [
'description' => 'Pick a starter NVMe drive configuration. U.2 bays direct-attach to CPU PCIe lanes — no RAID controller in the data path; software RAID (ZFS / mdraid / btrfs) only.', 'description' => 'Pick a U.2 NVMe size, then set quantity. Bays direct-attach to CPU PCIe lanes — no RAID controller in the data path; software RAID (ZFS / mdraid / btrfs) only.',
'mode' => 'preset', 'mode' => 'preset',
'service_type' => 'dedicated', 'service_type' => 'dedicated',
'is_active' => true, 'is_active' => true,
@@ -674,17 +667,16 @@ class ConfigOptionSeeder extends Seeder
], ],
); );
$gen14NvmeOption = $this->seedRadioOption($gen14Nvme, 'NVMe Drive Bays', false, 1); $gen14NvmeDriveOption = $this->seedRadioOption($gen14Nvme, 'Drive Selection', false, 1);
$this->seedValues($gen14NvmeOption, [ $this->seedValues($gen14NvmeDriveOption, [
['label' => 'No drives — configure via ticket', 'value' => 'none', 'monthly' => 0, 'is_default' => true], ['label' => 'No drives — configure via ticket', 'value' => 'none', 'monthly' => 0, 'is_default' => true],
['label' => '2× 1 TB U.2 NVMe', 'value' => '2x1tb-nvme', 'monthly' => 44.00], ['label' => '1.92 TB U.2 NVMe', 'value' => 'u2-nvme-1920gb', 'monthly' => 30.00],
['label' => '4× 1 TB U.2 NVMe', 'value' => '4x1tb-nvme', 'monthly' => 88.00], ['label' => '3.84 TB U.2 NVMe', 'value' => 'u2-nvme-3840gb', 'monthly' => 70.00],
['label' => '2× 2 TB U.2 NVMe', 'value' => '2x2tb-nvme', 'monthly' => 96.00], ['label' => '7.68 TB U.2 NVMe', 'value' => 'u2-nvme-7680gb', 'monthly' => 150.00],
['label' => '4× 2 TB U.2 NVMe', 'value' => '4x2tb-nvme', 'monthly' => 192.00],
['label' => '8× 2 TB U.2 NVMe', 'value' => '8x2tb-nvme', 'monthly' => 384.00],
['label' => '16× 2 TB U.2 NVMe', 'value' => '16x2tb-nvme', 'monthly' => 768.00],
]); ]);
$this->seedQuantityOption($gen14Nvme, 'Drive Quantity', 0, 24, 'drives', 0.00, 2);
$gen14NvmePlanSlugs = ['r640-10nvme', 'r740xd-24nvme']; $gen14NvmePlanSlugs = ['r640-10nvme', 'r740xd-24nvme'];
$gen14NvmePlans = Plan::query()->whereIn('slug', $gen14NvmePlanSlugs)->pluck('id'); $gen14NvmePlans = Plan::query()->whereIn('slug', $gen14NvmePlanSlugs)->pluck('id');
$gen14Nvme->plans()->syncWithoutDetaching($gen14NvmePlans); $gen14Nvme->plans()->syncWithoutDetaching($gen14NvmePlans);

View File

@@ -1,11 +1,18 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import type { DedicatedConfigGroup, DedicatedCycle, DedicatedPlan } from '@/stores/dedicatedConfigurator' import {
isDriveBayGroup,
type DedicatedConfigGroup,
type DedicatedCycle,
type DedicatedPlan,
type DedicatedSelection,
type DriveBaySelection,
} from '@/stores/dedicatedConfigurator'
interface Props { interface Props {
plan: DedicatedPlan plan: DedicatedPlan
configGroups: DedicatedConfigGroup[] configGroups: DedicatedConfigGroup[]
selections: Record<string, string> selections: Record<string, DedicatedSelection>
cycle: DedicatedCycle cycle: DedicatedCycle
baselinePrice: number baselinePrice: number
cycleSubtotal: number cycleSubtotal: number
@@ -20,6 +27,10 @@ interface Props {
const props = defineProps<Props>() const props = defineProps<Props>()
function isDriveBay(sel: DedicatedSelection | undefined): sel is DriveBaySelection {
return typeof sel === 'object' && sel !== null && 'drive' in sel
}
const cycleLabel: Record<DedicatedCycle, string> = { const cycleLabel: Record<DedicatedCycle, string> = {
monthly: 'Monthly', monthly: 'Monthly',
quarterly: 'Quarterly (3 months)', quarterly: 'Quarterly (3 months)',
@@ -54,13 +65,41 @@ const lineItems = computed<LineItem[]>(() => {
// Then each selected upgrade — only if it has a non-zero contribution. // Then each selected upgrade — only if it has a non-zero contribution.
for (const group of props.configGroups) { for (const group of props.configGroups) {
const valueSlug = props.selections[group.name] const sel = props.selections[group.name]
if (!valueSlug) continue if (sel === undefined) continue
if (isDriveBayGroup(group.name) && isDriveBay(sel)) {
// Drive bay groups: render N× <drive label> = $Y total (cost computed
// here so the line item updates the moment quantity changes).
const driveOpt = group.options.find(o => o.name === 'Drive Selection')
const drive = driveOpt?.values.find(v => v.value === sel.drive)
if (!drive || sel.drive === 'none' || sel.quantity <= 0) continue
const perDrive = parseFloat(
props.cycle === 'monthly'
? drive.monthly_price
: props.cycle === 'quarterly'
? drive.quarterly_price
: props.cycle === 'semi_annual'
? drive.semi_annual_price
: drive.annual_price,
)
const total = (perDrive || 0) * sel.quantity
if (total <= 0) continue
items.push({
label: group.name.replace('Dedicated 14th Gen — ', ''),
detail: `${sel.quantity}× ${drive.label}`,
amount: total,
})
continue
}
if (typeof sel !== 'string') continue
const opt = group.options[0] const opt = group.options[0]
if (!opt) continue if (!opt) continue
const value = opt.values.find(v => v.value === valueSlug) const value = opt.values.find(v => v.value === sel)
if (!value) continue if (!value) continue
const raw = props.cycle === 'monthly' const raw = props.cycle === 'monthly'

View File

@@ -0,0 +1,274 @@
<script lang="ts" setup>
import { computed } from 'vue'
import type { DedicatedConfigGroup, DedicatedConfigOption, DedicatedConfigValue, DedicatedCycle } from '@/stores/dedicatedConfigurator'
interface Props {
group: DedicatedConfigGroup
drive: string
quantity: number
cycle: DedicatedCycle
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:drive': [value: string]
'update:quantity': [value: number]
}>()
const driveOption = computed<DedicatedConfigOption | null>(
() => props.group.options.find(o => o.name === 'Drive Selection') ?? null,
)
const quantityOption = computed<DedicatedConfigOption | null>(
() => props.group.options.find(o => o.name === 'Drive Quantity') ?? null,
)
const driveValues = computed<DedicatedConfigValue[]>(() => driveOption.value?.values ?? [])
const minQty = computed<number>(() => quantityOption.value?.min_qty ?? 0)
const maxQty = computed<number>(() => quantityOption.value?.max_qty ?? 24)
const selectedDrive = computed<DedicatedConfigValue | null>(
() => driveValues.value.find(v => v.value === props.drive) ?? null,
)
const showQuantity = computed<boolean>(() => props.drive !== 'none' && !!selectedDrive.value)
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
}
function priceLabel(v: DedicatedConfigValue): string {
const p = priceFor(v)
if (p === 0) return v.is_default ? 'no drives' : 'free'
const suffix = props.cycle === 'monthly'
? '/drive/mo'
: props.cycle === 'quarterly'
? '/drive/qtr'
: props.cycle === 'semi_annual'
? '/drive/6mo'
: '/drive/yr'
return `+$${p.toFixed(2)}${suffix}`
}
const cycleSuffix = computed<string>(() =>
props.cycle === 'monthly'
? '/mo'
: props.cycle === 'quarterly'
? '/qtr'
: props.cycle === 'semi_annual'
? '/6mo'
: '/yr',
)
const totalCost = computed<number>(() => {
if (!selectedDrive.value || props.quantity <= 0) return 0
return priceFor(selectedDrive.value) * props.quantity
})
function decrement(): void {
if (props.quantity > minQty.value) emit('update:quantity', props.quantity - 1)
}
function increment(): void {
if (props.quantity < maxQty.value) emit('update:quantity', props.quantity + 1)
}
function pickDrive(value: string): void {
emit('update:drive', value)
// When customer goes from "none" → a real drive, jump quantity to a sane
// starting value (2 for redundancy) so they immediately see real pricing.
if (props.drive === 'none' && value !== 'none' && props.quantity === 0) {
const target = Math.min(2, maxQty.value)
if (target > 0) emit('update:quantity', target)
}
// When customer reverts to "none", clear quantity.
if (value === 'none' && props.quantity !== 0) {
emit('update:quantity', 0)
}
}
</script>
<template>
<div class="drive-bay-group">
<div class="drive-bay-group__head">
<h4 class="drive-bay-group__title">{{ group.name.replace('Dedicated 14th Gen — ', '') }}</h4>
<p v-if="group.description" class="drive-bay-group__desc">{{ group.description }}</p>
</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>
</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>
<div v-if="showQuantity" class="drive-bay-group__qty">
<div class="drive-bay-group__sub">Drives in chassis (max {{ maxQty }})</div>
<div class="drive-bay-group__stepper">
<VBtn
icon="tabler-minus"
variant="outlined"
color="primary"
size="default"
rounded="lg"
:disabled="quantity <= minQty"
aria-label="Decrease drive count"
@click="decrement"
/>
<div class="drive-bay-group__qty-display">
<span class="drive-bay-group__qty-value">{{ quantity }}</span>
<span class="drive-bay-group__qty-unit">drives</span>
</div>
<VBtn
icon="tabler-plus"
variant="outlined"
color="primary"
size="default"
rounded="lg"
:disabled="quantity >= maxQty"
aria-label="Increase drive count"
@click="increment"
/>
<div class="drive-bay-group__total">
<template v-if="totalCost > 0">+${{ totalCost.toFixed(2) }}{{ cycleSuffix }}</template>
<span v-else class="text-medium-emphasis">no extra cost</span>
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.drive-bay-group__head {
margin-bottom: 12px;
}
.drive-bay-group__title {
font-size: 16px;
font-weight: 700;
letter-spacing: -0.01em;
margin-bottom: 4px;
}
.drive-bay-group__desc {
font-size: 13px;
color: rgba(var(--v-theme-on-surface), 0.6);
margin-bottom: 0;
}
.drive-bay-group__sub {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: rgba(var(--v-theme-on-surface), 0.55);
margin: 16px 0 8px;
font-weight: 600;
}
.drive-bay-group__list {
display: flex;
flex-direction: column;
gap: 8px;
}
.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;
&:hover {
border-color: rgba(var(--v-theme-primary), 0.45);
background: rgba(var(--v-theme-primary), 0.06);
}
}
.drive-bay-group__option--active {
border-color: rgb(var(--v-theme-primary));
background: rgba(var(--v-theme-primary), 0.1);
}
.drive-bay-group__qty {
margin-top: 4px;
}
.drive-bay-group__stepper {
display: flex;
align-items: center;
gap: 16px;
padding: 14px 18px;
border-radius: 12px;
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
background: rgba(var(--v-theme-on-surface), 0.03);
@media (max-width: 480px) {
flex-wrap: wrap;
}
}
.drive-bay-group__qty-display {
display: flex;
flex-direction: column;
align-items: center;
min-width: 64px;
}
.drive-bay-group__qty-value {
font-size: 22px;
font-weight: 700;
line-height: 1.1;
color: rgba(var(--v-theme-on-surface), 0.95);
font-variant-numeric: tabular-nums;
}
.drive-bay-group__qty-unit {
font-size: 11px;
color: rgba(var(--v-theme-on-surface), 0.55);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.drive-bay-group__total {
margin-left: auto;
font-size: 14px;
font-weight: 700;
color: rgb(var(--v-theme-primary));
font-variant-numeric: tabular-nums;
}
</style>

View File

@@ -1,7 +1,15 @@
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, watch } from 'vue' import { onMounted, watch } from 'vue'
import { useDedicatedConfiguratorStore, type DedicatedPlan, type DedicatedConfigGroup, type DedicatedCycle } from '@/stores/dedicatedConfigurator' import {
isDriveBayGroup,
useDedicatedConfiguratorStore,
type DedicatedConfigGroup,
type DedicatedCycle,
type DedicatedPlan,
type DriveBaySelection,
} from '@/stores/dedicatedConfigurator'
import OptionGroupSelector from './OptionGroupSelector.vue' import OptionGroupSelector from './OptionGroupSelector.vue'
import DriveBayGroupSelector from './DriveBayGroupSelector.vue'
import CycleToggle from './CycleToggle.vue' import CycleToggle from './CycleToggle.vue'
interface Props { interface Props {
@@ -33,11 +41,27 @@ function onSelectionChange(groupName: string, value: string): void {
debouncePushUrl() debouncePushUrl()
} }
function onDriveChange(groupName: string, value: string): void {
store.setDrive(groupName, value)
debouncePushUrl()
}
function onQuantityChange(groupName: string, value: number): void {
store.setDriveQuantity(groupName, value)
debouncePushUrl()
}
function onCycleChange(c: DedicatedCycle): void { function onCycleChange(c: DedicatedCycle): void {
store.cycle = c store.cycle = c
debouncePushUrl() debouncePushUrl()
} }
function driveBaySelection(groupName: string): DriveBaySelection {
const sel = store.selections[groupName]
if (typeof sel === 'object' && sel !== null && 'drive' in sel) return sel
return { drive: 'none', quantity: 0 }
}
onMounted(() => { onMounted(() => {
store.init({ store.init({
plan: props.plan, plan: props.plan,
@@ -66,14 +90,24 @@ watch(() => props.plan?.id, () => {
</div> </div>
<div class="dedicated-configurator__groups"> <div class="dedicated-configurator__groups">
<OptionGroupSelector <template v-for="group in configGroups" :key="group.id">
v-for="group in configGroups" <DriveBayGroupSelector
:key="group.id" v-if="isDriveBayGroup(group.name)"
:group="group" :group="group"
:selected="store.selections[group.name] ?? ''" :drive="driveBaySelection(group.name).drive"
:quantity="driveBaySelection(group.name).quantity"
:cycle="store.cycle"
@update:drive="(v: string) => onDriveChange(group.name, v)"
@update:quantity="(q: number) => onQuantityChange(group.name, q)"
/>
<OptionGroupSelector
v-else
:group="group"
:selected="(typeof store.selections[group.name] === 'string' ? store.selections[group.name] : '') as string"
:cycle="store.cycle" :cycle="store.cycle"
@update:selected="(v: string) => onSelectionChange(group.name, v)" @update:selected="(v: string) => onSelectionChange(group.name, v)"
/> />
</template>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -26,6 +26,10 @@ export interface DedicatedConfigOption {
id: number id: number
name: string name: string
type: string type: string
min_qty?: number | null
max_qty?: number | null
step?: number | null
unit_label?: string | null
values: DedicatedConfigValue[] values: DedicatedConfigValue[]
} }
@@ -37,6 +41,13 @@ export interface DedicatedConfigGroup {
sort_order: number sort_order: number
} }
export interface DriveBaySelection {
drive: string
quantity: number
}
export type DedicatedSelection = string | DriveBaySelection
export type DedicatedCycle = 'monthly' | 'quarterly' | 'semi_annual' | 'annual' export type DedicatedCycle = 'monthly' | 'quarterly' | 'semi_annual' | 'annual'
export const CYCLE_MONTHS: Record<DedicatedCycle, number> = { export const CYCLE_MONTHS: Record<DedicatedCycle, number> = {
@@ -48,13 +59,23 @@ export const CYCLE_MONTHS: Record<DedicatedCycle, number> = {
const CYCLES_WITH_SETUP_FEE: DedicatedCycle[] = ['monthly', 'quarterly'] const CYCLES_WITH_SETUP_FEE: DedicatedCycle[] = ['monthly', 'quarterly']
export function isDriveBayGroup(name: string): boolean {
return name.includes('Drive Bays')
}
function isDriveBaySelection(sel: DedicatedSelection | undefined): sel is DriveBaySelection {
return typeof sel === 'object' && sel !== null && 'drive' in sel
}
export const useDedicatedConfiguratorStore = defineStore('dedicatedConfigurator', () => { export const useDedicatedConfiguratorStore = defineStore('dedicatedConfigurator', () => {
const plan = ref<DedicatedPlan | null>(null) const plan = ref<DedicatedPlan | null>(null)
const configGroups = ref<DedicatedConfigGroup[]>([]) const configGroups = ref<DedicatedConfigGroup[]>([])
const accountUrl = ref<string>('') const accountUrl = ref<string>('')
// selections: groupName → option value slug (e.g., {"Dedicated 14th Gen — RAM Upgrade": "64"}) // selections: groupName → either a single value slug (single-option groups)
const selections = ref<Record<string, string>>({}) // or { drive, quantity } for drive bay groups (Drive Selection radio +
// Drive Quantity stepper composite).
const selections = ref<Record<string, DedicatedSelection>>({})
const cycle = ref<DedicatedCycle>('monthly') const cycle = ref<DedicatedCycle>('monthly')
function init(catalog: { function init(catalog: {
@@ -66,14 +87,19 @@ export const useDedicatedConfiguratorStore = defineStore('dedicatedConfigurator'
configGroups.value = catalog.configGroups configGroups.value = catalog.configGroups
accountUrl.value = catalog.accountUrl accountUrl.value = catalog.accountUrl
// Seed selections with each group's default value (or first value if no default). const seeded: Record<string, DedicatedSelection> = {}
const seeded: Record<string, string> = {}
for (const group of catalog.configGroups) { for (const group of catalog.configGroups) {
if (isDriveBayGroup(group.name)) {
const driveOpt = group.options.find(o => o.name === 'Drive Selection')
const def = driveOpt?.values.find(v => v.is_default) ?? driveOpt?.values[0]
seeded[group.name] = { drive: def?.value ?? 'none', quantity: 0 }
} else {
const opt = group.options[0] const opt = group.options[0]
if (!opt) continue if (!opt) continue
const def = opt.values.find(v => v.is_default) ?? opt.values[0] const def = opt.values.find(v => v.is_default) ?? opt.values[0]
if (def) seeded[group.name] = def.value if (def) seeded[group.name] = def.value
} }
}
selections.value = seeded selections.value = seeded
} }
@@ -81,6 +107,18 @@ export const useDedicatedConfiguratorStore = defineStore('dedicatedConfigurator'
selections.value = { ...selections.value, [groupName]: value } selections.value = { ...selections.value, [groupName]: value }
} }
function setDrive(groupName: string, drive: string): void {
const current = selections.value[groupName]
const base = isDriveBaySelection(current) ? current : { drive: 'none', quantity: 0 }
selections.value = { ...selections.value, [groupName]: { ...base, drive } }
}
function setDriveQuantity(groupName: string, quantity: number): void {
const current = selections.value[groupName]
const base = isDriveBaySelection(current) ? current : { drive: 'none', quantity: 0 }
selections.value = { ...selections.value, [groupName]: { ...base, quantity } }
}
function findGroup(groupName: string): DedicatedConfigGroup | null { function findGroup(groupName: string): DedicatedConfigGroup | null {
return configGroups.value.find(g => g.name === groupName) ?? null return configGroups.value.find(g => g.name === groupName) ?? null
} }
@@ -113,12 +151,32 @@ export const useDedicatedConfiguratorStore = defineStore('dedicatedConfigurator'
const baselinePrice = computed<number>(() => planPriceForCycle(cycle.value)) const baselinePrice = computed<number>(() => planPriceForCycle(cycle.value))
// Per-group cost map for drive bay groups: drive_selection.price × quantity.
const driveBayCost = computed<Record<string, number>>(() => {
const costs: Record<string, number> = {}
for (const [groupName, sel] of Object.entries(selections.value)) {
if (!isDriveBaySelection(sel)) continue
const group = findGroup(groupName)
if (!group) continue
const driveOpt = group.options.find(o => o.name === 'Drive Selection')
const drive = driveOpt?.values.find(v => v.value === sel.drive)
if (!drive) continue
const perDrive = pickCyclePrice(drive, cycle.value)
costs[groupName] = perDrive * sel.quantity
}
return costs
})
const addOnsTotal = computed<number>(() => { const addOnsTotal = computed<number>(() => {
let total = 0 let total = 0
for (const [groupName, valueSlug] of Object.entries(selections.value)) { for (const [groupName, sel] of Object.entries(selections.value)) {
const v = findValue(groupName, valueSlug) if (isDriveBaySelection(sel)) {
total += driveBayCost.value[groupName] ?? 0
} else {
const v = findValue(groupName, sel)
if (v) total += pickCyclePrice(v, cycle.value) if (v) total += pickCyclePrice(v, cycle.value)
} }
}
return total return total
}) })
@@ -142,39 +200,42 @@ export const useDedicatedConfiguratorStore = defineStore('dedicatedConfigurator'
return !CYCLES_WITH_SETUP_FEE.includes(cycle.value) return !CYCLES_WITH_SETUP_FEE.includes(cycle.value)
}) })
// Build the share URL with all current selections + cycle as query params. function buildParams(): URLSearchParams {
// Param keys are short and readable so URLs stay shareable.
const shareUrl = computed<string>(() => {
if (!plan.value) return ''
const params = new URLSearchParams() const params = new URLSearchParams()
if (cycle.value !== 'monthly') params.set('cycle', cycle.value) if (cycle.value !== 'monthly') params.set('cycle', cycle.value)
for (const [groupName, valueSlug] of Object.entries(selections.value)) { for (const [groupName, sel] of Object.entries(selections.value)) {
if (isDriveBaySelection(sel)) {
const prefix = driveBayParamPrefix(groupName)
if (!prefix) continue
// Only include in URL when the user has actually picked drives.
if (sel.drive !== 'none' && sel.quantity > 0) {
params.set(`${prefix}_drive`, sel.drive)
params.set(`${prefix}_qty`, String(sel.quantity))
}
} else {
const param = groupNameToParam(groupName) const param = groupNameToParam(groupName)
if (!param) continue if (!param) continue
const g = findGroup(groupName) const g = findGroup(groupName)
const def = g?.options[0]?.values.find(v => v.is_default)?.value const def = g?.options[0]?.values.find(v => v.is_default)?.value
// Only add to URL if non-default if (def && sel === def) continue
if (def && valueSlug === def) continue params.set(param, sel)
params.set(param, valueSlug) }
}
return params
}
const shareUrl = computed<string>(() => {
if (!plan.value) return ''
const qs = buildParams().toString()
if (typeof window === 'undefined') {
return qs ? `/dedicated-servers/${plan.value.slug}?${qs}` : `/dedicated-servers/${plan.value.slug}`
} }
const qs = params.toString()
if (typeof window === 'undefined') return qs ? `/dedicated-servers/${plan.value.slug}?${qs}` : `/dedicated-servers/${plan.value.slug}`
return qs ? `${window.location.origin}${window.location.pathname}?${qs}` : `${window.location.origin}${window.location.pathname}` return qs ? `${window.location.origin}${window.location.pathname}?${qs}` : `${window.location.origin}${window.location.pathname}`
}) })
const checkoutUrl = computed<string>(() => { const checkoutUrl = computed<string>(() => {
if (!plan.value) return '' if (!plan.value) return ''
const params = new URLSearchParams() const qs = buildParams().toString()
if (cycle.value !== 'monthly') params.set('cycle', cycle.value)
for (const [groupName, valueSlug] of Object.entries(selections.value)) {
const param = groupNameToParam(groupName)
if (!param) continue
const g = findGroup(groupName)
const def = g?.options[0]?.values.find(v => v.is_default)?.value
if (def && valueSlug === def) continue
params.set(param, valueSlug)
}
const qs = params.toString()
const base = `${accountUrl.value}/checkout/${plan.value.id}` const base = `${accountUrl.value}/checkout/${plan.value.id}`
return qs ? `${base}?${qs}` : base return qs ? `${base}?${qs}` : base
}) })
@@ -187,24 +248,41 @@ export const useDedicatedConfiguratorStore = defineStore('dedicatedConfigurator'
'Dedicated 14th Gen — Operating System': 'os', 'Dedicated 14th Gen — Operating System': 'os',
'Dedicated 14th Gen — Bandwidth': 'bw', 'Dedicated 14th Gen — Bandwidth': 'bw',
'Dedicated 14th Gen — IPv4 Block': 'ipv4', 'Dedicated 14th Gen — IPv4 Block': 'ipv4',
'Dedicated 14th Gen — Private Networking': 'privnet',
'Dedicated 14th Gen — PCIe NVMe Add-in': 'pcie',
} }
return map[groupName] ?? null return map[groupName] ?? null
} }
function paramToGroupName(param: string): string[] { function paramToGroupName(param: string): string[] {
// Returns matching group names for a query param. Some params (cpu) map to
// multiple groups depending on chassis (CPU vs CPU R740xd) — we set on
// every matching group, the visible one (only one is attached) wins.
const map: Record<string, string[]> = { const map: Record<string, string[]> = {
cpu: ['Dedicated 14th Gen — CPU Upgrade', 'Dedicated 14th Gen — CPU Upgrade (R740xd)'], cpu: ['Dedicated 14th Gen — CPU Upgrade', 'Dedicated 14th Gen — CPU Upgrade (R740xd)'],
ram: ['Dedicated 14th Gen — RAM Upgrade'], ram: ['Dedicated 14th Gen — RAM Upgrade'],
os: ['Dedicated 14th Gen — Operating System'], os: ['Dedicated 14th Gen — Operating System'],
bw: ['Dedicated 14th Gen — Bandwidth'], bw: ['Dedicated 14th Gen — Bandwidth'],
ipv4: ['Dedicated 14th Gen — IPv4 Block'], ipv4: ['Dedicated 14th Gen — IPv4 Block'],
privnet: ['Dedicated 14th Gen — Private Networking'],
pcie: ['Dedicated 14th Gen — PCIe NVMe Add-in'],
} }
return map[param] ?? [] return map[param] ?? []
} }
function driveBayParamPrefix(groupName: string): string | null {
if (groupName.includes('LFF Drive Bays')) return 'lff'
if (groupName.includes('SFF Drive Bays')) return 'sff'
if (groupName.includes('NVMe Drive Bays')) return 'nvme'
return null
}
function paramPrefixToGroupName(prefix: string): string | null {
const map: Record<string, string> = {
lff: 'Dedicated 14th Gen — LFF Drive Bays',
sff: 'Dedicated 14th Gen — SFF Drive Bays',
nvme: 'Dedicated 14th Gen — NVMe Drive Bays',
}
return map[prefix] ?? null
}
function hydrateFromUrl(search: string): void { function hydrateFromUrl(search: string): void {
const p = new URLSearchParams(search) const p = new URLSearchParams(search)
@@ -213,7 +291,7 @@ export const useDedicatedConfiguratorStore = defineStore('dedicatedConfigurator'
cycle.value = c as DedicatedCycle cycle.value = c as DedicatedCycle
} }
for (const param of ['cpu', 'ram', 'os', 'bw', 'ipv4']) { for (const param of ['cpu', 'ram', 'os', 'bw', 'ipv4', 'privnet', 'pcie']) {
const v = p.get(param) const v = p.get(param)
if (!v) continue if (!v) continue
for (const groupName of paramToGroupName(param)) { for (const groupName of paramToGroupName(param)) {
@@ -225,6 +303,39 @@ export const useDedicatedConfiguratorStore = defineStore('dedicatedConfigurator'
} }
} }
} }
for (const prefix of ['lff', 'sff', 'nvme']) {
const groupName = paramPrefixToGroupName(prefix)
if (!groupName) continue
const driveSlug = p.get(`${prefix}_drive`)
const qtyStr = p.get(`${prefix}_qty`)
if (!driveSlug && !qtyStr) continue
const group = findGroup(groupName)
if (!group) continue
const driveOpt = group.options.find(o => o.name === 'Drive Selection')
const qtyOpt = group.options.find(o => o.name === 'Drive Quantity')
if (!driveOpt) continue
const validDrive = driveSlug
? driveOpt.values.some(v => v.value === driveSlug)
: false
const max = qtyOpt?.max_qty ?? 24
const min = qtyOpt?.min_qty ?? 0
const parsedQty = qtyStr ? parseInt(qtyStr, 10) : NaN
const clampedQty = Number.isFinite(parsedQty)
? Math.max(min, Math.min(max, parsedQty))
: 0
const current = selections.value[groupName]
const baseDrive = isDriveBaySelection(current) ? current.drive : 'none'
const baseQty = isDriveBaySelection(current) ? current.quantity : 0
selections.value[groupName] = {
drive: validDrive && driveSlug ? driveSlug : baseDrive,
quantity: qtyStr ? clampedQty : baseQty,
}
}
} }
return { return {
@@ -235,6 +346,7 @@ export const useDedicatedConfiguratorStore = defineStore('dedicatedConfigurator'
cycle, cycle,
baselinePrice, baselinePrice,
addOnsTotal, addOnsTotal,
driveBayCost,
setupFee, setupFee,
cycleSubtotal, cycleSubtotal,
cycleTotal, cycleTotal,
@@ -244,6 +356,8 @@ export const useDedicatedConfiguratorStore = defineStore('dedicatedConfigurator'
checkoutUrl, checkoutUrl,
init, init,
setSelection, setSelection,
setDrive,
setDriveQuantity,
hydrateFromUrl, hydrateFromUrl,
findGroup, findGroup,
findValue, findValue,

View File

@@ -114,10 +114,10 @@ Route::get('/dedicated-servers/{slug}', function (string $slug) {
->orderBy('sort_order') ->orderBy('sort_order')
->get(); ->get();
// Drive-bay combos are seeded with value slugs that start with the // Drive bay groups now use a Drive Selection (radio, per-drive cost) +
// bay count (e.g. "8x8tb-hdd" → 8 bays). Filter out combos that // Drive Quantity (stepper) composite. Clamp the stepper's max_qty down to
// don't fit this chassis so customers never see options that can't // the chassis's physical bay_count so customers can't pick more drives
// physically deploy on their build. // than the chassis can hold.
$bayCount = (int) ($plan->features['bay_count'] ?? 0); $bayCount = (int) ($plan->features['bay_count'] ?? 0);
if ($bayCount > 0) { if ($bayCount > 0) {
foreach ($configGroups as $group) { foreach ($configGroups as $group) {
@@ -125,15 +125,44 @@ Route::get('/dedicated-servers/{slug}', function (string $slug) {
continue; continue;
} }
foreach ($group->options as $option) { foreach ($group->options as $option) {
$filtered = $option->values->filter(function ($value) use ($bayCount): bool { if ($option->name === 'Drive Quantity' && (int) $option->max_qty > $bayCount) {
if (preg_match('/^(\d+)x/', $value->value, $m)) { $option->max_qty = $bayCount;
return (int) $m[1] <= $bayCount; }
}
}
} }
// 'none' (and any other non-quantity value) always passes. // CPU Platinum filter — chassis without features.cpu_premium hide the
return true; // Platinum 8280 option (R440/R540 lack support per Dell/SaveMyServer).
})->values(); $cpuPremium = (bool) ($plan->features['cpu_premium'] ?? false);
if (! $cpuPremium) {
foreach ($configGroups as $group) {
if (! str_contains($group->name, 'CPU Upgrade')) {
continue;
}
foreach ($group->options as $option) {
$filtered = $option->values->filter(
fn ($v) => ! str_contains((string) $v->value, 'platinum'),
)->values();
$option->setRelation('values', $filtered);
}
}
}
// RAM cap filter — chassis with limited DIMM slots hide tiers above
// their max. R440/R540 (16 slots) cap at 1 TB; others (24 slots) do 1.5 TB.
$maxRamGb = (int) ($plan->features['max_ram_gb'] ?? 0);
if ($maxRamGb > 0) {
foreach ($configGroups as $group) {
if (! str_contains($group->name, 'RAM Upgrade')) {
continue;
}
foreach ($group->options as $option) {
$filtered = $option->values->filter(function ($v) use ($maxRamGb) {
$size = (int) $v->value;
return $size === 0 || $size <= $maxRamGb;
})->values();
$option->setRelation('values', $filtered); $option->setRelation('values', $filtered);
} }
} }

View File

@@ -182,29 +182,123 @@ test('PCIe NVMe Add-in attaches only to LFF + SFF chassis, not pure-NVMe', funct
} }
}); });
test('drive bay options are filtered to those that fit chassis bay count', function (): void { test('drive bay groups expose two options: Drive Selection radio + Drive Quantity stepper', function (): void {
// R440 has 4 LFF bays — combos requiring more bays should be hidden. foreach (['r440-4lff', 'r640-8sff', 'r740xd-24nvme'] as $slug) {
$r440Response = $this->get('/dedicated-servers/r440-4lff'); $response = $this->get("/dedicated-servers/{$slug}");
$r440Groups = collect($r440Response->viewData('page')['props']['configGroups']); $groups = collect($response->viewData('page')['props']['configGroups']);
$lff = $r440Groups->firstWhere('name', 'Dedicated 14th Gen — LFF Drive Bays'); $bayGroup = $groups->first(fn ($g) => str_contains($g['name'], 'Drive Bays'));
$values = collect($lff['options'][0]['values'])->pluck('value')->all();
expect($values)->toContain('none', '2x4tb-hdd', '4x4tb-hdd', '2x8tb-hdd', '4x8tb-hdd');
expect($values)->not->toContain('8x8tb-hdd', '12x8tb-hdd'); // 8 and 12 bays don't fit in 4
// R740xd LFF has 12 bays — should see all combos. expect($bayGroup)->not->toBeNull("Plan {$slug} missing Drive Bays group");
$r740xdResponse = $this->get('/dedicated-servers/r740xd-12lff'); expect(collect($bayGroup['options'])->pluck('name')->all())
$r740xdGroups = collect($r740xdResponse->viewData('page')['props']['configGroups']); ->toBe(['Drive Selection', 'Drive Quantity']);
$lff740 = $r740xdGroups->firstWhere('name', 'Dedicated 14th Gen — LFF Drive Bays');
$values740 = collect($lff740['options'][0]['values'])->pluck('value')->all();
expect($values740)->toContain('8x8tb-hdd', '12x8tb-hdd');
// R640 NVMe has 10 bays — 16× shouldn't fit but 8× should. $driveOpt = collect($bayGroup['options'])->firstWhere('name', 'Drive Selection');
$r640Response = $this->get('/dedicated-servers/r640-10nvme'); $qtyOpt = collect($bayGroup['options'])->firstWhere('name', 'Drive Quantity');
$r640Groups = collect($r640Response->viewData('page')['props']['configGroups']);
$nvme = $r640Groups->firstWhere('name', 'Dedicated 14th Gen — NVMe Drive Bays'); expect($driveOpt['type'])->toBe('radio');
$valuesNvme = collect($nvme['options'][0]['values'])->pluck('value')->all(); expect($qtyOpt['type'])->toBe('quantity');
expect($valuesNvme)->toContain('8x2tb-nvme'); expect((int) $qtyOpt['min_qty'])->toBe(0);
expect($valuesNvme)->not->toContain('16x2tb-nvme'); // 16 bays don't fit in 10 expect((int) $qtyOpt['max_qty'])->toBeGreaterThan(0);
}
});
test('Drive Quantity max_qty clamps to chassis bay_count', function (): void {
// R440 has 4 LFF bays — Drive Quantity max_qty should clamp to 4.
$response = $this->get('/dedicated-servers/r440-4lff');
$bayGroup = collect($response->viewData('page')['props']['configGroups'])
->firstWhere('name', 'Dedicated 14th Gen — LFF Drive Bays');
$qtyOpt = collect($bayGroup['options'])->firstWhere('name', 'Drive Quantity');
expect((int) $qtyOpt['max_qty'])->toBe(4);
// R540 has 8 LFF bays.
$r540 = $this->get('/dedicated-servers/r540-8lff');
$r540Bay = collect($r540->viewData('page')['props']['configGroups'])
->firstWhere('name', 'Dedicated 14th Gen — LFF Drive Bays');
$r540Qty = collect($r540Bay['options'])->firstWhere('name', 'Drive Quantity');
expect((int) $r540Qty['max_qty'])->toBe(8);
// R640 NVMe has 10 bays — clamps the seeded 24-max down to 10.
$r640 = $this->get('/dedicated-servers/r640-10nvme');
$r640Bay = collect($r640->viewData('page')['props']['configGroups'])
->firstWhere('name', 'Dedicated 14th Gen — NVMe Drive Bays');
$r640Qty = collect($r640Bay['options'])->firstWhere('name', 'Drive Quantity');
expect((int) $r640Qty['max_qty'])->toBe(10);
// R740xd 24 NVMe → max 24, the seeded value, untouched.
$r740 = $this->get('/dedicated-servers/r740xd-24nvme');
$r740Bay = collect($r740->viewData('page')['props']['configGroups'])
->firstWhere('name', 'Dedicated 14th Gen — NVMe Drive Bays');
$r740Qty = collect($r740Bay['options'])->firstWhere('name', 'Drive Quantity');
expect((int) $r740Qty['max_qty'])->toBe(24);
});
test('LFF Drive Selection includes SAS HDD and SAS SSD variants', function (): void {
$response = $this->get('/dedicated-servers/r540-8lff');
$bayGroup = collect($response->viewData('page')['props']['configGroups'])
->firstWhere('name', 'Dedicated 14th Gen — LFF Drive Bays');
$driveOpt = collect($bayGroup['options'])->firstWhere('name', 'Drive Selection');
$values = collect($driveOpt['values'])->pluck('value')->all();
// SAS HDD variants
expect($values)->toContain('sas-hdd-12tb');
expect($values)->toContain('sas-hdd-16tb');
// SAS SSD variants (≥3 per spec acceptance)
expect($values)->toContain('sas-ssd-1920gb-lff');
expect($values)->toContain('sas-ssd-3840gb-lff');
expect($values)->toContain('sas-ssd-7680gb-lff');
// SATA still present
expect($values)->toContain('sata-hdd-4tb');
expect($values)->toContain('sata-ssd-3840gb-lff');
});
test('SFF Drive Selection includes SAS SSD variants', function (): void {
$response = $this->get('/dedicated-servers/r640-8sff');
$bayGroup = collect($response->viewData('page')['props']['configGroups'])
->firstWhere('name', 'Dedicated 14th Gen — SFF Drive Bays');
$driveOpt = collect($bayGroup['options'])->firstWhere('name', 'Drive Selection');
$values = collect($driveOpt['values'])->pluck('value')->all();
expect($values)->toContain('sas-ssd-1920gb');
expect($values)->toContain('sas-ssd-3840gb');
expect($values)->toContain('sas-ssd-7680gb');
expect($values)->toContain('sata-ssd-480gb');
});
test('NVMe Drive Selection includes enterprise U.2 sizes only', function (): void {
$response = $this->get('/dedicated-servers/r740xd-24nvme');
$bayGroup = collect($response->viewData('page')['props']['configGroups'])
->firstWhere('name', 'Dedicated 14th Gen — NVMe Drive Bays');
$driveOpt = collect($bayGroup['options'])->firstWhere('name', 'Drive Selection');
$values = collect($driveOpt['values'])->pluck('value')->all();
expect($values)->toContain('none');
expect($values)->toContain('u2-nvme-1920gb');
expect($values)->toContain('u2-nvme-3840gb');
expect($values)->toContain('u2-nvme-7680gb');
});
test('Drive Selection per-drive prices align with the spec table', function (): void {
$response = $this->get('/dedicated-servers/r740xd-12lff');
$bayGroup = collect($response->viewData('page')['props']['configGroups'])
->firstWhere('name', 'Dedicated 14th Gen — LFF Drive Bays');
$driveOpt = collect($bayGroup['options'])->firstWhere('name', 'Drive Selection');
$expected = [
'sata-hdd-4tb' => 12.00,
'sata-hdd-24tb' => 75.00,
'sas-hdd-12tb' => 50.00,
'sas-hdd-16tb' => 55.00,
'sata-ssd-7680gb-lff' => 100.00,
'sas-ssd-7680gb-lff' => 200.00,
];
foreach ($expected as $slug => $monthly) {
$row = collect($driveOpt['values'])->firstWhere('value', $slug);
expect($row)->not->toBeNull("Drive {$slug} missing");
expect((float) $row['monthly_price'])->toBe($monthly);
}
}); });
test('drive bay groups are attached to chassis by bay type', function (): void { test('drive bay groups are attached to chassis by bay type', function (): void {