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:
@@ -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>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { watch } from 'vue'
|
||||
import { onBeforeUnmount, onMounted, watch } from 'vue'
|
||||
import {
|
||||
groupAnchorId,
|
||||
isDriveBayGroup,
|
||||
isOperatingSystemGroup,
|
||||
useDedicatedConfiguratorStore,
|
||||
@@ -84,6 +85,60 @@ watch(() => props.plan?.id, () => {
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -94,7 +149,12 @@ watch(() => props.plan?.id, () => {
|
||||
</div>
|
||||
|
||||
<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
|
||||
v-if="isDriveBayGroup(group.name)"
|
||||
:group="group"
|
||||
@@ -118,7 +178,7 @@ watch(() => props.plan?.id, () => {
|
||||
:cycle="store.cycle"
|
||||
@update:selected="(v: string) => onSelectionChange(group.name, v)"
|
||||
/>
|
||||
</template>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
</div>
|
||||
|
||||
<div class="detail-grid">
|
||||
<div class="detail-grid__rail">
|
||||
<ConfigSectionRail :groups="configGroups" />
|
||||
</div>
|
||||
<div class="detail-grid__configurator">
|
||||
<DedicatedConfigurator
|
||||
:plan="plan"
|
||||
@@ -197,12 +201,18 @@ const store = useDedicatedConfiguratorStore()
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 380px;
|
||||
gap: 32px;
|
||||
// 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.
|
||||
// 3-column on desktop: anchor rail | configurator | sticky build summary.
|
||||
// The default `stretch` row alignment lets the rail and summary share the
|
||||
// configurator's full height so both can sticky-follow scroll.
|
||||
grid-template-columns: 180px minmax(0, 1fr) 380px;
|
||||
gap: 28px;
|
||||
|
||||
@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) {
|
||||
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 {
|
||||
position: sticky;
|
||||
// 64px navbar (Vuetify VAppBar default) + 16px breathing room.
|
||||
|
||||
@@ -67,6 +67,18 @@ export function isOperatingSystemGroup(name: string): boolean {
|
||||
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 {
|
||||
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 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: {
|
||||
plan: DedicatedPlan
|
||||
configGroups: DedicatedConfigGroup[]
|
||||
@@ -197,6 +214,23 @@ export const useDedicatedConfiguratorStore = defineStore('dedicatedConfigurator'
|
||||
|
||||
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>(() => {
|
||||
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,
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user