feat(dedicated): drive bay configurator + sticky-summary fix + reword setup fee

Three changes bundled:

1. Drive bay configurator (3 new ConfigOptionSeeder groups):
   - LFF Drive Bays (3.5") — 7 starter combos from "None" to
     12× 8 TB SATA HDD; attached to R440 / R540 / R740xd LFF
   - SFF Drive Bays (2.5") — 8 starter combos from "None" to
     24× 1.92 TB SATA SSD; attached to R640 / R740 / R740xd SFF
   - NVMe Drive Bays (U.2) — 7 starter combos from "None" to
     16× 2 TB U.2 NVMe; attached to R640 NVMe / R740xd NVMe
   Combos enforce chassis bay-count constraints via labels
   ("R740xd LFF only"); customers wanting heterogeneous setups
   use the post-order ticket flow.

2. Sticky build-summary fix: previously the BuildSummary card
   slid under the Vuetify navbar at scroll. Moved sticky from
   the inner card to the .detail-grid__summary wrapper, removed
   align-items: start so the right grid cell stretches to the
   configurator column's height (giving sticky a tall enough
   container), and offset top by 80px (64px navbar + 16px
   breathing room). Mobile path drops sticky entirely.

3. Setup fee reword — "Hardware acquisition" was leaking our
   cost structure and making the fee feel like procurement
   passthrough. Now reads "Server provisioning & deployment"
   in BuildSummary, and the FAQ describes what the fee covers
   (build, racking, iDRAC config, deployment) without exposing
   margins. Same shift across the non-refundable note: "once
   your build starts" instead of "once hardware is purchased."

Detail page bay-strategy callout updated: drive selection IS now
self-serve, so the callout pivots to "need a custom drive layout?"
pointing customers with mixed-size / hot-spare / RAID-preference
needs to the contact form.

Tests: updated count assertion to 9 groups, added a new test
verifying drive bay groups attach to chassis by bay type.
22/22 of my session's Pest tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-26 18:25:33 -04:00
parent be3eaba2a1
commit 61afa4ed14
5 changed files with 129 additions and 24 deletions

View File

@@ -498,6 +498,88 @@ class ConfigOptionSeeder extends Seeder
]);
$gen14Ipv4->plans()->syncWithoutDetaching($gen14AllPlans);
// ─── Dedicated 14th Gen — LFF Drive Bays ────────────────────────
$gen14Lff = PlanConfigGroup::updateOrCreate(
['name' => 'Dedicated 14th Gen — LFF Drive Bays'],
[
'description' => 'Pick a starter drive configuration for the 3.5" LFF bays. Customers needing a heterogeneous setup (mixed sizes per bay, hot spares, etc.) can request a custom layout via the post-order ticket flow.',
'mode' => 'preset',
'service_type' => 'dedicated',
'is_active' => true,
'sort_order' => 50,
],
);
$gen14LffOption = $this->seedRadioOption($gen14Lff, 'LFF Drive Bays', false, 1);
$this->seedValues($gen14LffOption, [
['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 (R540 / R740xd LFF only)', 'value' => '8x8tb-hdd', 'monthly' => 160.00],
['label' => '12× 8 TB SATA HDD (R740xd LFF only)', 'value' => '12x8tb-hdd', 'monthly' => 240.00],
]);
$gen14LffPlanSlugs = ['r440-4lff', 'r540-8lff', 'r740xd-12lff'];
$gen14LffPlans = Plan::query()->whereIn('slug', $gen14LffPlanSlugs)->pluck('id');
$gen14Lff->plans()->syncWithoutDetaching($gen14LffPlans);
// ─── Dedicated 14th Gen — SFF Drive Bays ────────────────────────
$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.',
'mode' => 'preset',
'service_type' => 'dedicated',
'is_active' => true,
'sort_order' => 51,
],
);
$gen14SffOption = $this->seedRadioOption($gen14Sff, 'SFF Drive Bays', false, 1);
$this->seedValues($gen14SffOption, [
['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 (R740 16-bay / R740xd 24-SFF only)', 'value' => '16x1920gb-ssd', 'monthly' => 288.00],
['label' => '24× 1.92 TB SATA SSD (R740xd 24-SFF only)', 'value' => '24x1920gb-ssd', 'monthly' => 432.00],
]);
$gen14SffPlanSlugs = ['r640-8sff', 'r740-16sff', 'r740xd-24sff'];
$gen14SffPlans = Plan::query()->whereIn('slug', $gen14SffPlanSlugs)->pluck('id');
$gen14Sff->plans()->syncWithoutDetaching($gen14SffPlans);
// ─── Dedicated 14th Gen — NVMe Drive Bays ───────────────────────
$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.',
'mode' => 'preset',
'service_type' => 'dedicated',
'is_active' => true,
'sort_order' => 52,
],
);
$gen14NvmeOption = $this->seedRadioOption($gen14Nvme, 'NVMe Drive Bays', false, 1);
$this->seedValues($gen14NvmeOption, [
['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 (R640 NVMe 10-bay / R740xd NVMe 24-bay)', 'value' => '8x2tb-nvme', 'monthly' => 384.00],
['label' => '16× 2 TB U.2 NVMe (R740xd NVMe 24-bay only)', 'value' => '16x2tb-nvme', 'monthly' => 768.00],
]);
$gen14NvmePlanSlugs = ['r640-10nvme', 'r740xd-24nvme'];
$gen14NvmePlans = Plan::query()->whereIn('slug', $gen14NvmePlanSlugs)->pluck('id');
$gen14Nvme->plans()->syncWithoutDetaching($gen14NvmePlans);
}
/**

View File

@@ -175,7 +175,7 @@ function formatPrice(amount: number): string {
<div class="build-summary__line-text">
<div class="build-summary__line-label">Setup fee</div>
<div class="build-summary__line-detail">
Hardware acquisition · charged on first invoice
Server provisioning &amp; deployment · charged on first invoice
</div>
</div>
<div class="build-summary__line-amount">
@@ -188,7 +188,7 @@ function formatPrice(amount: number): string {
</div>
<div v-if="!isSetupFeeWaived" class="build-summary__setup-note">
<VIcon icon="tabler-info-circle" size="12" class="me-1" />
Non-refundable once hardware is purchased. Switch to Semi-Annual or Annual to waive.
Non-refundable once your build starts. Switch to Semi-Annual or Annual to waive.
</div>
</div>
@@ -243,16 +243,9 @@ function formatPrice(amount: number): string {
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.25);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
position: sticky;
top: 24px;
max-height: calc(100vh - 48px);
overflow-y: auto;
@media (max-width: 960px) {
position: relative;
top: auto;
max-height: none;
}
// Stickiness is owned by the parent grid cell (.detail-grid__summary) on
// the page — keeping it here would create a doubly-sticky effect that
// doesn't behave correctly. The card is just a plain card.
}
.build-summary__head {

View File

@@ -145,18 +145,15 @@ const store = useDedicatedConfiguratorStore()
</VContainer>
</div>
<!-- Bay strategy reminder -->
<!-- Need a custom drive layout? -->
<VContainer class="marketing-section">
<VCard class="pa-6 detail-bay-card">
<div class="d-flex align-start ga-4 flex-wrap">
<VIcon icon="tabler-info-circle" size="28" color="primary" class="flex-shrink-0" />
<div class="flex-grow-1">
<h3 class="text-h6 font-weight-bold mb-2">All main bays ship empty</h3>
<p class="text-body-2 text-medium-emphasis mb-2">
<strong>Drive selection</strong> isn't in the v1 self-serve configurator. Once you order, we'll reach out to confirm your drive layout SATA / SAS / NVMe options, RAID/ZFS preference, and per-bay placement. Drives arrive separately and slot into the pre-installed trays during assembly.
</p>
<h3 class="text-h6 font-weight-bold mb-2">Need a custom drive layout?</h3>
<p class="text-body-2 text-medium-emphasis mb-0">
Need specific drives sourced before you order? <a href="/contact" class="text-primary text-decoration-none">Open a ticket</a> with your spec we'll quote the drive cost and add it to your first invoice.
The drive bay configurator covers the most common configurations. If you need a heterogeneous setup (mixed sizes per bay, hot spares, RAID layout preference, specific drive vendor) <a href="/contact" class="text-primary text-decoration-none">open a ticket</a> with your spec and we'll build to it.
</p>
</div>
</div>
@@ -216,7 +213,10 @@ const store = useDedicatedConfiguratorStore()
display: grid;
grid-template-columns: minmax(0, 1fr) 380px;
gap: 32px;
align-items: start;
// Default `stretch` behaviour: the right column grows to the row's full
// height (matching the configurator column). Combined with sticky on
// .detail-grid__summary that gives us a sidebar that stays in view all
// the way down through the configurator scroll.
@media (max-width: 1024px) {
grid-template-columns: 1fr;
@@ -225,10 +225,19 @@ const store = useDedicatedConfiguratorStore()
}
.detail-grid__summary {
position: relative;
position: sticky;
// 64px navbar (Vuetify VAppBar default) + 16px breathing room.
top: 80px;
align-self: start; // stop the cell from stretching the inner card vertically
max-height: calc(100vh - 96px);
overflow-y: auto;
@media (max-width: 1024px) {
order: -1; // on mobile, show summary above configurator so total is visible first
position: relative;
top: auto;
max-height: none;
overflow-y: visible;
order: -1; // mobile: summary above configurator so total is visible first
}
}

View File

@@ -102,7 +102,7 @@ const faqs = [
},
{
question: 'How do setup fees work?',
answer: 'Setup fees range from $349 to $799 depending on chassis class (covers our hardware-acquisition cost). They\'re <strong>charged on monthly and quarterly cycles, waived on Semi-Annual and Annual commitments</strong>. Once we\'ve placed the hardware order with our supplier (typically within 24 hours of your order), the setup fee is non-refundable. Rental fees still fall under the 14-day money-back guarantee.',
answer: 'Setup fees range from $349 to $799 depending on chassis class. They cover the work of building, racking, configuring iDRAC, and deploying your server. They\'re <strong>charged on monthly and quarterly cycles, waived on Semi-Annual and Annual commitments</strong>. Once your build starts (typically within 24 hours of your order), the setup fee is non-refundable. Rental fees still fall under the 14-day money-back guarantee.',
},
{
question: 'What operating systems are supported?',

View File

@@ -115,19 +115,40 @@ test('non-R740xd chassis get the standard CPU group', function (): void {
expect($names)->not->toContain('Dedicated 14th Gen — CPU Upgrade (R740xd)');
});
test('seeder creates the 6 dedicated 14th-gen config groups', function (): void {
test('seeder creates the 9 dedicated 14th-gen config groups', function (): void {
$names = PlanConfigGroup::query()
->where('name', 'like', 'Dedicated 14th Gen%')
->pluck('name')
->all();
expect(count($names))->toBe(6);
expect(count($names))->toBe(9);
expect($names)->toContain('Dedicated 14th Gen — CPU Upgrade');
expect($names)->toContain('Dedicated 14th Gen — CPU Upgrade (R740xd)');
expect($names)->toContain('Dedicated 14th Gen — RAM Upgrade');
expect($names)->toContain('Dedicated 14th Gen — Operating System');
expect($names)->toContain('Dedicated 14th Gen — Bandwidth');
expect($names)->toContain('Dedicated 14th Gen — IPv4 Block');
expect($names)->toContain('Dedicated 14th Gen — LFF Drive Bays');
expect($names)->toContain('Dedicated 14th Gen — SFF Drive Bays');
expect($names)->toContain('Dedicated 14th Gen — NVMe Drive Bays');
});
test('drive bay groups are attached to chassis by bay type', function (): void {
// LFF chassis should have LFF Drive Bays attached
$r440 = Plan::where('slug', 'r440-4lff')->first();
expect($r440->configGroups->pluck('name'))->toContain('Dedicated 14th Gen — LFF Drive Bays');
expect($r440->configGroups->pluck('name'))->not->toContain('Dedicated 14th Gen — SFF Drive Bays');
expect($r440->configGroups->pluck('name'))->not->toContain('Dedicated 14th Gen — NVMe Drive Bays');
// SFF chassis should have SFF Drive Bays
$r640 = Plan::where('slug', 'r640-8sff')->first();
expect($r640->configGroups->pluck('name'))->toContain('Dedicated 14th Gen — SFF Drive Bays');
expect($r640->configGroups->pluck('name'))->not->toContain('Dedicated 14th Gen — LFF Drive Bays');
// NVMe chassis should have NVMe Drive Bays
$nvme = Plan::where('slug', 'r740xd-24nvme')->first();
expect($nvme->configGroups->pluck('name'))->toContain('Dedicated 14th Gen — NVMe Drive Bays');
expect($nvme->configGroups->pluck('name'))->not->toContain('Dedicated 14th Gen — SFF Drive Bays');
});
test('RAM upgrade group standardizes on DDR4-2400 across all tiers', function (): void {