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:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
199
website/resources/ts/Pages/Marketing/DedicatedServerDetail.vue
Normal file
199
website/resources/ts/Pages/Marketing/DedicatedServerDetail.vue
Normal 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>
|
||||
@@ -1,12 +1,14 @@
|
||||
<script lang="ts" setup>
|
||||
import { Head, usePage } from '@inertiajs/vue3'
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import MarketingLayout from '@/Layouts/MarketingLayout.vue'
|
||||
import SectionHeader from '@/Components/Marketing/SectionHeader.vue'
|
||||
import HeroSection from '@/Components/Marketing/HeroSection.vue'
|
||||
import DedicatedHero from '@/Components/Marketing/DedicatedHero.vue'
|
||||
import ScrollReveal from '@/Components/Marketing/ScrollReveal.vue'
|
||||
import { crossDomainUrl } from '@/utils/resolvers'
|
||||
import ChassisCard from '@/Components/Marketing/Dedicated/ChassisCard.vue'
|
||||
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 })
|
||||
|
||||
@@ -15,6 +17,7 @@ interface Plan {
|
||||
name: string
|
||||
slug: string
|
||||
price: string
|
||||
setup_fee?: string | number
|
||||
features: Record<string, string | number> | null
|
||||
stock_quantity: number | null
|
||||
}
|
||||
@@ -26,39 +29,98 @@ interface PageProps {
|
||||
|
||||
const page = usePage()
|
||||
const props = computed(() => page.props as unknown as PageProps)
|
||||
const accountUrl = computed(() => crossDomainUrl(props.value.domains?.account))
|
||||
const plans = computed(() => props.value.plans || [])
|
||||
const plans = computed<Plan[]>(() => props.value.plans || [])
|
||||
|
||||
const features = [
|
||||
{ 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.' },
|
||||
{ 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.' },
|
||||
{ icon: 'tabler-clock', title: 'Same-Day Setup', description: 'Most in-stock servers deployed same-day, subject to availability.' },
|
||||
{ icon: 'tabler-headset', title: 'Expert Support', description: 'Experienced engineers ready to help with hardware and network issues via our ticket system.' },
|
||||
type Generation = 'all' | '14th-gen' | 'in-stock'
|
||||
|
||||
const filter = ref<Generation>('all')
|
||||
|
||||
const buildToOrderPlans = computed<Plan[]>(() =>
|
||||
plans.value.filter(p => p.features?.generation === '14th-gen'),
|
||||
)
|
||||
|
||||
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 = [
|
||||
{ icon: 'tabler-world', label: '10 TB Bandwidth' },
|
||||
{ icon: 'tabler-network', label: '1 Gbps Port' },
|
||||
{ icon: 'tabler-map-pin', label: 'Atlanta, GA Datacenter' },
|
||||
{ icon: 'tabler-address-book', label: '1 IPv4 Address' },
|
||||
{ icon: 'tabler-hexagons', label: '1x /64 IPv6 Subnet' },
|
||||
{ icon: 'tabler-dashboard', label: 'SynergyCP Panel' },
|
||||
const faqs = [
|
||||
{
|
||||
question: 'How long until my dedicated server is live?',
|
||||
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.',
|
||||
},
|
||||
{
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -67,17 +129,20 @@ function formatPrice(plan: Plan): string {
|
||||
<!-- Hero -->
|
||||
<HeroSection>
|
||||
<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">
|
||||
Bare Metal <span class="hero-gradient-text">Power</span>
|
||||
Bare-metal <span class="hero-gradient-text">Dell PowerEdge</span>
|
||||
</h1>
|
||||
<p class="text-h6 text-medium-emphasis font-weight-regular mb-8" style="max-width: 600px;">
|
||||
Enterprise-grade Dell PowerEdge servers with full root access, SynergyCP management, and same-day deployment from our Atlanta datacenter.
|
||||
<p class="text-h6 text-medium-emphasis font-weight-regular mb-4" style="max-width: 600px;">
|
||||
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>
|
||||
<a :href="plans.length ? accountUrl + '/checkout/' + plans[0].id : '/pricing'" class="text-decoration-none">
|
||||
<VBtn color="success" size="large" rounded="lg">
|
||||
Configure Server
|
||||
<VIcon icon="tabler-arrow-right" end />
|
||||
<p class="text-body-1 text-medium-emphasis mb-8">
|
||||
Starting at <span class="text-primary font-weight-bold">${{ startingPrice }}/mo</span>
|
||||
</p>
|
||||
<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>
|
||||
</a>
|
||||
</template>
|
||||
@@ -87,154 +152,138 @@ function formatPrice(plan: Plan): string {
|
||||
</template>
|
||||
</HeroSection>
|
||||
|
||||
<!-- Features -->
|
||||
<!-- Hardware band -->
|
||||
<VContainer class="marketing-section">
|
||||
<SectionHeader
|
||||
label="Features"
|
||||
label-color="success"
|
||||
title="Enterprise Hardware"
|
||||
highlight-word="Hardware"
|
||||
subtitle="Every dedicated server comes with these features included."
|
||||
/>
|
||||
<VRow>
|
||||
<VCol v-for="(feature, index) in features" :key="feature.title" cols="12" sm="6" md="4">
|
||||
<div :class="['d-flex ga-3 mb-4 feature-card-hover pa-3 rounded-lg', `fade-in-up stagger-${index + 1}`]">
|
||||
<VAvatar color="success" variant="tonal" size="44">
|
||||
<VIcon :icon="feature.icon" size="22" />
|
||||
</VAvatar>
|
||||
<div>
|
||||
<h3 class="text-subtitle-1 font-weight-bold">{{ feature.title }}</h3>
|
||||
<p class="text-body-2 text-medium-emphasis mb-0">{{ feature.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<div class="hardware-band mb-6" role="note" aria-label="Underlying hardware">
|
||||
<div class="hardware-band__label">Built on</div>
|
||||
<ul class="hardware-band__list">
|
||||
<li>
|
||||
<VIcon icon="tabler-server-2" size="16" />
|
||||
<span>Dell PowerEdge</span>
|
||||
</li>
|
||||
<li>
|
||||
<VIcon icon="tabler-cpu" size="16" />
|
||||
<span>Intel Xeon Gold (Cascade Lake)</span>
|
||||
</li>
|
||||
<li>
|
||||
<VIcon icon="tabler-shield-bolt" size="16" />
|
||||
<span>DDR4-2400 ECC</span>
|
||||
</li>
|
||||
<li>
|
||||
<VIcon icon="tabler-database" size="16" />
|
||||
<span>BOSS M.2 boot · all bays free for data</span>
|
||||
</li>
|
||||
<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>
|
||||
|
||||
<!-- Server Configurations -->
|
||||
<div class="section-alt-bg marketing-section">
|
||||
<!-- Chassis grid -->
|
||||
<div id="chassis-grid" class="section-alt-bg marketing-section">
|
||||
<VContainer>
|
||||
<SectionHeader
|
||||
label="Servers"
|
||||
label-color="success"
|
||||
title="Server Configurations"
|
||||
highlight-word="Configurations"
|
||||
subtitle="Real Dell PowerEdge servers. Storage sold separately -- configure your drives at checkout."
|
||||
label="Lineup"
|
||||
title="Pick your chassis"
|
||||
highlight-word="chassis"
|
||||
subtitle="Build to order from our 14th-gen catalog, or rent a ready-to-deploy server from rack inventory."
|
||||
/>
|
||||
|
||||
<VRow>
|
||||
<VCol v-for="(plan, index) in plans" :key="plan.id" cols="12" sm="6" lg="3">
|
||||
<VCard
|
||||
variant="outlined"
|
||||
:class="['h-100 feature-card-hover', `fade-in-up stagger-${Math.min(index + 1, 8)}`, { 'server-card-unavailable': !isInStock(plan) }]"
|
||||
<div class="d-flex justify-center mb-6">
|
||||
<GenerationFilter v-model="filter" :counts="counts" />
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<!-- Header with model and stock status -->
|
||||
<div class="d-flex align-center justify-space-between mb-1">
|
||||
<h3 class="text-subtitle-1 font-weight-bold">{{ plan.name }}</h3>
|
||||
<VChip
|
||||
:color="isInStock(plan) ? 'success' : 'error'"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ isInStock(plan) ? 'In Stock' : 'Sold Out' }}
|
||||
</VChip>
|
||||
</div>
|
||||
<ChassisCard :plan="plan" />
|
||||
</VCol>
|
||||
</VRow>
|
||||
</div>
|
||||
|
||||
<p class="text-caption text-medium-emphasis mb-3">{{ getFeature(plan, 'storage_bays') }}</p>
|
||||
|
||||
<!-- Price -->
|
||||
<div class="mb-4">
|
||||
<span class="text-h4 font-weight-bold" :class="isInStock(plan) ? 'text-success' : 'text-medium-emphasis'">{{ formatPrice(plan) }}</span>
|
||||
<span class="text-body-2 text-medium-emphasis">/mo</span>
|
||||
</div>
|
||||
|
||||
<VDivider class="mb-4" />
|
||||
|
||||
<!-- Specs -->
|
||||
<div class="mb-4">
|
||||
<div class="d-flex align-center ga-2 py-1">
|
||||
<VIcon icon="tabler-cpu" size="16" color="medium-emphasis" />
|
||||
<span class="text-body-2">{{ getFeature(plan, 'cpu') }}</span>
|
||||
</div>
|
||||
<div class="d-flex align-center ga-2 py-1">
|
||||
<VIcon icon="tabler-topology-star-3" size="16" color="medium-emphasis" />
|
||||
<span class="text-body-2">{{ getFeature(plan, 'cores') }}</span>
|
||||
</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>
|
||||
<div v-show="filter === 'all' || filter === 'in-stock'">
|
||||
<h3 class="text-h6 font-weight-bold mb-4 chassis-grid-heading">
|
||||
In stock — rack inventory (12th/13th gen)
|
||||
<span class="text-caption text-medium-emphasis ms-2">
|
||||
Ready to ship in 1-2 business days · no setup fee
|
||||
</span>
|
||||
</h3>
|
||||
<VRow>
|
||||
<VCol
|
||||
v-for="plan in inStockPlans"
|
||||
:key="plan.id"
|
||||
cols="12"
|
||||
sm="6"
|
||||
md="4"
|
||||
lg="3"
|
||||
>
|
||||
<ChassisCard :plan="plan" />
|
||||
</VCol>
|
||||
</VRow>
|
||||
</div>
|
||||
</VContainer>
|
||||
</div>
|
||||
|
||||
<!-- Included With Every Server -->
|
||||
<!-- Comparison -->
|
||||
<VContainer class="marketing-section">
|
||||
<SectionHeader
|
||||
label="Included"
|
||||
label-color="success"
|
||||
title="Included With Every Server"
|
||||
highlight-word="Every"
|
||||
subtitle="No hidden fees. All servers come with these essentials."
|
||||
label="Comparison"
|
||||
title="EZSCALE vs the budget-bundler shortcut"
|
||||
highlight-word="EZSCALE"
|
||||
subtitle="A few honest differences against the cheapest dual-Xeon dedicated providers in our market band."
|
||||
/>
|
||||
<VRow justify="center">
|
||||
<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>
|
||||
<ComparisonTable :rows="comparisonRows" their-label="Typical budget-bundler" />
|
||||
</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 -->
|
||||
<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">
|
||||
<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">
|
||||
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>
|
||||
<div class="d-flex ga-3 justify-center flex-wrap">
|
||||
<a :href="plans.length ? accountUrl + '/checkout/' + plans[0].id : '/pricing'" class="text-decoration-none">
|
||||
<VBtn color="success" size="large" rounded="lg">
|
||||
Get Started
|
||||
<VIcon icon="tabler-arrow-right" end />
|
||||
<a href="#chassis-grid" class="text-decoration-none">
|
||||
<VBtn color="primary" size="large" rounded="lg">
|
||||
Browse catalog
|
||||
<VIcon icon="tabler-arrow-up" end />
|
||||
</VBtn>
|
||||
</a>
|
||||
<a href="/contact" class="text-decoration-none">
|
||||
<VBtn color="success" variant="outlined" size="large" rounded="lg">
|
||||
Contact Sales
|
||||
<VBtn color="primary" variant="outlined" size="large" rounded="lg">
|
||||
Talk to a Human
|
||||
</VBtn>
|
||||
</a>
|
||||
</div>
|
||||
@@ -244,7 +293,50 @@ function formatPrice(plan: Plan): string {
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.server-card-unavailable {
|
||||
opacity: 0.7;
|
||||
.chassis-grid-heading {
|
||||
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>
|
||||
|
||||
252
website/resources/ts/stores/dedicatedConfigurator.ts
Normal file
252
website/resources/ts/stores/dedicatedConfigurator.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user