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>
250 lines
9.0 KiB
Vue
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>
|