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:
@@ -580,10 +580,16 @@ class ConfigOptionSeeder extends Seeder
|
||||
$gen14PciNvme->plans()->syncWithoutDetaching($gen14PciNvmePlans);
|
||||
|
||||
// ─── 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(
|
||||
['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',
|
||||
'service_type' => 'dedicated',
|
||||
'is_active' => true,
|
||||
@@ -591,45 +597,30 @@ class ConfigOptionSeeder extends Seeder
|
||||
],
|
||||
);
|
||||
|
||||
$gen14LffOption = $this->seedRadioOption($gen14Lff, 'LFF Drive Bays', false, 1);
|
||||
$this->seedValues($gen14LffOption, [
|
||||
$gen14LffDriveOption = $this->seedRadioOption($gen14Lff, 'Drive Selection', false, 1);
|
||||
$this->seedValues($gen14LffDriveOption, [
|
||||
['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× 4 TB SATA HDD', 'value' => '4x4tb-hdd', 'monthly' => 48.00],
|
||||
['label' => '2× 8 TB SATA HDD', 'value' => '2x8tb-hdd', 'monthly' => 40.00],
|
||||
['label' => '4× 8 TB SATA HDD', 'value' => '4x8tb-hdd', 'monthly' => 80.00],
|
||||
['label' => '8× 8 TB SATA HDD', 'value' => '8x8tb-hdd', 'monthly' => 160.00],
|
||||
['label' => '12× 8 TB SATA HDD', 'value' => '12x8tb-hdd', 'monthly' => 240.00],
|
||||
['label' => '2× 12 TB Enterprise HDD', 'value' => '2x12tb-hdd', 'monthly' => 90.00],
|
||||
['label' => '4× 12 TB Enterprise HDD', 'value' => '4x12tb-hdd', 'monthly' => 180.00],
|
||||
['label' => '8× 12 TB Enterprise HDD', 'value' => '8x12tb-hdd', 'monthly' => 360.00],
|
||||
['label' => '12× 12 TB Enterprise HDD', 'value' => '12x12tb-hdd', 'monthly' => 540.00],
|
||||
['label' => '2× 20 TB Enterprise HDD', 'value' => '2x20tb-hdd', 'monthly' => 110.00],
|
||||
['label' => '4× 20 TB Enterprise HDD', 'value' => '4x20tb-hdd', 'monthly' => 220.00],
|
||||
['label' => '8× 20 TB Enterprise HDD', 'value' => '8x20tb-hdd', 'monthly' => 440.00],
|
||||
['label' => '12× 20 TB Enterprise HDD', 'value' => '12x20tb-hdd', 'monthly' => 660.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],
|
||||
['label' => '4 TB SATA HDD', 'value' => 'sata-hdd-4tb', 'monthly' => 12.00],
|
||||
['label' => '8 TB SATA HDD', 'value' => 'sata-hdd-8tb', 'monthly' => 20.00],
|
||||
['label' => '12 TB Enterprise SATA HDD', 'value' => 'sata-hdd-12tb', 'monthly' => 45.00],
|
||||
['label' => '20 TB Enterprise SATA HDD', 'value' => 'sata-hdd-20tb', 'monthly' => 55.00],
|
||||
['label' => '24 TB Enterprise SATA HDD', 'value' => 'sata-hdd-24tb', 'monthly' => 75.00],
|
||||
['label' => '12 TB Enterprise SAS HDD', 'value' => 'sas-hdd-12tb', 'monthly' => 50.00],
|
||||
['label' => '16 TB Enterprise SAS HDD', 'value' => 'sas-hdd-16tb', 'monthly' => 55.00],
|
||||
['label' => '480 GB SATA SSD (LFF carrier)', 'value' => 'sata-ssd-480gb-lff', 'monthly' => 10.00],
|
||||
['label' => '1.92 TB SATA SSD (LFF carrier)', 'value' => 'sata-ssd-1920gb-lff', 'monthly' => 18.00],
|
||||
['label' => '3.84 TB SATA SSD (LFF carrier)', 'value' => 'sata-ssd-3840gb-lff', 'monthly' => 45.00],
|
||||
['label' => '7.68 TB SATA SSD (LFF carrier)', 'value' => 'sata-ssd-7680gb-lff', 'monthly' => 100.00],
|
||||
['label' => '1.92 TB SAS SSD (LFF carrier)', 'value' => 'sas-ssd-1920gb-lff', 'monthly' => 40.00],
|
||||
['label' => '3.84 TB SAS SSD (LFF carrier)', 'value' => 'sas-ssd-3840gb-lff', 'monthly' => 80.00],
|
||||
['label' => '7.68 TB SAS SSD (LFF carrier)', 'value' => 'sas-ssd-7680gb-lff', 'monthly' => 200.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'];
|
||||
$gen14LffPlans = Plan::query()->whereIn('slug', $gen14LffPlanSlugs)->pluck('id');
|
||||
$gen14Lff->plans()->syncWithoutDetaching($gen14LffPlans);
|
||||
@@ -638,7 +629,7 @@ class ConfigOptionSeeder extends Seeder
|
||||
$gen14Sff = PlanConfigGroup::updateOrCreate(
|
||||
['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',
|
||||
'service_type' => 'dedicated',
|
||||
'is_active' => true,
|
||||
@@ -646,18 +637,20 @@ class ConfigOptionSeeder extends Seeder
|
||||
],
|
||||
);
|
||||
|
||||
$gen14SffOption = $this->seedRadioOption($gen14Sff, 'SFF Drive Bays', false, 1);
|
||||
$this->seedValues($gen14SffOption, [
|
||||
$gen14SffDriveOption = $this->seedRadioOption($gen14Sff, 'Drive Selection', false, 1);
|
||||
$this->seedValues($gen14SffDriveOption, [
|
||||
['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' => '4× 480 GB SATA SSD', 'value' => '4x480gb-ssd', 'monthly' => 40.00],
|
||||
['label' => '2× 1.92 TB SATA SSD', 'value' => '2x1920gb-ssd', 'monthly' => 36.00],
|
||||
['label' => '4× 1.92 TB SATA SSD', 'value' => '4x1920gb-ssd', 'monthly' => 72.00],
|
||||
['label' => '8× 1.92 TB SATA SSD', 'value' => '8x1920gb-ssd', 'monthly' => 144.00],
|
||||
['label' => '16× 1.92 TB SATA SSD', 'value' => '16x1920gb-ssd', 'monthly' => 288.00],
|
||||
['label' => '24× 1.92 TB SATA SSD', 'value' => '24x1920gb-ssd', 'monthly' => 432.00],
|
||||
['label' => '480 GB SATA SSD', 'value' => 'sata-ssd-480gb', 'monthly' => 10.00],
|
||||
['label' => '1.92 TB SATA SSD', 'value' => 'sata-ssd-1920gb', 'monthly' => 18.00],
|
||||
['label' => '3.84 TB SATA SSD', 'value' => 'sata-ssd-3840gb', 'monthly' => 45.00],
|
||||
['label' => '7.68 TB SATA SSD', 'value' => 'sata-ssd-7680gb', 'monthly' => 100.00],
|
||||
['label' => '1.92 TB SAS SSD', 'value' => 'sas-ssd-1920gb', 'monthly' => 40.00],
|
||||
['label' => '3.84 TB SAS SSD', 'value' => 'sas-ssd-3840gb', 'monthly' => 80.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'];
|
||||
$gen14SffPlans = Plan::query()->whereIn('slug', $gen14SffPlanSlugs)->pluck('id');
|
||||
$gen14Sff->plans()->syncWithoutDetaching($gen14SffPlans);
|
||||
@@ -666,7 +659,7 @@ class ConfigOptionSeeder extends Seeder
|
||||
$gen14Nvme = PlanConfigGroup::updateOrCreate(
|
||||
['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',
|
||||
'service_type' => 'dedicated',
|
||||
'is_active' => true,
|
||||
@@ -674,17 +667,16 @@ class ConfigOptionSeeder extends Seeder
|
||||
],
|
||||
);
|
||||
|
||||
$gen14NvmeOption = $this->seedRadioOption($gen14Nvme, 'NVMe Drive Bays', false, 1);
|
||||
$this->seedValues($gen14NvmeOption, [
|
||||
$gen14NvmeDriveOption = $this->seedRadioOption($gen14Nvme, 'Drive Selection', false, 1);
|
||||
$this->seedValues($gen14NvmeDriveOption, [
|
||||
['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' => '4× 1 TB U.2 NVMe', 'value' => '4x1tb-nvme', 'monthly' => 88.00],
|
||||
['label' => '2× 2 TB U.2 NVMe', 'value' => '2x2tb-nvme', 'monthly' => 96.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],
|
||||
['label' => '1.92 TB U.2 NVMe', 'value' => 'u2-nvme-1920gb', 'monthly' => 30.00],
|
||||
['label' => '3.84 TB U.2 NVMe', 'value' => 'u2-nvme-3840gb', 'monthly' => 70.00],
|
||||
['label' => '7.68 TB U.2 NVMe', 'value' => 'u2-nvme-7680gb', 'monthly' => 150.00],
|
||||
]);
|
||||
|
||||
$this->seedQuantityOption($gen14Nvme, 'Drive Quantity', 0, 24, 'drives', 0.00, 2);
|
||||
|
||||
$gen14NvmePlanSlugs = ['r640-10nvme', 'r740xd-24nvme'];
|
||||
$gen14NvmePlans = Plan::query()->whereIn('slug', $gen14NvmePlanSlugs)->pluck('id');
|
||||
$gen14Nvme->plans()->syncWithoutDetaching($gen14NvmePlans);
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
<script lang="ts" setup>
|
||||
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 {
|
||||
plan: DedicatedPlan
|
||||
configGroups: DedicatedConfigGroup[]
|
||||
selections: Record<string, string>
|
||||
selections: Record<string, DedicatedSelection>
|
||||
cycle: DedicatedCycle
|
||||
baselinePrice: number
|
||||
cycleSubtotal: number
|
||||
@@ -20,6 +27,10 @@ interface 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> = {
|
||||
monthly: 'Monthly',
|
||||
quarterly: 'Quarterly (3 months)',
|
||||
@@ -54,13 +65,41 @@ const lineItems = computed<LineItem[]>(() => {
|
||||
|
||||
// Then each selected upgrade — only if it has a non-zero contribution.
|
||||
for (const group of props.configGroups) {
|
||||
const valueSlug = props.selections[group.name]
|
||||
if (!valueSlug) continue
|
||||
const sel = props.selections[group.name]
|
||||
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]
|
||||
if (!opt) continue
|
||||
|
||||
const value = opt.values.find(v => v.value === valueSlug)
|
||||
const value = opt.values.find(v => v.value === sel)
|
||||
if (!value) continue
|
||||
|
||||
const raw = props.cycle === 'monthly'
|
||||
|
||||
@@ -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>
|
||||
@@ -1,7 +1,15 @@
|
||||
<script lang="ts" setup>
|
||||
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 DriveBayGroupSelector from './DriveBayGroupSelector.vue'
|
||||
import CycleToggle from './CycleToggle.vue'
|
||||
|
||||
interface Props {
|
||||
@@ -33,11 +41,27 @@ function onSelectionChange(groupName: string, value: string): void {
|
||||
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 {
|
||||
store.cycle = c
|
||||
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(() => {
|
||||
store.init({
|
||||
plan: props.plan,
|
||||
@@ -66,14 +90,24 @@ watch(() => props.plan?.id, () => {
|
||||
</div>
|
||||
|
||||
<div class="dedicated-configurator__groups">
|
||||
<OptionGroupSelector
|
||||
v-for="group in configGroups"
|
||||
:key="group.id"
|
||||
:group="group"
|
||||
:selected="store.selections[group.name] ?? ''"
|
||||
:cycle="store.cycle"
|
||||
@update:selected="(v: string) => onSelectionChange(group.name, v)"
|
||||
/>
|
||||
<template v-for="group in configGroups" :key="group.id">
|
||||
<DriveBayGroupSelector
|
||||
v-if="isDriveBayGroup(group.name)"
|
||||
:group="group"
|
||||
: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"
|
||||
@update:selected="(v: string) => onSelectionChange(group.name, v)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -26,6 +26,10 @@ export interface DedicatedConfigOption {
|
||||
id: number
|
||||
name: string
|
||||
type: string
|
||||
min_qty?: number | null
|
||||
max_qty?: number | null
|
||||
step?: number | null
|
||||
unit_label?: string | null
|
||||
values: DedicatedConfigValue[]
|
||||
}
|
||||
|
||||
@@ -37,6 +41,13 @@ export interface DedicatedConfigGroup {
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
export interface DriveBaySelection {
|
||||
drive: string
|
||||
quantity: number
|
||||
}
|
||||
|
||||
export type DedicatedSelection = string | DriveBaySelection
|
||||
|
||||
export type DedicatedCycle = 'monthly' | 'quarterly' | 'semi_annual' | 'annual'
|
||||
|
||||
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']
|
||||
|
||||
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', () => {
|
||||
const plan = ref<DedicatedPlan | null>(null)
|
||||
const configGroups = ref<DedicatedConfigGroup[]>([])
|
||||
const accountUrl = ref<string>('')
|
||||
|
||||
// selections: groupName → option value slug (e.g., {"Dedicated 14th Gen — RAM Upgrade": "64"})
|
||||
const selections = ref<Record<string, string>>({})
|
||||
// selections: groupName → either a single value slug (single-option groups)
|
||||
// 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')
|
||||
|
||||
function init(catalog: {
|
||||
@@ -66,13 +87,18 @@ export const useDedicatedConfiguratorStore = defineStore('dedicatedConfigurator'
|
||||
configGroups.value = catalog.configGroups
|
||||
accountUrl.value = catalog.accountUrl
|
||||
|
||||
// Seed selections with each group's default value (or first value if no default).
|
||||
const seeded: Record<string, string> = {}
|
||||
const seeded: Record<string, DedicatedSelection> = {}
|
||||
for (const group of catalog.configGroups) {
|
||||
const opt = group.options[0]
|
||||
if (!opt) continue
|
||||
const def = opt.values.find(v => v.is_default) ?? opt.values[0]
|
||||
if (def) seeded[group.name] = def.value
|
||||
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]
|
||||
if (!opt) continue
|
||||
const def = opt.values.find(v => v.is_default) ?? opt.values[0]
|
||||
if (def) seeded[group.name] = def.value
|
||||
}
|
||||
}
|
||||
selections.value = seeded
|
||||
}
|
||||
@@ -81,6 +107,18 @@ export const useDedicatedConfiguratorStore = defineStore('dedicatedConfigurator'
|
||||
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 {
|
||||
return configGroups.value.find(g => g.name === groupName) ?? null
|
||||
}
|
||||
@@ -113,11 +151,31 @@ export const useDedicatedConfiguratorStore = defineStore('dedicatedConfigurator'
|
||||
|
||||
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>(() => {
|
||||
let total = 0
|
||||
for (const [groupName, valueSlug] of Object.entries(selections.value)) {
|
||||
const v = findValue(groupName, valueSlug)
|
||||
if (v) total += pickCyclePrice(v, cycle.value)
|
||||
for (const [groupName, sel] of Object.entries(selections.value)) {
|
||||
if (isDriveBaySelection(sel)) {
|
||||
total += driveBayCost.value[groupName] ?? 0
|
||||
} else {
|
||||
const v = findValue(groupName, sel)
|
||||
if (v) total += pickCyclePrice(v, cycle.value)
|
||||
}
|
||||
}
|
||||
return total
|
||||
})
|
||||
@@ -142,39 +200,42 @@ export const useDedicatedConfiguratorStore = defineStore('dedicatedConfigurator'
|
||||
return !CYCLES_WITH_SETUP_FEE.includes(cycle.value)
|
||||
})
|
||||
|
||||
// Build the share URL with all current selections + cycle as query params.
|
||||
// Param keys are short and readable so URLs stay shareable.
|
||||
const shareUrl = computed<string>(() => {
|
||||
if (!plan.value) return ''
|
||||
function buildParams(): URLSearchParams {
|
||||
const params = new URLSearchParams()
|
||||
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
|
||||
// Only add to URL if non-default
|
||||
if (def && valueSlug === def) continue
|
||||
params.set(param, valueSlug)
|
||||
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)
|
||||
if (!param) continue
|
||||
const g = findGroup(groupName)
|
||||
const def = g?.options[0]?.values.find(v => v.is_default)?.value
|
||||
if (def && sel === def) continue
|
||||
params.set(param, sel)
|
||||
}
|
||||
}
|
||||
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}`
|
||||
})
|
||||
|
||||
const checkoutUrl = computed<string>(() => {
|
||||
if (!plan.value) return ''
|
||||
const params = new URLSearchParams()
|
||||
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 qs = buildParams().toString()
|
||||
const base = `${accountUrl.value}/checkout/${plan.value.id}`
|
||||
return qs ? `${base}?${qs}` : base
|
||||
})
|
||||
@@ -187,24 +248,41 @@ export const useDedicatedConfiguratorStore = defineStore('dedicatedConfigurator'
|
||||
'Dedicated 14th Gen — Operating System': 'os',
|
||||
'Dedicated 14th Gen — Bandwidth': 'bw',
|
||||
'Dedicated 14th Gen — IPv4 Block': 'ipv4',
|
||||
'Dedicated 14th Gen — Private Networking': 'privnet',
|
||||
'Dedicated 14th Gen — PCIe NVMe Add-in': 'pcie',
|
||||
}
|
||||
return map[groupName] ?? null
|
||||
}
|
||||
|
||||
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[]> = {
|
||||
cpu: ['Dedicated 14th Gen — CPU Upgrade', 'Dedicated 14th Gen — CPU Upgrade (R740xd)'],
|
||||
ram: ['Dedicated 14th Gen — RAM Upgrade'],
|
||||
os: ['Dedicated 14th Gen — Operating System'],
|
||||
bw: ['Dedicated 14th Gen — Bandwidth'],
|
||||
ipv4: ['Dedicated 14th Gen — IPv4 Block'],
|
||||
privnet: ['Dedicated 14th Gen — Private Networking'],
|
||||
pcie: ['Dedicated 14th Gen — PCIe NVMe Add-in'],
|
||||
}
|
||||
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 {
|
||||
const p = new URLSearchParams(search)
|
||||
|
||||
@@ -213,7 +291,7 @@ export const useDedicatedConfiguratorStore = defineStore('dedicatedConfigurator'
|
||||
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)
|
||||
if (!v) continue
|
||||
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 {
|
||||
@@ -235,6 +346,7 @@ export const useDedicatedConfiguratorStore = defineStore('dedicatedConfigurator'
|
||||
cycle,
|
||||
baselinePrice,
|
||||
addOnsTotal,
|
||||
driveBayCost,
|
||||
setupFee,
|
||||
cycleSubtotal,
|
||||
cycleTotal,
|
||||
@@ -244,6 +356,8 @@ export const useDedicatedConfiguratorStore = defineStore('dedicatedConfigurator'
|
||||
checkoutUrl,
|
||||
init,
|
||||
setSelection,
|
||||
setDrive,
|
||||
setDriveQuantity,
|
||||
hydrateFromUrl,
|
||||
findGroup,
|
||||
findValue,
|
||||
|
||||
@@ -114,10 +114,10 @@ Route::get('/dedicated-servers/{slug}', function (string $slug) {
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
|
||||
// Drive-bay combos are seeded with value slugs that start with the
|
||||
// bay count (e.g. "8x8tb-hdd" → 8 bays). Filter out combos that
|
||||
// don't fit this chassis so customers never see options that can't
|
||||
// physically deploy on their build.
|
||||
// Drive bay groups now use a Drive Selection (radio, per-drive cost) +
|
||||
// Drive Quantity (stepper) composite. Clamp the stepper's max_qty down to
|
||||
// the chassis's physical bay_count so customers can't pick more drives
|
||||
// than the chassis can hold.
|
||||
$bayCount = (int) ($plan->features['bay_count'] ?? 0);
|
||||
if ($bayCount > 0) {
|
||||
foreach ($configGroups as $group) {
|
||||
@@ -125,15 +125,44 @@ Route::get('/dedicated-servers/{slug}', function (string $slug) {
|
||||
continue;
|
||||
}
|
||||
foreach ($group->options as $option) {
|
||||
$filtered = $option->values->filter(function ($value) use ($bayCount): bool {
|
||||
if (preg_match('/^(\d+)x/', $value->value, $m)) {
|
||||
return (int) $m[1] <= $bayCount;
|
||||
}
|
||||
if ($option->name === 'Drive Quantity' && (int) $option->max_qty > $bayCount) {
|
||||
$option->max_qty = $bayCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 'none' (and any other non-quantity value) always passes.
|
||||
return true;
|
||||
// CPU Platinum filter — chassis without features.cpu_premium hide the
|
||||
// Platinum 8280 option (R440/R540 lack support per Dell/SaveMyServer).
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
// R440 has 4 LFF bays — combos requiring more bays should be hidden.
|
||||
$r440Response = $this->get('/dedicated-servers/r440-4lff');
|
||||
$r440Groups = collect($r440Response->viewData('page')['props']['configGroups']);
|
||||
$lff = $r440Groups->firstWhere('name', 'Dedicated 14th Gen — LFF 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
|
||||
test('drive bay groups expose two options: Drive Selection radio + Drive Quantity stepper', function (): void {
|
||||
foreach (['r440-4lff', 'r640-8sff', 'r740xd-24nvme'] as $slug) {
|
||||
$response = $this->get("/dedicated-servers/{$slug}");
|
||||
$groups = collect($response->viewData('page')['props']['configGroups']);
|
||||
$bayGroup = $groups->first(fn ($g) => str_contains($g['name'], 'Drive Bays'));
|
||||
|
||||
// R740xd LFF has 12 bays — should see all combos.
|
||||
$r740xdResponse = $this->get('/dedicated-servers/r740xd-12lff');
|
||||
$r740xdGroups = collect($r740xdResponse->viewData('page')['props']['configGroups']);
|
||||
$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');
|
||||
expect($bayGroup)->not->toBeNull("Plan {$slug} missing Drive Bays group");
|
||||
expect(collect($bayGroup['options'])->pluck('name')->all())
|
||||
->toBe(['Drive Selection', 'Drive Quantity']);
|
||||
|
||||
// R640 NVMe has 10 bays — 16× shouldn't fit but 8× should.
|
||||
$r640Response = $this->get('/dedicated-servers/r640-10nvme');
|
||||
$r640Groups = collect($r640Response->viewData('page')['props']['configGroups']);
|
||||
$nvme = $r640Groups->firstWhere('name', 'Dedicated 14th Gen — NVMe Drive Bays');
|
||||
$valuesNvme = collect($nvme['options'][0]['values'])->pluck('value')->all();
|
||||
expect($valuesNvme)->toContain('8x2tb-nvme');
|
||||
expect($valuesNvme)->not->toContain('16x2tb-nvme'); // 16 bays don't fit in 10
|
||||
$driveOpt = collect($bayGroup['options'])->firstWhere('name', 'Drive Selection');
|
||||
$qtyOpt = collect($bayGroup['options'])->firstWhere('name', 'Drive Quantity');
|
||||
|
||||
expect($driveOpt['type'])->toBe('radio');
|
||||
expect($qtyOpt['type'])->toBe('quantity');
|
||||
expect((int) $qtyOpt['min_qty'])->toBe(0);
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user