feat(dedicated): phase 3 — frontend lineup pages + configurator

New pages:
- /dedicated-servers (rewrite): chassis grid with generation filter
  (All / Build to Order / In Stock — Rack inventory), hardware band,
  ColoCrossing-vs-EZSCALE comparison table, 8-question FAQ, custom-
  build CTA. Uses ChassisCard + GenerationFilter components.
- /dedicated-servers/{slug}: per-chassis page with locked baseline
  spec sidebar, configurator section (4-cycle toggle, 6 option
  groups via OptionGroupSelector, sticky ConfiguratorFooter with
  setup-fee waiver display), bay-strategy reminder card.

New shared components:
- Components/Marketing/Dedicated/
  - ChassisCard.vue — uniform card for both build-to-order and
    in-stock variants; differentiates with badge + border tone
  - GenerationFilter.vue — 3-option chip toggle with counts
  - BuildStatusPanel.vue — 5-stage timeline (Ordered → Hardware
    acquired → Assembly → Racked → Deployed) with editable prop
    for admin use; reused on customer service detail page
  - DedicatedConfigurator/
    - index.vue — orchestrator, mounts store, debounces URL push
    - CycleToggle.vue — 4 cycles (Monthly/Quarterly/Semi/Annual)
      with setup-waived badges on 6+ month tiers
    - OptionGroupSelector.vue — generic radio for any config group
    - ConfiguratorFooter.vue — sticky total + share link + order CTA

Pinia store:
- stores/dedicatedConfigurator.ts: per-chassis state (selections
  keyed by group name, cycle), getters for all sub-totals, setup-
  fee waiver logic, hydrateFromUrl + shareUrl + checkoutUrl. URL
  param shape: ?cycle=&cpu=&ram=&os=&bw=&ipv4= (only non-default
  values serialized).

Comparison rows are sourced from the freshly-landed competitor
research at infrastructure/docs/json/competitors-2026q2.json
and ovh-2026q2.json — focuses on hardware transparency, iDRAC9
inclusion, BOSS boot, setup-fee policy, and engineer-first support.

Drive picker descoped to v1.1 (per design spec): the configurator
captures CPU/RAM/OS/Bandwidth/IPv4 self-serve; drive selection is
handled via post-order ticket in v1. Bay-strategy callout on the
detail page sets that expectation.

npm run build clean; DedicatedServers + DedicatedServerDetail
bundles are 14.7 / 16.3 kB (gzipped 5.8 each). Visually verified
in the docker dev stack.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-26 18:01:12 -04:00
parent 9c178f289c
commit 311a4e961c
10 changed files with 1744 additions and 165 deletions

View File

@@ -0,0 +1,238 @@
<script lang="ts" setup>
import { computed } from 'vue'
interface Milestone {
id: number
stage: string
reached_at: string | null
note: string | null
}
interface Props {
milestones: Milestone[]
editable?: boolean
}
const props = withDefaults(defineProps<Props>(), {
editable: false,
})
const emit = defineEmits<{
markReached: [stage: string, note: string]
}>()
interface StageDef {
stage: string
label: string
customerMessage: string
icon: string
}
const STAGES: StageDef[] = [
{ stage: 'ordered', label: 'Ordered', customerMessage: "We've received your order. The hardware order will be placed with our supplier within 24 hours.", icon: 'tabler-receipt' },
{ stage: 'hardware_acquired', label: 'Hardware acquired', customerMessage: 'Hardware ordered and en route to our Atlanta datacenter (typically 2-3 days).', icon: 'tabler-truck-delivery' },
{ stage: 'assembly', label: 'Assembly', customerMessage: 'Server received in Atlanta. Our DC tech is configuring iDRAC, BOSS boot, and base BIOS settings.', icon: 'tabler-tools' },
{ stage: 'racked', label: 'Racked & online', customerMessage: 'Server racked, powered up, and online for final QA testing.', icon: 'tabler-server' },
{ stage: 'deployed', label: 'Deployed', customerMessage: 'Server is live. Root credentials and iDRAC access have been emailed to you.', icon: 'tabler-circle-check' },
]
interface RowState extends StageDef {
reachedAt: Date | null
note: string | null
isReached: boolean
isCurrent: boolean
}
const rows = computed<RowState[]>(() => {
// Build a quick lookup by stage
const milestoneByStage = new Map<string, Milestone>()
for (const m of props.milestones) milestoneByStage.set(m.stage, m)
// Find the highest stage that has reached_at — that's the "current" stage
let currentIdx = -1
for (let i = STAGES.length - 1; i >= 0; i--) {
const m = milestoneByStage.get(STAGES[i].stage)
if (m?.reached_at) {
currentIdx = i
break
}
}
return STAGES.map((s, idx) => {
const m = milestoneByStage.get(s.stage)
return {
...s,
reachedAt: m?.reached_at ? new Date(m.reached_at) : null,
note: m?.note ?? null,
isReached: !!m?.reached_at,
isCurrent: idx === currentIdx + 1, // next not-yet-reached
}
})
})
function formatDate(d: Date): string {
return d.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
timeZoneName: 'short',
})
}
function markReached(stage: string): void {
emit('markReached', stage, '')
}
</script>
<template>
<VCard class="build-status pa-6" variant="outlined">
<div class="build-status__head mb-5">
<div class="d-flex align-center ga-2 mb-2">
<VIcon icon="tabler-progress" color="primary" />
<h3 class="text-h6 font-weight-bold mb-0">Build progress</h3>
</div>
<p class="text-body-2 text-medium-emphasis mb-0">
Track your dedicated server build from order to live deployment.
</p>
</div>
<div class="build-status__timeline">
<div
v-for="(row, idx) in rows"
:key="row.stage"
class="build-status__step"
:class="{
'build-status__step--reached': row.isReached,
'build-status__step--current': row.isCurrent,
'build-status__step--editable': editable && !row.isReached,
}"
>
<div class="build-status__connector" v-if="idx > 0" />
<div class="build-status__icon-wrap">
<VIcon :icon="row.isReached ? 'tabler-circle-check-filled' : row.icon" size="24" />
</div>
<div class="build-status__body">
<div class="d-flex align-center justify-space-between flex-wrap ga-2">
<h4 class="build-status__step-title">{{ row.label }}</h4>
<div v-if="row.reachedAt" class="text-caption text-medium-emphasis">
{{ formatDate(row.reachedAt) }}
</div>
<VBtn
v-else-if="editable"
size="x-small"
variant="tonal"
color="primary"
@click="markReached(row.stage)"
>
Mark reached
</VBtn>
</div>
<p class="text-body-2 text-medium-emphasis mt-1 mb-0">{{ row.customerMessage }}</p>
<p v-if="row.note" class="text-caption mt-2 mb-0 build-status__note">
<VIcon icon="tabler-message" size="12" class="me-1" />
{{ row.note }}
</p>
</div>
</div>
</div>
</VCard>
</template>
<style lang="scss" scoped>
.build-status__timeline {
display: flex;
flex-direction: column;
gap: 6px;
position: relative;
}
.build-status__step {
display: flex;
align-items: flex-start;
gap: 16px;
padding: 12px 0;
position: relative;
opacity: 0.55;
&--reached {
opacity: 1;
}
&--current {
opacity: 1;
}
}
.build-status__connector {
position: absolute;
left: 23px;
top: -6px;
width: 2px;
height: 18px;
background: rgba(var(--v-theme-on-surface), 0.15);
}
.build-status__step--reached .build-status__connector {
background: rgb(var(--v-theme-primary));
}
.build-status__icon-wrap {
display: flex;
align-items: center;
justify-content: center;
width: 46px;
height: 46px;
border-radius: 50%;
background: rgba(var(--v-theme-on-surface), 0.06);
flex-shrink: 0;
z-index: 1;
.v-icon {
color: rgba(var(--v-theme-on-surface), 0.6);
}
}
.build-status__step--reached .build-status__icon-wrap {
background: rgba(var(--v-theme-primary), 0.15);
.v-icon {
color: rgb(var(--v-theme-primary));
}
}
.build-status__step--current .build-status__icon-wrap {
background: rgba(var(--v-theme-primary), 0.08);
border: 2px dashed rgba(var(--v-theme-primary), 0.5);
animation: build-status-pulse 2s ease-in-out infinite;
.v-icon {
color: rgb(var(--v-theme-primary));
}
}
.build-status__step-title {
font-size: 15px;
font-weight: 700;
margin-bottom: 0;
}
.build-status__note {
background: rgba(var(--v-theme-on-surface), 0.04);
padding: 6px 10px;
border-radius: 6px;
display: inline-flex;
align-items: center;
}
.build-status__body {
flex-grow: 1;
}
@keyframes build-status-pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
</style>

