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);
|
$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);
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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>
|
<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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user