Files
website/website/resources/ts/Pages/Marketing/DedicatedServerDetail.vue
Andrew f0df110b47 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>
2026-04-26 21:05:37 -04:00

250 lines
9.0 KiB
Vue

<script lang="ts" setup>
import { Head, usePage, Link } from '@inertiajs/vue3'
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'
defineOptions({ layout: MarketingLayout })
interface PageProps {
plan: DedicatedPlan
configGroups: DedicatedConfigGroup[]
domains: { marketing: string; account: string; admin: string }
}
const page = usePage()
const props = computed(() => page.props as unknown as PageProps)
const accountUrl = computed<string>(() => crossDomainUrl(props.value.domains?.account))
const plan = computed<DedicatedPlan>(() => props.value.plan)
const configGroups = computed<DedicatedConfigGroup[]>(() => props.value.configGroups || [])
const features = computed(() => plan.value.features ?? {})
const headlineSpecs = computed<Array<{ label: string; value: string; icon: string }>>(() => {
const f = features.value as Record<string, string | number | undefined>
const rows: Array<{ label: string; value: string; icon: string }> = []
if (f.cpu) rows.push({ label: 'CPU', value: String(f.cpu), icon: 'tabler-cpu' })
if (f.ram) rows.push({ label: 'RAM', value: String(f.ram), icon: 'tabler-device-sd-card' })
if (f.boot) rows.push({ label: 'Boot', value: String(f.boot), icon: 'tabler-disc' })
if (f.bays) rows.push({ label: 'Data bays', value: String(f.bays), icon: 'tabler-database' })
if (f.storage_controller) rows.push({ label: 'Controller', value: String(f.storage_controller), icon: 'tabler-cpu-2' })
if (f.idrac) rows.push({ label: 'Remote mgmt', value: String(f.idrac), icon: 'tabler-screen-share' })
if (f.network) rows.push({ label: 'Network', value: String(f.network), icon: 'tabler-network' })
if (f.bandwidth) rows.push({ label: 'Bandwidth', value: String(f.bandwidth), icon: 'tabler-arrows-right-left' })
if (f.ipv4) rows.push({ label: 'IPv4', value: String(f.ipv4), icon: 'tabler-id-badge' })
if (f.ipv6) rows.push({ label: 'IPv6', value: String(f.ipv6), icon: 'tabler-world' })
return rows
})
const setupFee = computed<number>(() => parseFloat(String(plan.value.setup_fee ?? 0)) || 0)
const leadTime = computed<string>(() => String(features.value.lead_time_days ?? '7-10'))
const formFactor = computed<string>(() => String(features.value.form_factor ?? ''))
const formattedPrice = computed<string>(() => {
const price = parseFloat(plan.value.price) || 0
return price % 1 === 0 ? `${price}` : price.toFixed(2)
})
// Pull live state from the store so the sticky summary stays in sync with
// every selection change without prop-drilling through the configurator.
const store = useDedicatedConfiguratorStore()
</script>
<template>
<Head :title="`${plan.name} | EZSCALE Dedicated Servers`" />
<div>
<!-- Breadcrumb + back link -->
<VContainer class="mt-6">
<Link href="/dedicated-servers" class="text-decoration-none text-body-2 text-medium-emphasis">
<VIcon icon="tabler-arrow-left" size="16" class="me-1" />
Back to dedicated servers
</Link>
</VContainer>
<!-- Header -->
<VContainer class="marketing-section pt-4">
<div class="detail-header">
<div class="detail-header__main">
<div class="d-flex align-center ga-2 mb-3 flex-wrap">
<VChip color="info" variant="tonal" size="small" prepend-icon="tabler-tools">
Build to order · {{ leadTime }} business days
</VChip>
<VChip v-if="formFactor" color="default" variant="tonal" size="small">
{{ formFactor }}
</VChip>
</div>
<h1 class="text-h3 font-weight-bold mb-3">{{ plan.name }}</h1>
<p v-if="plan.description" class="text-body-1 text-medium-emphasis mb-4" style="max-width: 660px;">
{{ plan.description }}
</p>
<div class="d-flex align-baseline ga-3 flex-wrap mb-2">
<span class="text-h4 font-weight-bold text-primary">${{ formattedPrice }}/mo</span>
<span v-if="setupFee > 0" class="text-body-2 text-medium-emphasis">
+ ${{ setupFee.toFixed(0) }} setup
<span class="text-caption">(waived on 6+ month commits)</span>
</span>
</div>
<div class="text-caption text-medium-emphasis">
Starting price for the locked baseline build. Configure below to see your actual total.
</div>
</div>
<div class="detail-header__specs">
<h3 class="text-overline mb-3">Locked baseline build</h3>
<div class="detail-header__spec-list">
<div v-for="row in headlineSpecs" :key="row.label" class="detail-header__spec-row">
<VIcon :icon="row.icon" size="16" class="me-2 text-medium-emphasis flex-shrink-0" />
<span class="detail-header__spec-label">{{ row.label }}</span>
<span class="detail-header__spec-value">{{ row.value }}</span>
</div>
</div>
</div>
</div>
</VContainer>
<!-- Configurator + sticky build summary -->
<div class="section-alt-bg marketing-section">
<VContainer>
<div class="text-center mb-6">
<VChip color="primary" variant="tonal" size="small" class="mb-2">Configure</VChip>
<h2 class="text-h4 font-weight-bold mb-2">Build it your way</h2>
<p class="text-body-1 text-medium-emphasis">
Pick a billing cycle and customize the upgrades. The build summary on the right updates as you go.
</p>
</div>
<div class="detail-grid">
<div class="detail-grid__rail">
<ConfigSectionRail :groups="configGroups" />
</div>
<div class="detail-grid__configurator">
<DedicatedConfigurator
:plan="plan"
:config-groups="configGroups"
:account-url="accountUrl"
/>
</div>
<div class="detail-grid__summary">
<BuildSummary
:plan="plan"
:config-groups="configGroups"
:selections="store.selections"
:cycle="store.cycle"
:baseline-price="store.baselinePrice"
:cycle-subtotal="store.cycleSubtotal"
:setup-fee="store.setupFee"
:baseline-setup-fee="setupFee"
:is-setup-fee-waived="store.isSetupFeeWaived"
:monthly-effective="store.monthlyEffective"
:cycle-total="store.cycleTotal"
:checkout-url="store.checkoutUrl"
:share-url="store.shareUrl"
/>
</div>
</div>
</VContainer>
</div>
</div>
</template>
<style lang="scss" scoped>
.detail-header {
display: grid;
grid-template-columns: 1.4fr 1fr;
gap: 40px;
align-items: start;
@media (max-width: 960px) {
grid-template-columns: 1fr;
gap: 28px;
}
}
.detail-header__specs {
padding: 22px;
border-radius: 16px;
background: rgba(var(--v-theme-surface), 0.6);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
}
.detail-header__spec-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.detail-header__spec-row {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 13px;
}
.detail-header__spec-label {
width: 110px;
flex-shrink: 0;
color: rgba(var(--v-theme-on-surface), 0.55);
font-weight: 500;
}
.detail-header__spec-value {
color: rgba(var(--v-theme-on-surface), 0.92);
flex-grow: 1;
}
.detail-grid {
display: grid;
// 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;
gap: 24px;
}
}
.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.
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) {
position: relative;
top: auto;
max-height: none;
overflow-y: visible;
order: -1; // mobile: summary above configurator so total is visible first
}
}
</style>