View File

@@ -0,0 +1,255 @@
<script lang="ts" setup>
import { computed } from 'vue'
interface Plan {
id: number
slug: string
name: string
price: string
setup_fee?: string | number
features: Record<string, string | number> | null
}
interface Props {
plan: Plan
}
const props = defineProps<Props>()
const generation = computed<string>(() => {
const g = props.plan.features?.generation
if (typeof g === 'string') return g
return ''
})
const isBuildToOrder = computed<boolean>(() => generation.value === '14th-gen')
const leadTime = computed<string | null>(() => {
const lt = props.plan.features?.lead_time_days
return typeof lt === 'string' ? lt : null
})
const formattedPrice = computed<string>(() => {
const price = parseFloat(props.plan.price) || 0
return price % 1 === 0 ? `${price}` : price.toFixed(2)
})
const setupFee = computed<number>(() => {
return parseFloat(String(props.plan.setup_fee ?? 0)) || 0
})
const cpu = computed<string>(() => {
const c = props.plan.features?.cpu
return typeof c === 'string' ? c : '—'
})
const ram = computed<string>(() => {
const r = props.plan.features?.ram
return typeof r === 'string' ? r : '—'
})
const bays = computed<string>(() => {
const b = props.plan.features?.bays ?? props.plan.features?.storage_bays
return typeof b === 'string' ? b : '—'
})
const formFactor = computed<string>(() => {
const f = props.plan.features?.form_factor
return typeof f === 'string' ? f : ''
})
</script>
<template>
<div class="chassis-card" :class="{ 'chassis-card--build-to-order': isBuildToOrder }">
<div class="chassis-card__badge-row">
<VChip
v-if="isBuildToOrder"
size="x-small"
color="info"
variant="tonal"
density="compact"
prepend-icon="tabler-tools"
>
Build to order · {{ leadTime }}d
</VChip>
<VChip
v-else
size="x-small"
color="success"
variant="tonal"
density="compact"
prepend-icon="tabler-circle-check"
>
In stock · 1-2d ship
</VChip>
<VChip
v-if="formFactor"
size="x-small"
color="default"
variant="tonal"
density="compact"
>
{{ formFactor }}
</VChip>
</div>
<div class="chassis-card__head">
<div class="chassis-card__name">{{ plan.name }}</div>
<div class="chassis-card__price-row">
<span class="chassis-card__currency">$</span>
<span class="chassis-card__price">{{ formattedPrice }}</span>
<span class="chassis-card__period">/mo</span>
</div>
<div v-if="setupFee > 0" class="chassis-card__setup-fee">
+${{ setupFee.toFixed(0) }} setup
<span class="text-medium-emphasis">(waived on 6+ months)</span>
</div>
</div>
<div class="chassis-card__specs">
<div class="chassis-card__spec">
<VIcon icon="tabler-cpu" size="18" />
<span>{{ cpu }}</span>
</div>
<div class="chassis-card__spec">
<VIcon icon="tabler-device-sd-card" size="18" />
<span>{{ ram }}</span>
</div>
<div class="chassis-card__spec">
<VIcon icon="tabler-database" size="18" />
<span>{{ bays }}</span>
</div>
</div>
<div class="chassis-card__actions">
<Link v-if="isBuildToOrder" :href="`/dedicated-servers/${plan.slug}`" class="text-decoration-none">
<VBtn block color="primary" size="default" rounded="lg">
Configure & price
<VIcon icon="tabler-arrow-right" end size="small" />
</VBtn>
</Link>
<a v-else :href="`/dedicated-servers/${plan.slug}`" class="text-decoration-none">
<VBtn block color="primary" size="default" rounded="lg">
View details
<VIcon icon="tabler-arrow-right" end size="small" />
</VBtn>
</a>
</div>
</div>
</template>
<script lang="ts">
import { Link } from '@inertiajs/vue3'
export default {
components: { Link },
}
</script>
<style lang="scss" scoped>
.chassis-card {
display: flex;
flex-direction: column;
padding: 22px 20px 20px;
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);
transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease;
height: 100%;
&:hover {
transform: translateY(-3px);
border-color: rgba(var(--v-theme-primary), 0.4);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.25);
}
}
.chassis-card--build-to-order {
border-color: rgba(var(--v-theme-info), 0.25);
&:hover {
border-color: rgba(var(--v-theme-info), 0.5);
}
}
.chassis-card__badge-row {
display: flex;
gap: 6px;
margin-bottom: 14px;
flex-wrap: wrap;
}
.chassis-card__head {
margin-bottom: 16px;
}
.chassis-card__name {
font-size: 17px;
font-weight: 700;
letter-spacing: -0.01em;
color: rgba(var(--v-theme-on-surface), 0.95);
margin-bottom: 6px;
}
.chassis-card__price-row {
display: flex;
align-items: baseline;
gap: 2px;
color: rgb(var(--v-theme-primary));
}
.chassis-card__currency {
font-size: 18px;
font-weight: 600;
}
.chassis-card__price {
font-size: 32px;
font-weight: 800;
line-height: 1;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
}
.chassis-card__period {
font-size: 13px;
font-weight: 500;
color: rgba(var(--v-theme-on-surface), 0.6);
margin-left: 4px;
}
.chassis-card__setup-fee {
margin-top: 4px;
font-size: 12px;
color: rgba(var(--v-theme-on-surface), 0.7);
}
.chassis-card__specs {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 18px;
padding: 12px 0;
border-top: 1px solid rgba(var(--v-theme-on-surface), 0.06);
border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.06);
}
.chassis-card__spec {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
color: rgba(var(--v-theme-on-surface), 0.85);
.v-icon {
color: rgba(var(--v-theme-on-surface), 0.55);
flex-shrink: 0;
}
}
.chassis-card__actions {
margin-top: auto;
}
</style>

View File

