feat(dedicated): 3-column anchor rail layout for configurator

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 <section :id="cfg-<slug>"> 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) <noreply@anthropic.com>
This commit is contained in:
2026-04-26 21:05:37 -04:00
parent a224051bde
commit f0df110b47
4 changed files with 281 additions and 9 deletions

View File

@@ -0,0 +1,150 @@
<script lang="ts" setup>
import { computed } from 'vue'
import {
groupAnchorId,
shortGroupLabel,
useDedicatedConfiguratorStore,
type DedicatedConfigGroup,
} from '@/stores/dedicatedConfigurator'
interface Props {
groups: DedicatedConfigGroup[]
}
const props = defineProps<Props>()
const store = useDedicatedConfiguratorStore()
interface RailItem {
anchorId: string
label: string
isTouched: boolean
}
const items = computed<RailItem[]>(() =>
props.groups.map(g => ({
anchorId: groupAnchorId(g.name),
label: shortGroupLabel(g.name),
isTouched: store.isGroupTouched(g.name),
})),
)
function goTo(anchorId: string): void {
if (typeof document === 'undefined') return
const el = document.getElementById(anchorId)
if (!el) return
// 80px navbar + 12px breathing room.
const top = el.getBoundingClientRect().top + window.scrollY - 92
window.scrollTo({ top, behavior: 'smooth' })
}
</script>
<template>
<aside class="config-rail" aria-label="Configurator sections">
<h5 class="config-rail__heading">Sections</h5>
<button
v-for="item in items"
:key="item.anchorId"
type="button"
class="config-rail__item"
:class="{
'config-rail__item--active': store.activeAnchorId === item.anchorId,
'config-rail__item--done': item.isTouched && store.activeAnchorId !== item.anchorId,
}"
@click="goTo(item.anchorId)"
>
<span class="config-rail__icon">
<VIcon
v-if="store.activeAnchorId === item.anchorId"
icon="tabler-point-filled"
size="14"
color="primary"
/>
<VIcon
v-else-if="item.isTouched"
icon="tabler-circle-check-filled"
size="14"
color="primary"
/>
<VIcon
v-else
icon="tabler-circle"
size="14"
class="config-rail__icon--empty"
/>
</span>
<span class="config-rail__label">{{ item.label }}</span>
</button>
</aside>
</template>
<style lang="scss" scoped>
.config-rail {
display: flex;
flex-direction: column;
gap: 2px;
}
.config-rail__heading {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.1em;
color: rgba(var(--v-theme-on-surface), 0.5);
font-weight: 700;
margin: 0 10px 10px;
}
.config-rail__item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 8px;
border: none;
background: transparent;
border-left: 2px solid transparent;
font-size: 13px;
font-weight: 500;
color: rgba(var(--v-theme-on-surface), 0.7);
cursor: pointer;
text-align: left;
width: 100%;
transition: background-color 0.15s, color 0.15s, border-color 0.15s;
&:hover {
color: rgba(var(--v-theme-on-surface), 0.95);
background: rgba(var(--v-theme-on-surface), 0.04);
}
}
.config-rail__item--done {
color: rgba(var(--v-theme-on-surface), 0.95);
font-weight: 600;
}
.config-rail__item--active {
color: rgb(var(--v-theme-primary));
background: rgba(var(--v-theme-primary), 0.08);
border-left-color: rgb(var(--v-theme-primary));
font-weight: 700;
}
.config-rail__icon {
width: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.config-rail__icon--empty {
color: rgba(var(--v-theme-on-surface), 0.3);
}
.config-rail__label {
flex: 1 1 auto;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View File

@@ -1,6 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { watch } from 'vue' import { onBeforeUnmount, onMounted, watch } from 'vue'
import { import {
groupAnchorId,
isDriveBayGroup, isDriveBayGroup,
isOperatingSystemGroup, isOperatingSystemGroup,
useDedicatedConfiguratorStore, useDedicatedConfiguratorStore,
@@ -84,6 +85,60 @@ watch(() => props.plan?.id, () => {
accountUrl: props.accountUrl, accountUrl: props.accountUrl,
}) })
}) })
// Active-section tracking for the anchor rail. We watch all configurator
// section anchors and set store.activeAnchorId to whichever section is
// crossing the trigger zone (top 25% of viewport) — this keeps the rail
// in sync with scroll without firing every pixel.
let observer: IntersectionObserver | null = null
function refreshObserver(): void {
if (typeof window === 'undefined') return
observer?.disconnect()
observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter(e => e.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)
if (visible[0]) {
store.activeAnchorId = visible[0].target.id
}
},
{
// Section is "active" when its top crosses into the upper portion of
// viewport — between just below the navbar and the top quarter.
rootMargin: '-92px 0px -65% 0px',
threshold: 0,
},
)
for (const group of props.configGroups) {
const el = document.getElementById(groupAnchorId(group.name))
if (el) observer.observe(el)
}
// Default the active anchor to the first section so the rail isn't blank
// before any scroll has happened.
if (props.configGroups[0]) {
store.activeAnchorId = groupAnchorId(props.configGroups[0].name)
}
}
onMounted(() => {
// Wait one tick so the section <section :id="..."> elements are in DOM.
requestAnimationFrame(refreshObserver)
})
onBeforeUnmount(() => {
observer?.disconnect()
observer = null
})
watch(() => props.configGroups, () => {
// Plan switch / route change re-renders sections — re-observe.
requestAnimationFrame(refreshObserver)
})
</script> </script>
<template> <template>
@@ -94,7 +149,12 @@ watch(() => props.plan?.id, () => {
</div> </div>
<div class="dedicated-configurator__groups"> <div class="dedicated-configurator__groups">
<template v-for="group in configGroups" :key="group.id"> <section
v-for="group in configGroups"
:key="group.id"
:id="groupAnchorId(group.name)"
class="dedicated-configurator__section"
>
<DriveBayGroupSelector <DriveBayGroupSelector
v-if="isDriveBayGroup(group.name)" v-if="isDriveBayGroup(group.name)"
:group="group" :group="group"
@@ -118,7 +178,7 @@ watch(() => props.plan?.id, () => {
:cycle="store.cycle" :cycle="store.cycle"
@update:selected="(v: string) => onSelectionChange(group.name, v)" @update:selected="(v: string) => onSelectionChange(group.name, v)"
/> />
</template> </section>
</div> </div>
</div> </div>
</template> </template>
@@ -147,6 +207,13 @@ watch(() => props.plan?.id, () => {
backdrop-filter: blur(12px); 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 { .dedicated-configurator__footer-wrap {
margin-top: 8px; margin-top: 8px;
} }

View File

@@ -4,6 +4,7 @@ import { computed } from 'vue'
import MarketingLayout from '@/Layouts/MarketingLayout.vue' import MarketingLayout from '@/Layouts/MarketingLayout.vue'
import DedicatedConfigurator from '@/Components/Marketing/Dedicated/DedicatedConfigurator/index.vue' import DedicatedConfigurator from '@/Components/Marketing/Dedicated/DedicatedConfigurator/index.vue'
import BuildSummary from '@/Components/Marketing/Dedicated/DedicatedConfigurator/BuildSummary.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 { useDedicatedConfiguratorStore } from '@/stores/dedicatedConfigurator'
import { crossDomainUrl } from '@/utils/resolvers' import { crossDomainUrl } from '@/utils/resolvers'
import type { DedicatedPlan, DedicatedConfigGroup } from '@/stores/dedicatedConfigurator' import type { DedicatedPlan, DedicatedConfigGroup } from '@/stores/dedicatedConfigurator'
@@ -117,6 +118,9 @@ const store = useDedicatedConfiguratorStore()
</div> </div>
<div class="detail-grid"> <div class="detail-grid">
<div class="detail-grid__rail">
<ConfigSectionRail :groups="configGroups" />
</div>
<div class="detail-grid__configurator"> <div class="detail-grid__configurator">
<DedicatedConfigurator <DedicatedConfigurator
:plan="plan" :plan="plan"
@@ -197,12 +201,18 @@ const store = useDedicatedConfiguratorStore()
.detail-grid { .detail-grid {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) 380px; // 3-column on desktop: anchor rail | configurator | sticky build summary.
gap: 32px; // The default `stretch` row alignment lets the rail and summary share the
// Default `stretch` behaviour: the right column grows to the row's full // configurator's full height so both can sticky-follow scroll.
// height (matching the configurator column). Combined with sticky on grid-template-columns: 180px minmax(0, 1fr) 380px;
// .detail-grid__summary that gives us a sidebar that stays in view all gap: 28px;
// the way down through the configurator scroll.
@media (max-width: 1280px) {
// Tablet-ish: rail collapses, summary stays.
grid-template-columns: minmax(0, 1fr) 360px;
.detail-grid__rail { display: none; }
}
@media (max-width: 1024px) { @media (max-width: 1024px) {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -210,6 +220,15 @@ const store = useDedicatedConfiguratorStore()
} }
} }
.detail-grid__rail {
position: sticky;
top: 92px;
align-self: start;
max-height: calc(100vh - 108px);
overflow-y: auto;
padding: 4px 0;
}
.detail-grid__summary { .detail-grid__summary {
position: sticky; position: sticky;
// 64px navbar (Vuetify VAppBar default) + 16px breathing room. // 64px navbar (Vuetify VAppBar default) + 16px breathing room.

View File

@@ -67,6 +67,18 @@ export function isOperatingSystemGroup(name: string): boolean {
return name.includes('Operating System') return name.includes('Operating System')
} }
export function groupAnchorId(name: string): string {
// "Dedicated 14th Gen — LFF Drive Bays" → "cfg-lff-drive-bays"
const trimmed = name.replace(/^Dedicated 14th Gen — /, '').toLowerCase()
const slug = trimmed.replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
return `cfg-${slug}`
}
export function shortGroupLabel(name: string): string {
// Strip the leading "Dedicated 14th Gen — " for compact rail/anchor display.
return name.replace(/^Dedicated 14th Gen — /, '')
}
function isDriveBaySelection(sel: DedicatedSelection | undefined): sel is DriveBaySelection { function isDriveBaySelection(sel: DedicatedSelection | undefined): sel is DriveBaySelection {
return typeof sel === 'object' && sel !== null && 'drive' in sel return typeof sel === 'object' && sel !== null && 'drive' in sel
} }
@@ -82,6 +94,11 @@ export const useDedicatedConfiguratorStore = defineStore('dedicatedConfigurator'
const selections = ref<Record<string, DedicatedSelection>>({}) const selections = ref<Record<string, DedicatedSelection>>({})
const cycle = ref<DedicatedCycle>('monthly') const cycle = ref<DedicatedCycle>('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<string>('')
function init(catalog: { function init(catalog: {
plan: DedicatedPlan plan: DedicatedPlan
configGroups: DedicatedConfigGroup[] configGroups: DedicatedConfigGroup[]
@@ -197,6 +214,23 @@ export const useDedicatedConfiguratorStore = defineStore('dedicatedConfigurator'
const monthlyEffective = computed<number>(() => cycleSubtotal.value / CYCLE_MONTHS[cycle.value]) const monthlyEffective = computed<number>(() => 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<boolean>(() => { const isSetupFeeWaived = computed<boolean>(() => {
if (!plan.value) return true if (!plan.value) return true
const fee = parseFloat(String(plan.value.setup_fee ?? 0)) const fee = parseFloat(String(plan.value.setup_fee ?? 0))
@@ -348,6 +382,7 @@ export const useDedicatedConfiguratorStore = defineStore('dedicatedConfigurator'
accountUrl, accountUrl,
selections, selections,
cycle, cycle,
activeAnchorId,
baselinePrice, baselinePrice,
addOnsTotal, addOnsTotal,
driveBayCost, driveBayCost,
@@ -366,5 +401,6 @@ export const useDedicatedConfiguratorStore = defineStore('dedicatedConfigurator'
findGroup, findGroup,
findValue, findValue,
pickCyclePrice, pickCyclePrice,
isGroupTouched,
} }
}) })