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>
|
<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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user