@@ -0,0 +1,119 @@
<script lang="ts" setup>
import { ref } from 'vue'
import type { DedicatedCycle } from '@/stores/dedicatedConfigurator'
interface Props {
cycleSubtotal: number
setupFee: number
cycleTotal: number
monthlyEffective: number
cycle: DedicatedCycle
isSetupFeeWaived: boolean
baselineSetupFee: number
checkoutUrl: string
shareUrl: string
}
defineProps<Props>()
const cycleLabel: Record<DedicatedCycle, string> = {
monthly: 'monthly',
quarterly: 'quarterly (3 months)',
semi_annual: 'semi-annually (6 months)',
annual: 'annually (12 months)',
}
const copied = ref<boolean>(false)
async function copyShareLink(url: string): Promise<void> {
try {
await navigator.clipboard.writeText(url)
} catch {
const ta = document.createElement('textarea')
ta.value = url
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.select()
try { document.execCommand('copy') } catch { /* ignore */ }
document.body.removeChild(ta)
}
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
}
</script>
<template>
<div class="configurator-footer">
<div class="configurator-footer__totals flex-grow-1">
<div class="text-caption text-medium-emphasis">Your estimated total</div>
<div class="d-flex align-baseline ga-2 flex-wrap">
<span class="text-h4 font-weight-bold text-primary">${{ cycleTotal.toFixed(2) }}</span>
<span class="text-body-2 text-medium-emphasis">billed {{ cycleLabel[cycle] }}</span>
</div>
<div v-if="cycle !== 'monthly'" class="text-caption text-medium-emphasis">
Effective ${{ monthlyEffective.toFixed(2) }}/mo
</div>
<div v-if="baselineSetupFee > 0" class="configurator-footer__setup mt-2">
<template v-if="isSetupFeeWaived">
<VIcon icon="tabler-circle-check-filled" color="success" size="14" class="me-1" />
<span class="text-caption text-success">Setup fee (${{ baselineSetupFee.toFixed(0) }}) waived on this cycle</span>
</template>
<template v-else>
<VIcon icon="tabler-info-circle" size="14" class="me-1 text-medium-emphasis" />
<span class="text-caption text-medium-emphasis">
Includes ${{ setupFee.toFixed(0) }} setup fee non-refundable once hardware is purchased
</span>
</template>
</div>
</div>
<div class="d-flex align-center ga-3 flex-wrap">
<VBtn
:variant="copied ? 'flat' : 'outlined'"
:color="copied ? 'success' : 'primary'"
size="large"
:prepend-icon="copied ? 'tabler-check' : 'tabler-link'"
@click="copyShareLink(shareUrl)"
>
{{ copied ? 'Copied!' : 'Copy share link' }}
</VBtn>
<a :href="checkoutUrl" class="text-decoration-none">
<VBtn color="primary" size="large" rounded="lg" prepend-icon="tabler-shopping-cart">
Order this build
<VIcon icon="tabler-arrow-right" end />
</VBtn>
</a>
</div>
</div>
</template>
<style lang="scss" scoped>
.configurator-footer {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 22px;
padding: 22px 26px;
border-radius: 18px;
background: rgba(var(--v-theme-surface), 0.85);
border: 1px solid rgba(var(--v-theme-primary), 0.22);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
position: sticky;
bottom: 16px;
z-index: 5;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
@media (max-width: 600px) {
position: relative;
bottom: auto;
}
}
.configurator-footer__setup {
display: flex;
align-items: center;
}
</style>

View File

@@ -0,0 +1,91 @@
<script lang="ts" setup>
import type { DedicatedCycle } from '@/stores/dedicatedConfigurator'
interface Props {
modelValue: DedicatedCycle
}
defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: DedicatedCycle]
}>()
const cycles: Array<{ value: DedicatedCycle; label: string; badge?: string; waivesSetup?: boolean }> = [
{ value: 'monthly', label: 'Monthly' },
{ value: 'quarterly', label: 'Quarterly', badge: '5% off' },
{ value: 'semi_annual', label: 'Semi-Annual', badge: '10% off · setup waived', waivesSetup: true },
{ value: 'annual', label: 'Annual', badge: '15% off · setup waived', waivesSetup: true },
]
</script>
<template>
<div class="cycle-toggle d-inline-flex pa-1 rounded-pill flex-wrap" role="radiogroup" aria-label="Billing cycle">
<button
v-for="c in cycles"
:key="c.value"
type="button"
role="radio"
:aria-checked="modelValue === c.value"
class="cycle-toggle__option"
:class="{
'cycle-toggle__option--active': modelValue === c.value,
'cycle-toggle__option--waives': c.waivesSetup,
}"
@click="emit('update:modelValue', c.value)"
>
<span>{{ c.label }}</span>
<span v-if="c.badge" class="cycle-toggle__badge">{{ c.badge }}</span>
</button>
</div>
</template>
<style lang="scss" scoped>
.cycle-toggle {
background: rgba(var(--v-theme-surface), 0.6);
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
gap: 4px;
backdrop-filter: blur(8px);
}
.cycle-toggle__option {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-radius: 999px;
font-size: 13px;
font-weight: 500;
color: rgba(var(--v-theme-on-surface), 0.7);
background: transparent;
border: none;
cursor: pointer;
transition: all 0.18s ease;
white-space: nowrap;
&:hover:not(.cycle-toggle__option--active) {
color: rgba(var(--v-theme-on-surface), 0.95);
background: rgba(var(--v-theme-on-surface), 0.04);
}
}
.cycle-toggle__option--active {
background: rgb(var(--v-theme-primary));
color: rgb(var(--v-theme-on-primary));
box-shadow: 0 4px 12px rgba(var(--v-theme-primary), 0.25);
}
.cycle-toggle__badge {
font-size: 11px;
font-weight: 700;
padding: 2px 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.22);
letter-spacing: 0.02em;
}
.cycle-toggle__option:not(.cycle-toggle__option--active) .cycle-toggle__badge {
background: rgba(var(--v-theme-success), 0.18);
color: rgb(var(--v-theme-success));
}
</style>

View File

@@ -0,0 +1,120 @@
<script lang="ts" setup>
import { computed } from 'vue'
import type { DedicatedConfigGroup, DedicatedConfigValue, DedicatedCycle } from '@/stores/dedicatedConfigurator'
interface Props {
group: DedicatedConfigGroup
selected: string
cycle: DedicatedCycle
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:selected': [value: string]
}>()
const values = computed<DedicatedConfigValue[]>(() => props.group.options[0]?.values ?? [])
function priceFor(v: DedicatedConfigValue): number {
const raw = props.cycle === 'monthly'
? v.monthly_price
: props.cycle === 'quarterly'
? v.quarterly_price
: props.cycle === 'semi_annual'
? v.semi_annual_price
: v.annual_price
return raw ? parseFloat(raw) : 0
}
function priceLabel(v: DedicatedConfigValue): string {
const p = priceFor(v)
if (p === 0) return v.is_default ? 'included' : 'no extra cost'
const suffix = props.cycle === 'monthly' ? '/mo' : props.cycle === 'quarterly' ? '/qtr' : props.cycle === 'semi_annual' ? '/6mo' : '/yr'
return `+$${p.toFixed(2)}${suffix}`
}
</script>
<template>
<div class="option-group">
<div class="option-group__head">
<h4 class="option-group__title">{{ group.name.replace('Dedicated 14th Gen — ', '') }}</h4>
<p v-if="group.description" class="option-group__desc">{{ group.description }}</p>
</div>
<div class="option-group__list">
<button
v-for="v in values"
:key="v.id"
type="button"
class="option-group__option"
:class="{ 'option-group__option--active': selected === v.value }"
@click="emit('update:selected', v.value)"
>
<VIcon
:icon="selected === v.value ? 'tabler-circle-check-filled' : 'tabler-circle'"
:color="selected === v.value ? 'primary' : undefined"
size="22"
class="me-3 mt-1 flex-shrink-0"
/>
<div class="flex-grow-1">
<div class="font-weight-bold">{{ v.label }}</div>
</div>
<div
class="ms-3 flex-shrink-0 font-weight-bold"
:class="priceFor(v) === 0 ? 'text-medium-emphasis text-caption' : 'text-primary'"
>
{{ priceLabel(v) }}
</div>
</button>
</div>
</div>
</template>
<style lang="scss" scoped>
.option-group__head {
margin-bottom: 12px;
}
.option-group__title {
font-size: 16px;
font-weight: 700;
letter-spacing: -0.01em;
margin-bottom: 4px;
}
.option-group__desc {
font-size: 13px;
color: rgba(var(--v-theme-on-surface), 0.6);
margin-bottom: 0;
}
.option-group__list {
display: flex;
flex-direction: column;
gap: 8px;
}
.option-group__option {
display: flex;
align-items: flex-start;
text-align: left;
width: 100%;
padding: 12px 16px;
border-radius: 12px;
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
background: rgba(var(--v-theme-on-surface), 0.03);
cursor: pointer;
transition: all 0.15s ease;
&:hover {
border-color: rgba(var(--v-theme-primary), 0.45);
background: rgba(var(--v-theme-primary), 0.06);
}
}
.option-group__option--active {
border-color: rgb(var(--v-theme-primary));
background: rgba(var(--v-theme-primary), 0.10);
}
</style>

