From f0df110b471367220d1d3faf94639ea040c82952c216e559a9a19f1c993f9af9 Mon Sep 17 00:00:00 2001 From: Andrew Date: Sun, 26 Apr 2026 21:05:37 -0400 Subject: [PATCH] feat(dedicated): 3-column anchor rail layout for configurator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switches the dedicated server detail page from 2-column (configurator + sticky summary) to 3-column (anchor rail | configurator | sticky summary) — option D from the visual companion exploration. Power-user shape: every section visible at once, rail tracks completion state, sticky-on-both-sides keeps navigation + price always in reach. - New ConfigSectionRail.vue: vertical list of section anchors (CPU, RAM, OS, Storage, Network, etc.) with three states (untouched, touched ✓, active ●). Click to smooth-scroll. - Configurator wraps each group in
with scroll-margin-top: 92px to clear the navbar on scroll-into-view. - IntersectionObserver in DedicatedConfigurator/index.vue updates store.activeAnchorId as sections cross the upper viewport. rootMargin '-92px 0px -65% 0px' picks the section nearest the top. - Store: activeAnchorId reactive ref, isGroupTouched() helper (compares selection against seeded default; drive bay groups also require quantity > 0 to count), groupAnchorId() and shortGroupLabel() helpers. - Detail-grid CSS: 180px | 1fr | 380px on desktop. Rail hides at ≤1280px (tablet keeps the summary). Full stack at ≤1024px. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ConfigSectionRail.vue | 150 ++++++++++++++++++ .../Dedicated/DedicatedConfigurator/index.vue | 73 ++++++++- .../Pages/Marketing/DedicatedServerDetail.vue | 31 +++- .../ts/stores/dedicatedConfigurator.ts | 36 +++++ 4 files changed, 281 insertions(+), 9 deletions(-) create mode 100644 website/resources/ts/Components/Marketing/Dedicated/DedicatedConfigurator/ConfigSectionRail.vue diff --git a/website/resources/ts/Components/Marketing/Dedicated/DedicatedConfigurator/ConfigSectionRail.vue b/website/resources/ts/Components/Marketing/Dedicated/DedicatedConfigurator/ConfigSectionRail.vue new file mode 100644 index 0000000..3f506f0 --- /dev/null +++ b/website/resources/ts/Components/Marketing/Dedicated/DedicatedConfigurator/ConfigSectionRail.vue @@ -0,0 +1,150 @@ + + + + + diff --git a/website/resources/ts/Components/Marketing/Dedicated/DedicatedConfigurator/index.vue b/website/resources/ts/Components/Marketing/Dedicated/DedicatedConfigurator/index.vue index 60d1726..c673b83 100644 --- a/website/resources/ts/Components/Marketing/Dedicated/DedicatedConfigurator/index.vue +++ b/website/resources/ts/Components/Marketing/Dedicated/DedicatedConfigurator/index.vue @@ -1,6 +1,7 @@ @@ -147,6 +207,13 @@ watch(() => props.plan?.id, () => { backdrop-filter: blur(12px); } +// Each section has its own anchor target. scroll-margin-top is used by the +// browser-native "scrollIntoView({behavior: smooth})" fallback to keep the +// section title clear of the sticky navbar (matches our 92px goTo offset). +.dedicated-configurator__section { + scroll-margin-top: 92px; +} + .dedicated-configurator__footer-wrap { margin-top: 8px; } diff --git a/website/resources/ts/Pages/Marketing/DedicatedServerDetail.vue b/website/resources/ts/Pages/Marketing/DedicatedServerDetail.vue index d2535b9..421b09e 100644 --- a/website/resources/ts/Pages/Marketing/DedicatedServerDetail.vue +++ b/website/resources/ts/Pages/Marketing/DedicatedServerDetail.vue @@ -4,6 +4,7 @@ import { computed } from 'vue' import MarketingLayout from '@/Layouts/MarketingLayout.vue' import DedicatedConfigurator from '@/Components/Marketing/Dedicated/DedicatedConfigurator/index.vue' import BuildSummary from '@/Components/Marketing/Dedicated/DedicatedConfigurator/BuildSummary.vue' +import ConfigSectionRail from '@/Components/Marketing/Dedicated/DedicatedConfigurator/ConfigSectionRail.vue' import { useDedicatedConfiguratorStore } from '@/stores/dedicatedConfigurator' import { crossDomainUrl } from '@/utils/resolvers' import type { DedicatedPlan, DedicatedConfigGroup } from '@/stores/dedicatedConfigurator' @@ -117,6 +118,9 @@ const store = useDedicatedConfiguratorStore()
+
+ +
>({}) const cycle = ref('monthly') + // Tracks which configurator section is currently in view; updated by an + // IntersectionObserver in the configurator and read by ConfigSectionRail + // to highlight the active anchor. + const activeAnchorId = ref('') + function init(catalog: { plan: DedicatedPlan configGroups: DedicatedConfigGroup[] @@ -197,6 +214,23 @@ export const useDedicatedConfiguratorStore = defineStore('dedicatedConfigurator' const monthlyEffective = computed(() => cycleSubtotal.value / CYCLE_MONTHS[cycle.value]) + // A section is "touched" when the customer has moved off the seeded + // default. Drive bay groups also need a non-zero quantity to count. + function isGroupTouched(groupName: string): boolean { + const sel = selections.value[groupName] + if (sel === undefined) return false + const group = findGroup(groupName) + if (!group) return false + + if (isDriveBaySelection(sel)) { + return sel.drive !== 'none' && sel.quantity > 0 + } + + const opt = group.options[0] + const def = opt?.values.find(v => v.is_default)?.value ?? opt?.values[0]?.value + return sel !== def + } + const isSetupFeeWaived = computed(() => { if (!plan.value) return true const fee = parseFloat(String(plan.value.setup_fee ?? 0)) @@ -348,6 +382,7 @@ export const useDedicatedConfiguratorStore = defineStore('dedicatedConfigurator' accountUrl, selections, cycle, + activeAnchorId, baselinePrice, addOnsTotal, driveBayCost, @@ -366,5 +401,6 @@ export const useDedicatedConfiguratorStore = defineStore('dedicatedConfigurator' findGroup, findValue, pickCyclePrice, + isGroupTouched, } })