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:
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 & 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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user