View File

@@ -0,0 +1,125 @@
<script lang="ts" setup>
import { computed, onMounted, watch } from 'vue'
import { useDedicatedConfiguratorStore, type DedicatedPlan, type DedicatedConfigGroup, type DedicatedCycle } from '@/stores/dedicatedConfigurator'
import OptionGroupSelector from './OptionGroupSelector.vue'
import CycleToggle from './CycleToggle.vue'
import ConfiguratorFooter from './ConfiguratorFooter.vue'
interface Props {
plan: DedicatedPlan
configGroups: DedicatedConfigGroup[]
accountUrl: string
}
const props = defineProps<Props>()
const store = useDedicatedConfiguratorStore()
const baselineSetupFee = computed<number>(() => parseFloat(String(props.plan.setup_fee ?? 0)) || 0)
function pushUrlState(): void {
if (typeof window === 'undefined') return
const newUrl = store.shareUrl
if (newUrl !== window.location.href) {
window.history.replaceState({}, '', newUrl)
}
}
let urlDebounce: ReturnType<typeof setTimeout> | null = null
function debouncePushUrl(): void {
if (urlDebounce) clearTimeout(urlDebounce)
urlDebounce = setTimeout(pushUrlState, 300)
}
function onSelectionChange(groupName: string, value: string): void {
store.setSelection(groupName, value)
debouncePushUrl()
}
function onCycleChange(c: DedicatedCycle): void {
store.cycle = c
debouncePushUrl()
}
onMounted(() => {
store.init({
plan: props.plan,
configGroups: props.configGroups,
accountUrl: props.accountUrl,
})
if (typeof window !== 'undefined') {
store.hydrateFromUrl(window.location.search)
}
})
watch(() => props.plan?.id, () => {
store.init({
plan: props.plan,
configGroups: props.configGroups,
accountUrl: props.accountUrl,
})
})
</script>
<template>
<div class="dedicated-configurator">
<div class="dedicated-configurator__cycle">
<div class="text-overline text-medium-emphasis mb-2">Billing cycle</div>
<CycleToggle :model-value="store.cycle" @update:model-value="onCycleChange" />
</div>
<div class="dedicated-configurator__groups">
<OptionGroupSelector
v-for="group in configGroups"
:key="group.id"
:group="group"
:selected="store.selections[group.name] ?? ''"
:cycle="store.cycle"
@update:selected="(v: string) => onSelectionChange(group.name, v)"
/>
</div>
<div class="dedicated-configurator__footer-wrap">
<ConfiguratorFooter
:cycle-subtotal="store.cycleSubtotal"
:setup-fee="store.setupFee"
:cycle-total="store.cycleTotal"
:monthly-effective="store.monthlyEffective"
:cycle="store.cycle"
:is-setup-fee-waived="store.isSetupFeeWaived"
:baseline-setup-fee="baselineSetupFee"
:checkout-url="store.checkoutUrl"
:share-url="store.shareUrl"
/>
</div>
</div>
</template>
<style lang="scss" scoped>
.dedicated-configurator {
display: flex;
flex-direction: column;
gap: 28px;
}
.dedicated-configurator__cycle {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.dedicated-configurator__groups {
display: flex;
flex-direction: column;
gap: 28px;
padding: 24px;
border-radius: 18px;
background: rgba(var(--v-theme-surface), 0.4);
border: 1px solid rgba(var(--v-theme-on-surface), 0.06);
backdrop-filter: blur(12px);
}
.dedicated-configurator__footer-wrap {
margin-top: 8px;
}
</style>

View File

@@ -0,0 +1,88 @@
<script lang="ts" setup>
type Generation = 'all' | '14th-gen' | 'in-stock'
interface Props {
modelValue: Generation
counts: { all: number; build_to_order: number; in_stock: number }
}
defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: Generation]
}>()
const tabs: Array<{ value: Generation; label: string; count: 'all' | 'build_to_order' | 'in_stock' }> = [
{ value: 'all', label: 'All servers', count: 'all' },
{ value: '14th-gen', label: 'Build to Order', count: 'build_to_order' },
{ value: 'in-stock', label: 'In Stock — Rack inventory', count: 'in_stock' },
]
</script>
<template>
<div class="gen-filter d-inline-flex pa-1 rounded-pill" role="radiogroup" aria-label="Filter dedicated servers">
<button
v-for="tab in tabs"
:key="tab.value"
type="button"
role="radio"
:aria-checked="modelValue === tab.value"
class="gen-filter__option"
:class="{ 'gen-filter__option--active': modelValue === tab.value }"
@click="emit('update:modelValue', tab.value)"
>
<span>{{ tab.label }}</span>
<span class="gen-filter__count">{{ counts[tab.count] }}</span>
</button>
</div>
</template>
<style lang="scss" scoped>
.gen-filter {
background: rgba(var(--v-theme-surface), 0.6);
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
gap: 4px;
backdrop-filter: blur(8px);
}
.gen-filter__option {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 18px;
border-radius: 999px;
font-size: 14px;
font-weight: 500;
color: rgba(var(--v-theme-on-surface), 0.7);
background: transparent;
border: none;
cursor: pointer;
transition: all 0.18s ease;
white-space: nowrap;
&:hover:not(.gen-filter__option--active) {
color: rgba(var(--v-theme-on-surface), 0.95);
background: rgba(var(--v-theme-on-surface), 0.04);
}
}
.gen-filter__option--active {
background: rgb(var(--v-theme-primary));
color: rgb(var(--v-theme-on-primary));
box-shadow: 0 4px 12px rgba(var(--v-theme-primary), 0.25);
}
.gen-filter__count {
font-size: 11px;
font-weight: 700;
padding: 2px 7px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.18);
letter-spacing: 0.02em;
}
.gen-filter__option:not(.gen-filter__option--active) .gen-filter__count {
background: rgba(var(--v-theme-on-surface), 0.08);
color: rgba(var(--v-theme-on-surface), 0.7);
}
</style>

View File

@@ -0,0 +1,199 @@
<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 { 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)
})
</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 -->
<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. Total updates live; share link copies the exact config.
</p>
</div>
<div class="detail-configurator-wrap">
<DedicatedConfigurator
:plan="plan"
:config-groups="configGroups"
:account-url="accountUrl"
/>
</div>
</VContainer>
</div>
<!-- Bay strategy reminder -->
<VContainer class="marketing-section">
<VCard class="pa-6 detail-bay-card">
<div class="d-flex align-start ga-4 flex-wrap">
<VIcon icon="tabler-info-circle" size="28" color="primary" class="flex-shrink-0" />
<div class="flex-grow-1">
<h3 class="text-h6 font-weight-bold mb-2">All main bays ship empty</h3>
<p class="text-body-2 text-medium-emphasis mb-2">
<strong>Drive selection</strong> isn't in the v1 self-serve configurator. Once you order, we'll reach out to confirm your drive layout SATA / SAS / NVMe options, RAID/ZFS preference, and per-bay placement. Drives arrive separately and slot into the pre-installed trays during assembly.
</p>
<p class="text-body-2 text-medium-emphasis mb-0">
Need specific drives sourced before you order? <a href="/contact" class="text-primary text-decoration-none">Open a ticket</a> with your spec we'll quote the drive cost and add it to your first invoice.
</p>
</div>
</div>
</VCard>
</VContainer>
</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-configurator-wrap {
max-width: 880px;
margin: 0 auto;
}
.detail-bay-card {
background: rgba(var(--v-theme-surface), 0.6) !important;
border: 1px solid rgba(var(--v-theme-info), 0.18) !important;
}
</style>

View File

@@ -1,12 +1,14 @@
<script lang="ts" setup> <script lang="ts" setup>
import { Head, usePage } from '@inertiajs/vue3' import { Head, usePage } from '@inertiajs/vue3'
import { computed } from 'vue' import { computed, ref } from 'vue'
import MarketingLayout from '@/Layouts/MarketingLayout.vue' import MarketingLayout from '@/Layouts/MarketingLayout.vue'
import SectionHeader from '@/Components/Marketing/SectionHeader.vue' import SectionHeader from '@/Components/Marketing/SectionHeader.vue'
import HeroSection from '@/Components/Marketing/HeroSection.vue' import HeroSection from '@/Components/Marketing/HeroSection.vue'
import DedicatedHero from '@/Components/Marketing/DedicatedHero.vue' import DedicatedHero from '@/Components/Marketing/DedicatedHero.vue'
import ScrollReveal from '@/Components/Marketing/ScrollReveal.vue' import ChassisCard from '@/Components/Marketing/Dedicated/ChassisCard.vue'
import { crossDomainUrl } from '@/utils/resolvers' import GenerationFilter from '@/Components/Marketing/Dedicated/GenerationFilter.vue'
import ComparisonTable from '@/Components/Marketing/ComparisonTable.vue'
import Faq from '@/Components/Marketing/Faq.vue'
defineOptions({ layout: MarketingLayout }) defineOptions({ layout: MarketingLayout })
@@ -15,6 +17,7 @@ interface Plan {
name: string name: string
slug: string slug: string
price: string price: string
setup_fee?: string | number
features: Record<string, string | number> | null features: Record<string, string | number> | null
stock_quantity: number | null stock_quantity: number | null
} }
@@ -26,39 +29,98 @@ interface PageProps {
const page = usePage() const page = usePage()
const props = computed(() => page.props as unknown as PageProps) const props = computed(() => page.props as unknown as PageProps)
const accountUrl = computed(() => crossDomainUrl(props.value.domains?.account)) const plans = computed<Plan[]>(() => props.value.plans || [])
const plans = computed(() => props.value.plans || [])
const features = [ type Generation = 'all' | '14th-gen' | 'in-stock'
{ icon: 'tabler-cpu', title: 'Dedicated Hardware', description: 'No shared resources — all CPU, RAM, and storage are exclusively yours.' },
{ icon: 'tabler-dashboard', title: 'SynergyCP Access', description: 'Full server management with SynergyCP panel including IPMI, rDNS, and OS reload.' }, const filter = ref<Generation>('all')
{ icon: 'tabler-network', title: '1Gbps Network', description: '1 Gbps port with 10 TB bandwidth included on every server.' },
{ icon: 'tabler-lock', title: 'RAID Support', description: 'Enterprise RAID controllers available for data redundancy and performance.' }, const buildToOrderPlans = computed<Plan[]>(() =>
{ icon: 'tabler-clock', title: 'Same-Day Setup', description: 'Most in-stock servers deployed same-day, subject to availability.' }, plans.value.filter(p => p.features?.generation === '14th-gen'),
{ icon: 'tabler-headset', title: 'Expert Support', description: 'Experienced engineers ready to help with hardware and network issues via our ticket system.' }, )
const inStockPlans = computed<Plan[]>(() =>
plans.value.filter(p => p.features?.generation !== '14th-gen'),
)
const counts = computed(() => ({
all: plans.value.length,
build_to_order: buildToOrderPlans.value.length,
in_stock: inStockPlans.value.length,
}))
const startingPrice = computed<string>(() => {
if (plans.value.length === 0) return '44'
const lowest = Math.min(...plans.value.map(p => parseFloat(p.price)))
return lowest % 1 === 0 ? lowest.toString() : lowest.toFixed(2)
})
// "EZSCALE vs the typical big-cloud / budget-bundler" comparison rows.
// Numbers and competitor positioning sourced from
// infrastructure/docs/json/competitors-2026q2.json (ColoCrossing, GTHost,
// budget Atlanta-presence providers) and ovh-2026q2.json (OVH SYS / Kimsufi).
const comparisonRows = [
{
feature: 'Hardware transparency',
ours: 'Dell PowerEdge SKU + CPU model + DIMM type',
theirs: 'Generic "Xeon Scalable" + "DDR4"',
},
{
feature: 'iDRAC9 Enterprise (full virtual KVM)',
ours: 'Included on every build',
theirs: 'Often Express tier or extra fee',
},
{
feature: 'BOSS M.2 boot drive',
ours: 'Included — all main bays free for data',
theirs: 'Boot drive eats a data bay',
},
{
feature: 'Setup fee',
ours: 'Waived at 6+ month commitments',
theirs: 'OVH Kimsufi/SYS charge $60-$125 setup; many add hidden install fees',
},
{
feature: 'Who answers your ticket',
ours: 'Engineers, AI-assisted by Ezra',
theirs: 'Tier-1 outsourced or AI-only',
},
] ]
const included = [ const faqs = [
{ icon: 'tabler-world', label: '10 TB Bandwidth' }, {
{ icon: 'tabler-network', label: '1 Gbps Port' }, question: 'How long until my dedicated server is live?',
{ icon: 'tabler-map-pin', label: 'Atlanta, GA Datacenter' }, answer: '<strong>Build to order (14th gen):</strong> 7-10 business days from order. We order the chassis from SaveMyServer (Suwanee, GA — 30 minutes from our Atlanta DC), assemble it, rack it, and hand off root access. You\'ll see live status in your dashboard.<br><br><strong>In stock (12th/13th gen):</strong> 1-2 business days. These are racked already in our Atlanta facility.',
{ icon: 'tabler-address-book', label: '1 IPv4 Address' }, },
{ icon: 'tabler-hexagons', label: '1x /64 IPv6 Subnet' }, {
{ icon: 'tabler-dashboard', label: 'SynergyCP Panel' }, question: 'What hardware do you actually use?',
answer: 'Dell PowerEdge 14th-gen chassis (R440 / R540 / R640 / R740 / R740xd / R740xd NVMe), Intel Xeon Gold 6230 baseline (40 cores @ 2.10 GHz), DDR4-2400 ECC memory (RDIMM up to 128 GB; LRDIMM at 256+ GB), Dell BOSS M.2 mirror for boot, PERC HBA330 controller in IT mode (pass-through for ZFS / mdraid), iDRAC9 Enterprise. Full bills of materials are public in our build sheets.',
},
{
question: 'Why is the configured price higher than the listed starting price?',
answer: 'Listed starting prices are for the locked baseline build (CPU + 32 GB RAM + boot drive + iDRAC + power + network). <strong>All main bays ship empty</strong> — you pick drives in the configurator. A typical "ready to deploy" config (256 GB RAM + 2× SSDs + bandwidth uplift) lands $50-$200/mo above the starting price. We don\'t bundle drives by default because workload requirements vary too much.',
},
{
question: 'How do setup fees work?',
answer: 'Setup fees range from $349 to $799 depending on chassis class (covers our hardware-acquisition cost). They\'re <strong>charged on monthly and quarterly cycles, waived on Semi-Annual and Annual commitments</strong>. Once we\'ve placed the hardware order with our supplier (typically within 24 hours of your order), the setup fee is non-refundable. Rental fees still fall under the 14-day money-back guarantee.',
},
{
question: 'What operating systems are supported?',
answer: 'AlmaLinux 9, Ubuntu 24.04 LTS, Debian 12, Rocky Linux 9, and Windows Server 2022 (BYOL — bring your own license). You can also choose "No OS" and PXE-boot a custom image — common for k3s nodes, Proxmox / ESXi hypervisors, or Talos clusters.',
},
{
question: 'Can I run software RAID / ZFS?',
answer: 'Yes — we ship the PERC HBA330 controller in IT mode (pure pass-through) so SMART data and firmware events route directly to your OS. ZFS, mdraid, btrfs all work. Hardware RAID (PERC H730p / H740p) is available as an upgrade if you prefer it.',
},
{
question: 'Where is the data physically located?',
answer: 'Atlanta, GA — Evocative datacenter (250 Williams St NW, downtown ATL). All of our customer-facing dedicated servers and hypervisors live in the same facility.',
},
{
question: 'What about DDoS protection, IPv4 addresses, and bandwidth?',
answer: 'Every server includes 1 IPv4 + a /64 IPv6 block, with extra IPv4 blocks (/29, /28, /27) as configurable add-ons. Bandwidth defaults to 1 Gbps unmetered; 10 Gbps tiered packages (10/50/100 TB or unmetered fair-use) are available. Volumetric DDoS protection is on the roadmap and will be free when it ships.',
},
] ]
function isInStock(plan: Plan): boolean {
return plan.stock_quantity === null || plan.stock_quantity > 0
}
function getFeature(plan: Plan, key: string): string {
return String(plan.features?.[key] ?? '-')
}
function formatPrice(plan: Plan): string {
const price = parseFloat(plan.price) || 0
return price % 1 === 0 ? `$${price}` : `$${price.toFixed(2)}`
}
</script> </script>
<template> <template>
@@ -67,17 +129,20 @@ function formatPrice(plan: Plan): string {
<!-- Hero --> <!-- Hero -->
<HeroSection> <HeroSection>
<template #content> <template #content>
<VChip color="success" variant="tonal" class="mb-4">Dedicated Servers</VChip> <VChip color="primary" variant="tonal" class="mb-4">Dedicated Servers</VChip>
<h1 class="text-h3 font-weight-bold mb-3"> <h1 class="text-h3 font-weight-bold mb-3">
Bare Metal <span class="hero-gradient-text">Power</span> Bare-metal <span class="hero-gradient-text">Dell PowerEdge</span>
</h1> </h1>
<p class="text-h6 text-medium-emphasis font-weight-regular mb-8" style="max-width: 600px;"> <p class="text-h6 text-medium-emphasis font-weight-regular mb-4" style="max-width: 600px;">
Enterprise-grade Dell PowerEdge servers with full root access, SynergyCP management, and same-day deployment from our Atlanta datacenter. Atlanta-based dedicated servers. Pick a ready-to-rent chassis from rack inventory, or build a 14th-gen exactly to spec we order the hardware and rack it for you.
</p> </p>
<a :href="plans.length ? accountUrl + '/checkout/' + plans[0].id : '/pricing'" class="text-decoration-none"> <p class="text-body-1 text-medium-emphasis mb-8">
<VBtn color="success" size="large" rounded="lg"> Starting at <span class="text-primary font-weight-bold">${{ startingPrice }}/mo</span>
Configure Server </p>
<VIcon icon="tabler-arrow-right" end /> <a href="#chassis-grid" class="text-decoration-none">
<VBtn color="primary" size="large" rounded="lg">
Browse the lineup
<VIcon icon="tabler-arrow-down" end />
</VBtn> </VBtn>
</a> </a>
</template> </template>
@@ -87,154 +152,138 @@ function formatPrice(plan: Plan): string {
</template> </template>
</HeroSection> </HeroSection>
<!-- Features --> <!-- Hardware band -->
<VContainer class="marketing-section"> <VContainer class="marketing-section">
<SectionHeader <div class="hardware-band mb-6" role="note" aria-label="Underlying hardware">
label="Features" <div class="hardware-band__label">Built on</div>
label-color="success" <ul class="hardware-band__list">
title="Enterprise Hardware" <li>
highlight-word="Hardware" <VIcon icon="tabler-server-2" size="16" />
subtitle="Every dedicated server comes with these features included." <span>Dell PowerEdge</span>
/> </li>
<VRow> <li>
<VCol v-for="(feature, index) in features" :key="feature.title" cols="12" sm="6" md="4"> <VIcon icon="tabler-cpu" size="16" />
<div :class="['d-flex ga-3 mb-4 feature-card-hover pa-3 rounded-lg', `fade-in-up stagger-${index + 1}`]"> <span>Intel Xeon Gold (Cascade Lake)</span>
<VAvatar color="success" variant="tonal" size="44"> </li>
<VIcon :icon="feature.icon" size="22" /> <li>
</VAvatar> <VIcon icon="tabler-shield-bolt" size="16" />
<div> <span>DDR4-2400 ECC</span>
<h3 class="text-subtitle-1 font-weight-bold">{{ feature.title }}</h3> </li>
<p class="text-body-2 text-medium-emphasis mb-0">{{ feature.description }}</p> <li>
</div> <VIcon icon="tabler-database" size="16" />
</div> <span>BOSS M.2 boot · all bays free for data</span>
</VCol> </li>
</VRow> <li>
<VIcon icon="tabler-screen-share" size="16" />
<span>iDRAC9 Enterprise</span>
</li>
<li>
<VIcon icon="tabler-map-pin" size="16" />
<span>Atlanta, GA datacenter</span>
</li>
</ul>
</div>
</VContainer> </VContainer>
<!-- Server Configurations --> <!-- Chassis grid -->
<div class="section-alt-bg marketing-section"> <div id="chassis-grid" class="section-alt-bg marketing-section">
<VContainer> <VContainer>
<SectionHeader <SectionHeader
label="Servers" label="Lineup"
label-color="success" title="Pick your chassis"
title="Server Configurations" highlight-word="chassis"
highlight-word="Configurations" subtitle="Build to order from our 14th-gen catalog, or rent a ready-to-deploy server from rack inventory."
subtitle="Real Dell PowerEdge servers. Storage sold separately -- configure your drives at checkout."
/> />
<VRow> <div class="d-flex justify-center mb-6">
<VCol v-for="(plan, index) in plans" :key="plan.id" cols="12" sm="6" lg="3"> <GenerationFilter v-model="filter" :counts="counts" />
<VCard </div>
variant="outlined"
:class="['h-100 feature-card-hover', `fade-in-up stagger-${Math.min(index + 1, 8)}`, { 'server-card-unavailable': !isInStock(plan) }]" <div v-show="filter === 'all' || filter === '14th-gen'" class="mb-8">
<h3 class="text-h6 font-weight-bold mb-4 chassis-grid-heading">
Build to order Dell 14th gen
<span class="text-caption text-medium-emphasis ms-2">
7-10 business days · setup fee waived on 6+ month commitments
</span>
</h3>
<VRow>
<VCol
v-for="plan in buildToOrderPlans"
:key="plan.id"
cols="12"
sm="6"
md="4"
lg="3"
> >
<VCardText class="pa-5"> <ChassisCard :plan="plan" />
<!-- Header with model and stock status --> </VCol>
<div class="d-flex align-center justify-space-between mb-1"> </VRow>
<h3 class="text-subtitle-1 font-weight-bold">{{ plan.name }}</h3> </div>
<VChip
:color="isInStock(plan) ? 'success' : 'error'"
size="small"
variant="tonal"
>
{{ isInStock(plan) ? 'In Stock' : 'Sold Out' }}
</VChip>
</div>
<p class="text-caption text-medium-emphasis mb-3">{{ getFeature(plan, 'storage_bays') }}</p> <div v-show="filter === 'all' || filter === 'in-stock'">
<h3 class="text-h6 font-weight-bold mb-4 chassis-grid-heading">
<!-- Price --> In stock rack inventory (12th/13th gen)
<div class="mb-4"> <span class="text-caption text-medium-emphasis ms-2">
<span class="text-h4 font-weight-bold" :class="isInStock(plan) ? 'text-success' : 'text-medium-emphasis'">{{ formatPrice(plan) }}</span> Ready to ship in 1-2 business days · no setup fee
<span class="text-body-2 text-medium-emphasis">/mo</span> </span>
</div> </h3>
<VRow>
<VDivider class="mb-4" /> <VCol
v-for="plan in inStockPlans"
<!-- Specs --> :key="plan.id"
<div class="mb-4"> cols="12"
<div class="d-flex align-center ga-2 py-1"> sm="6"
<VIcon icon="tabler-cpu" size="16" color="medium-emphasis" /> md="4"
<span class="text-body-2">{{ getFeature(plan, 'cpu') }}</span> lg="3"
</div> >
<div class="d-flex align-center ga-2 py-1"> <ChassisCard :plan="plan" />
<VIcon icon="tabler-topology-star-3" size="16" color="medium-emphasis" /> </VCol>
<span class="text-body-2">{{ getFeature(plan, 'cores') }}</span> </VRow>
</div> </div>
<div class="d-flex align-center ga-2 py-1">
<VIcon icon="tabler-database" size="16" color="medium-emphasis" />
<span class="text-body-2">{{ getFeature(plan, 'ram') }} RAM</span>
</div>
<div class="d-flex align-center ga-2 py-1">
<VIcon icon="tabler-server" size="16" color="medium-emphasis" />
<span class="text-body-2">{{ getFeature(plan, 'storage_bays') }}</span>
</div>
</div>
<!-- Order Button -->
<a
v-if="isInStock(plan)"
:href="accountUrl + '/checkout/' + plan.id"
class="text-decoration-none d-block"
>
<VBtn color="success" variant="tonal" block>
Order Now
</VBtn>
</a>
<VBtn
v-else
color="default"
variant="tonal"
block
disabled
>
Unavailable
</VBtn>
</VCardText>
</VCard>
</VCol>
</VRow>
</VContainer> </VContainer>
</div> </div>
<!-- Included With Every Server --> <!-- Comparison -->
<VContainer class="marketing-section"> <VContainer class="marketing-section">
<SectionHeader <SectionHeader
label="Included" label="Comparison"
label-color="success" title="EZSCALE vs the budget-bundler shortcut"
title="Included With Every Server" highlight-word="EZSCALE"
highlight-word="Every" subtitle="A few honest differences against the cheapest dual-Xeon dedicated providers in our market band."
subtitle="No hidden fees. All servers come with these essentials."
/> />
<VRow justify="center"> <ComparisonTable :rows="comparisonRows" their-label="Typical budget-bundler" />
<VCol v-for="item in included" :key="item.label" cols="6" sm="4" md="2">
<div class="text-center">
<VAvatar color="success" variant="tonal" size="44" class="mb-3">
<VIcon :icon="item.icon" size="22" />
</VAvatar>
<p class="text-body-2 font-weight-medium mb-0">{{ item.label }}</p>
</div>
</VCol>
</VRow>
</VContainer> </VContainer>
<!-- FAQ -->
<div class="section-alt-bg marketing-section">
<VContainer>
<SectionHeader
label="FAQ"
title="Common questions"
highlight-word="questions"
subtitle="Hardware, setup fees, lead times, and the things people actually ask before signing up."
/>
<Faq :items="faqs" />
</VContainer>
</div>
<!-- CTA --> <!-- CTA -->
<div class="marketing-section" style="background: linear-gradient(135deg, rgb(var(--v-theme-success), 0.12), rgb(var(--v-theme-surface)));"> <div class="marketing-section" style="background: linear-gradient(135deg, rgb(var(--v-theme-primary), 0.12), rgb(var(--v-theme-surface)));">
<VContainer class="text-center"> <VContainer class="text-center">
<h2 class="text-h4 font-weight-bold mb-3">Need a Custom Configuration?</h2> <h2 class="text-h4 font-weight-bold mb-3">Need something custom?</h2>
<p class="text-body-1 text-medium-emphasis mb-6"> <p class="text-body-1 text-medium-emphasis mb-6">
Contact us for custom builds, bulk orders, or servers with specific hardware requirements. If your workload doesn't fit the catalog colocation, custom GPU, multi-server clusters talk to us. We build to spec.
</p> </p>
<div class="d-flex ga-3 justify-center flex-wrap"> <div class="d-flex ga-3 justify-center flex-wrap">
<a :href="plans.length ? accountUrl + '/checkout/' + plans[0].id : '/pricing'" class="text-decoration-none"> <a href="#chassis-grid" class="text-decoration-none">
<VBtn color="success" size="large" rounded="lg"> <VBtn color="primary" size="large" rounded="lg">
Get Started Browse catalog
<VIcon icon="tabler-arrow-right" end /> <VIcon icon="tabler-arrow-up" end />
</VBtn> </VBtn>
</a> </a>
<a href="/contact" class="text-decoration-none"> <a href="/contact" class="text-decoration-none">
<VBtn color="success" variant="outlined" size="large" rounded="lg"> <VBtn color="primary" variant="outlined" size="large" rounded="lg">
Contact Sales Talk to a Human
</VBtn> </VBtn>
</a> </a>
</div> </div>
@@ -244,7 +293,50 @@ function formatPrice(plan: Plan): string {
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.server-card-unavailable { .chassis-grid-heading {
opacity: 0.7; letter-spacing: -0.01em;
color: rgba(var(--v-theme-on-surface), 0.85);
}
.hardware-band {
display: flex;
align-items: center;
gap: 18px;
flex-wrap: wrap;
padding: 14px 20px;
border-radius: 12px;
background: rgba(var(--v-theme-surface), 0.45);
border: 1px solid rgba(var(--v-theme-on-surface), 0.06);
&__label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: rgba(var(--v-theme-on-surface), 0.5);
white-space: nowrap;
}
&__list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-wrap: wrap;
gap: 18px;
flex-grow: 1;
li {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: rgba(var(--v-theme-on-surface), 0.78);
.v-icon {
color: rgb(var(--v-theme-primary));
}
}
}
} }
</style> </style>

View File

@@ -0,0 +1,252 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
export interface DedicatedPlan {
id: number
slug: string
name: string
price: string
setup_fee?: string | number
features: Record<string, string | number> | null
prices?: Array<{ billing_cycle: string; price: string }>
}
export interface DedicatedConfigValue {
id: number
label: string
value: string
monthly_price: string
quarterly_price: string
semi_annual_price: string
annual_price: string
is_default: boolean
}
export interface DedicatedConfigOption {
id: number
name: string
type: string
values: DedicatedConfigValue[]
}
export interface DedicatedConfigGroup {
id: number
name: string
description: string | null
options: DedicatedConfigOption[]
sort_order: number
}
export type DedicatedCycle = 'monthly' | 'quarterly' | 'semi_annual' | 'annual'
export const CYCLE_MONTHS: Record<DedicatedCycle, number> = {
monthly: 1,
quarterly: 3,
semi_annual: 6,
annual: 12,
}
const CYCLES_WITH_SETUP_FEE: DedicatedCycle[] = ['monthly', 'quarterly']
export const useDedicatedConfiguratorStore = defineStore('dedicatedConfigurator', () => {
const plan = ref<DedicatedPlan | null>(null)
const configGroups = ref<DedicatedConfigGroup[]>([])
const accountUrl = ref<string>('')
// selections: groupName → option value slug (e.g., {"Dedicated 14th Gen — RAM Upgrade": "64"})
const selections = ref<Record<string, string>>({})
const cycle = ref<DedicatedCycle>('monthly')
function init(catalog: {
plan: DedicatedPlan
configGroups: DedicatedConfigGroup[]
accountUrl: string
}): void {
plan.value = catalog.plan
configGroups.value = catalog.configGroups
accountUrl.value = catalog.accountUrl
// Seed selections with each group's default value (or first value if no default).
const seeded: Record<string, string> = {}
for (const group of catalog.configGroups) {
const opt = group.options[0]
if (!opt) continue
const def = opt.values.find(v => v.is_default) ?? opt.values[0]
if (def) seeded[group.name] = def.value
}
selections.value = seeded
}
function setSelection(groupName: string, value: string): void {
selections.value = { ...selections.value, [groupName]: value }
}
function findGroup(groupName: string): DedicatedConfigGroup | null {
return configGroups.value.find(g => g.name === groupName) ?? null
}
function findValue(groupName: string, valueSlug: string): DedicatedConfigValue | null {
const g = findGroup(groupName)
if (!g || !g.options[0]) return null
return g.options[0].values.find(v => v.value === valueSlug) ?? null
}
function pickCyclePrice(value: DedicatedConfigValue, c: DedicatedCycle): number {
const raw = c === 'monthly'
? value.monthly_price
: c === 'quarterly'
? value.quarterly_price
: c === 'semi_annual'
? value.semi_annual_price
: value.annual_price
return raw ? parseFloat(raw) : 0
}
function planPriceForCycle(c: DedicatedCycle): number {
if (!plan.value) return 0
if (plan.value.prices && plan.value.prices.length > 0) {
const pp = plan.value.prices.find(p => p.billing_cycle === c)
if (pp) return parseFloat(pp.price)
}
return parseFloat(plan.value.price) * CYCLE_MONTHS[c]
}
const baselinePrice = computed<number>(() => planPriceForCycle(cycle.value))
const addOnsTotal = computed<number>(() => {
let total = 0
for (const [groupName, valueSlug] of Object.entries(selections.value)) {
const v = findValue(groupName, valueSlug)
if (v) total += pickCyclePrice(v, cycle.value)
}
return total
})
const setupFee = computed<number>(() => {
if (!plan.value) return 0
const fee = parseFloat(String(plan.value.setup_fee ?? 0))
if (fee <= 0) return 0
return CYCLES_WITH_SETUP_FEE.includes(cycle.value) ? fee : 0
})
const cycleSubtotal = computed<number>(() => baselinePrice.value + addOnsTotal.value)
const cycleTotal = computed<number>(() => cycleSubtotal.value + setupFee.value)
const monthlyEffective = computed<number>(() => cycleSubtotal.value / CYCLE_MONTHS[cycle.value])
const isSetupFeeWaived = computed<boolean>(() => {
if (!plan.value) return true
const fee = parseFloat(String(plan.value.setup_fee ?? 0))
if (fee <= 0) return true
return !CYCLES_WITH_SETUP_FEE.includes(cycle.value)
})
// Build the share URL with all current selections + cycle as query params.
// Param keys are short and readable so URLs stay shareable.
const shareUrl = computed<string>(() => {
if (!plan.value) return ''
const params = new URLSearchParams()
if (cycle.value !== 'monthly') params.set('cycle', cycle.value)
for (const [groupName, valueSlug] of Object.entries(selections.value)) {
const param = groupNameToParam(groupName)
if (!param) continue
const g = findGroup(groupName)
const def = g?.options[0]?.values.find(v => v.is_default)?.value
// Only add to URL if non-default
if (def && valueSlug === def) continue
params.set(param, valueSlug)
}
const qs = params.toString()
if (typeof window === 'undefined') return qs ? `/dedicated-servers/${plan.value.slug}?${qs}` : `/dedicated-servers/${plan.value.slug}`
return qs ? `${window.location.origin}${window.location.pathname}?${qs}` : `${window.location.origin}${window.location.pathname}`
})
const checkoutUrl = computed<string>(() => {
if (!plan.value) return ''
const params = new URLSearchParams()
if (cycle.value !== 'monthly') params.set('cycle', cycle.value)
for (const [groupName, valueSlug] of Object.entries(selections.value)) {
const param = groupNameToParam(groupName)
if (!param) continue
const g = findGroup(groupName)
const def = g?.options[0]?.values.find(v => v.is_default)?.value
if (def && valueSlug === def) continue
params.set(param, valueSlug)
}
const qs = params.toString()
const base = `${accountUrl.value}/checkout/${plan.value.id}`
return qs ? `${base}?${qs}` : base
})
function groupNameToParam(groupName: string): string | null {
const map: Record<string, string> = {
'Dedicated 14th Gen — CPU Upgrade': 'cpu',
'Dedicated 14th Gen — CPU Upgrade (R740xd)': 'cpu',
'Dedicated 14th Gen — RAM Upgrade': 'ram',
'Dedicated 14th Gen — Operating System': 'os',
'Dedicated 14th Gen — Bandwidth': 'bw',
'Dedicated 14th Gen — IPv4 Block': 'ipv4',
}
return map[groupName] ?? null
}
function paramToGroupName(param: string): string[] {
// Returns matching group names for a query param. Some params (cpu) map to
// multiple groups depending on chassis (CPU vs CPU R740xd) — we set on
// every matching group, the visible one (only one is attached) wins.
const map: Record<string, string[]> = {
cpu: ['Dedicated 14th Gen — CPU Upgrade', 'Dedicated 14th Gen — CPU Upgrade (R740xd)'],
ram: ['Dedicated 14th Gen — RAM Upgrade'],
os: ['Dedicated 14th Gen — Operating System'],
bw: ['Dedicated 14th Gen — Bandwidth'],
ipv4: ['Dedicated 14th Gen — IPv4 Block'],
}
return map[param] ?? []
}
function hydrateFromUrl(search: string): void {
const p = new URLSearchParams(search)
const c = p.get('cycle')
if (c && (['monthly', 'quarterly', 'semi_annual', 'annual'] as const).includes(c as DedicatedCycle)) {
cycle.value = c as DedicatedCycle
}
for (const param of ['cpu', 'ram', 'os', 'bw', 'ipv4']) {
const v = p.get(param)
if (!v) continue
for (const groupName of paramToGroupName(param)) {
const group = findGroup(groupName)
if (!group) continue
const validValue = group.options[0]?.values.find(val => val.value === v)
if (validValue) {
selections.value[groupName] = v
}
}
}
}
return {
plan,
configGroups,
accountUrl,
selections,
cycle,
baselinePrice,
addOnsTotal,
setupFee,
cycleSubtotal,
cycleTotal,
monthlyEffective,
isSetupFeeWaived,
shareUrl,
checkoutUrl,
init,
setSelection,
hydrateFromUrl,
findGroup,
findValue,
pickCyclePrice,
}
})