feat(dedicated): build summary sidebar + expanded CPU/IPv4 options
CPU upgrade ladder (standard, R440/R540/R640/R740) now 6 tiers: - Gold 6230 (baseline, included) - Gold 6244 high-clock (16C / 3.6 GHz, +$25/mo) — for per-core licensed workloads (MS SQL / Oracle) and single-threaded compute - Gold 6248 (40C / 2.5 GHz, +$35/mo) - Gold 6230R sweet-spot (52C / 2.1 GHz, +$50/mo) — Cascade Lake Refresh of the 6230, more cores at same clock, bridges baseline and 6248R - Gold 6248R (48C / 3.0 GHz, +$75/mo) - Gold 6258R (56C / 2.7 GHz, +$145/mo) R740xd CPU ladder unchanged (6230 / 6248R / 6258R / Platinum 8280). IPv4 block options extended to /24: - /29 ($12) · /28 ($36) · /27 ($80) · /26 ($145) · /25 ($275) · /24 ($499). All blocks above /29 require ARIN justification — the group description explains the policy and each tier's label carries a "justification required" tag. Build summary sidebar replaces the bottom sticky footer on the per-chassis page. New 2-column layout (configurator left, summary right, sticky); collapses to single-column on tablet/mobile with the summary stacked above the configurator so total stays visible. The summary fixes the original "Total $468 billed monthly / includes $349 setup" wording confusion by splitting into clearly labeled sections: - RECURRING: per-line itemized breakdown (baseline + each upgrade with its actual cycle-priced cost), subtotal in /mo or /yr suffix - ONE-TIME: setup fee with non-refundable note (or strikethrough + "waived" badge when cycle is semi/annual) - TOTAL: "First invoice $X" + "Then $Y/mo recurring" framing on monthly/quarterly cycles; "Total due today" + renewal preview on semi/annual Removed: ConfiguratorFooter.vue (replaced by BuildSummary). Pinned to top via position:sticky with viewport-height clamp + internal scroll for tall configs. Order CTA + Copy share link moved into the summary card. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -358,7 +358,13 @@ class ConfigOptionSeeder extends Seeder
|
||||
$gen14CpuOption = $this->seedRadioOption($gen14CpuUpgrade, 'CPU Upgrade', false, 1);
|
||||
$this->seedValues($gen14CpuOption, [
|
||||
['label' => 'Baseline: 2× Xeon Gold 6230 (40C / 2.10 GHz)', 'value' => 'gold-6230-baseline', 'monthly' => 0, 'is_default' => true],
|
||||
// High-clock SKU for per-core licensed workloads (Microsoft SQL,
|
||||
// Oracle, single-threaded compute). Fewer cores, higher base clock.
|
||||
['label' => 'High-clock: 2× Xeon Gold 6244 (16C / 3.60 GHz)', 'value' => 'gold-6244', 'monthly' => 25.00],
|
||||
['label' => 'Upgrade: 2× Xeon Gold 6248 (40C / 2.50 GHz)', 'value' => 'gold-6248', 'monthly' => 35.00],
|
||||
// Cascade Lake Refresh of the 6230 — same gen, more cores at the
|
||||
// same clock. Sweet-spot mid-tier between baseline and 6248R.
|
||||
['label' => 'Mid-tier: 2× Xeon Gold 6230R (52C / 2.10 GHz)', 'value' => 'gold-6230r', 'monthly' => 50.00],
|
||||
['label' => 'Upgrade: 2× Xeon Gold 6248R (48C / 3.00 GHz)', 'value' => 'gold-6248r', 'monthly' => 75.00],
|
||||
['label' => 'Upgrade: 2× Xeon Gold 6258R (56C / 2.70 GHz)', 'value' => 'gold-6258r', 'monthly' => 145.00],
|
||||
]);
|
||||
@@ -469,7 +475,7 @@ class ConfigOptionSeeder extends Seeder
|
||||
$gen14Ipv4 = PlanConfigGroup::updateOrCreate(
|
||||
['name' => 'Dedicated 14th Gen — IPv4 Block'],
|
||||
[
|
||||
'description' => 'Additional IPv4 addresses beyond the 1 included with every server.',
|
||||
'description' => 'Additional IPv4 addresses beyond the 1 included with every server. Blocks of /28 or larger require ARIN justification — we provide the template; you fill in your use case before the IPs are allocated.',
|
||||
'mode' => 'preset',
|
||||
'service_type' => 'dedicated',
|
||||
'is_active' => true,
|
||||
@@ -478,11 +484,17 @@ class ConfigOptionSeeder extends Seeder
|
||||
);
|
||||
|
||||
$gen14Ipv4Option = $this->seedRadioOption($gen14Ipv4, 'IPv4 Block', false, 1);
|
||||
// ARIN justification policy applies to anything above /29; the group
|
||||
// description (above) tells customers about it, so we don't repeat
|
||||
// it on every tier label.
|
||||
$this->seedValues($gen14Ipv4Option, [
|
||||
['label' => '1 included (no extra)', 'value' => '1', 'monthly' => 0, 'is_default' => true],
|
||||
['label' => '/29 (5 usable)', 'value' => '5', 'monthly' => 12.00],
|
||||
['label' => '/28 (13 usable)', 'value' => '13', 'monthly' => 36.00],
|
||||
['label' => '/27 (29 usable)', 'value' => '29', 'monthly' => 80.00],
|
||||
['label' => '/28 (13 usable) — justification required', 'value' => '13', 'monthly' => 36.00],
|
||||
['label' => '/27 (29 usable) — justification required', 'value' => '29', 'monthly' => 80.00],
|
||||
['label' => '/26 (61 usable) — justification required', 'value' => '61', 'monthly' => 145.00],
|
||||
['label' => '/25 (125 usable) — justification required', 'value' => '125', 'monthly' => 275.00],
|
||||
['label' => '/24 (253 usable) — justification required', 'value' => '253', 'monthly' => 499.00],
|
||||
]);
|
||||
|
||||
$gen14Ipv4->plans()->syncWithoutDetaching($gen14AllPlans);
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import type { DedicatedConfigGroup, DedicatedCycle, DedicatedPlan } from '@/stores/dedicatedConfigurator'
|
||||
|
||||
interface Props {
|
||||
plan: DedicatedPlan
|
||||
configGroups: DedicatedConfigGroup[]
|
||||
selections: Record<string, string>
|
||||
cycle: DedicatedCycle
|
||||
baselinePrice: number
|
||||
cycleSubtotal: number
|
||||
setupFee: number
|
||||
baselineSetupFee: number
|
||||
isSetupFeeWaived: boolean
|
||||
monthlyEffective: number
|
||||
cycleTotal: number
|
||||
checkoutUrl: string
|
||||
shareUrl: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const cycleLabel: Record<DedicatedCycle, string> = {
|
||||
monthly: 'Monthly',
|
||||
quarterly: 'Quarterly (3 months)',
|
||||
semi_annual: 'Semi-Annual (6 months)',
|
||||
annual: 'Annual (12 months)',
|
||||
}
|
||||
|
||||
const cycleSuffix: Record<DedicatedCycle, string> = {
|
||||
monthly: '/mo',
|
||||
quarterly: '/qtr',
|
||||
semi_annual: '/6mo',
|
||||
annual: '/yr',
|
||||
}
|
||||
|
||||
interface LineItem {
|
||||
label: string
|
||||
detail: string
|
||||
amount: number
|
||||
isBaseline?: boolean
|
||||
}
|
||||
|
||||
const lineItems = computed<LineItem[]>(() => {
|
||||
const items: LineItem[] = []
|
||||
|
||||
// Always show the baseline first.
|
||||
items.push({
|
||||
label: props.plan.name,
|
||||
detail: 'Locked baseline build',
|
||||
amount: props.baselinePrice,
|
||||
isBaseline: true,
|
||||
})
|
||||
|
||||
// Then each selected upgrade — only if it has a non-zero contribution.
|
||||
for (const group of props.configGroups) {
|
||||
const valueSlug = props.selections[group.name]
|
||||
if (!valueSlug) continue
|
||||
|
||||
const opt = group.options[0]
|
||||
if (!opt) continue
|
||||
|
||||
const value = opt.values.find(v => v.value === valueSlug)
|
||||
if (!value) continue
|
||||
|
||||
const raw = props.cycle === 'monthly'
|
||||
? value.monthly_price
|
||||
: props.cycle === 'quarterly'
|
||||
? value.quarterly_price
|
||||
: props.cycle === 'semi_annual'
|
||||
? value.semi_annual_price
|
||||
: value.annual_price
|
||||
const cost = raw ? parseFloat(raw) : 0
|
||||
|
||||
if (cost === 0 && !value.is_default) {
|
||||
// Free non-default selection (e.g., choosing AlmaLinux 9 over "No OS")
|
||||
// — show as no-extra-cost row to confirm the choice.
|
||||
items.push({
|
||||
label: group.name.replace('Dedicated 14th Gen — ', ''),
|
||||
detail: value.label,
|
||||
amount: 0,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (cost === 0) continue // skip default-included rows
|
||||
|
||||
items.push({
|
||||
label: group.name.replace('Dedicated 14th Gen — ', ''),
|
||||
detail: value.label,
|
||||
amount: cost,
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
const copied = ref<boolean>(false)
|
||||
|
||||
async function copyShareLink(): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(props.shareUrl)
|
||||
} catch {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = props.shareUrl
|
||||
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)
|
||||
}
|
||||
|
||||
function formatPrice(amount: number): string {
|
||||
return amount.toFixed(2)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="build-summary" aria-label="Build summary">
|
||||
<div class="build-summary__head">
|
||||
<div class="text-overline text-medium-emphasis">Build summary</div>
|
||||
<h3 class="build-summary__title">{{ plan.name }}</h3>
|
||||
<div class="build-summary__cycle-row">
|
||||
<VIcon icon="tabler-calendar-month" size="14" class="me-1" />
|
||||
<span>{{ cycleLabel[cycle] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="build-summary__divider"></div>
|
||||
|
||||
<div class="build-summary__section">
|
||||
<div class="text-overline build-summary__section-label">Recurring</div>
|
||||
<div class="build-summary__lines">
|
||||
<div
|
||||
v-for="(item, idx) in lineItems"
|
||||
:key="idx"
|
||||
class="build-summary__line"
|
||||
:class="{ 'build-summary__line--baseline': item.isBaseline }"
|
||||
>
|
||||
<div class="build-summary__line-text">
|
||||
<div class="build-summary__line-label">{{ item.label }}</div>
|
||||
<div class="build-summary__line-detail">{{ item.detail }}</div>
|
||||
</div>
|
||||
<div class="build-summary__line-amount">
|
||||
<template v-if="item.isBaseline">${{ formatPrice(item.amount) }}</template>
|
||||
<template v-else-if="item.amount === 0">
|
||||
<span class="text-success">included</span>
|
||||
</template>
|
||||
<template v-else>+${{ formatPrice(item.amount) }}</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="build-summary__subtotal">
|
||||
<span class="build-summary__subtotal-label">Subtotal</span>
|
||||
<div class="build-summary__subtotal-amount">
|
||||
<span class="build-summary__subtotal-price">${{ formatPrice(cycleSubtotal) }}</span>
|
||||
<span class="build-summary__subtotal-suffix">{{ cycleSuffix[cycle] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="cycle !== 'monthly'" class="build-summary__effective">
|
||||
≈ ${{ formatPrice(monthlyEffective) }}/mo effective
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="baselineSetupFee > 0" class="build-summary__divider"></div>
|
||||
|
||||
<div v-if="baselineSetupFee > 0" class="build-summary__section">
|
||||
<div class="text-overline build-summary__section-label">One-time</div>
|
||||
<div class="build-summary__line">
|
||||
<div class="build-summary__line-text">
|
||||
<div class="build-summary__line-label">Setup fee</div>
|
||||
<div class="build-summary__line-detail">
|
||||
Hardware acquisition · charged on first invoice
|
||||
</div>
|
||||
</div>
|
||||
<div class="build-summary__line-amount">
|
||||
<template v-if="isSetupFeeWaived">
|
||||
<span class="build-summary__waived-amount">${{ formatPrice(baselineSetupFee) }}</span>
|
||||
<div class="text-caption text-success font-weight-bold">waived</div>
|
||||
</template>
|
||||
<template v-else>+${{ formatPrice(setupFee) }}</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!isSetupFeeWaived" class="build-summary__setup-note">
|
||||
<VIcon icon="tabler-info-circle" size="12" class="me-1" />
|
||||
Non-refundable once hardware is purchased. Switch to Semi-Annual or Annual to waive.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="build-summary__divider build-summary__divider--strong"></div>
|
||||
|
||||
<div class="build-summary__total">
|
||||
<div class="build-summary__total-label">
|
||||
<template v-if="cycle === 'monthly'">First invoice</template>
|
||||
<template v-else>Total due today</template>
|
||||
</div>
|
||||
<div class="build-summary__total-amount">
|
||||
${{ formatPrice(cycleTotal) }}
|
||||
</div>
|
||||
<div v-if="cycle === 'monthly' && !isSetupFeeWaived" class="build-summary__total-detail">
|
||||
Then ${{ formatPrice(cycleSubtotal) }}/mo recurring
|
||||
</div>
|
||||
<div v-else-if="cycle !== 'monthly'" class="build-summary__total-detail">
|
||||
Renews {{ cycleLabel[cycle].toLowerCase() }} at ${{ formatPrice(cycleSubtotal) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="build-summary__actions">
|
||||
<a :href="checkoutUrl" class="text-decoration-none">
|
||||
<VBtn block color="primary" size="large" rounded="lg" prepend-icon="tabler-shopping-cart">
|
||||
Order this build
|
||||
<VIcon icon="tabler-arrow-right" end />
|
||||
</VBtn>
|
||||
</a>
|
||||
<VBtn
|
||||
block
|
||||
:variant="copied ? 'flat' : 'outlined'"
|
||||
:color="copied ? 'success' : 'primary'"
|
||||
size="default"
|
||||
:prepend-icon="copied ? 'tabler-check' : 'tabler-link'"
|
||||
class="mt-2"
|
||||
@click="copyShareLink"
|
||||
>
|
||||
{{ copied ? 'Copied!' : 'Copy share link' }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.build-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 24px;
|
||||
border-radius: 18px;
|
||||
background: rgba(var(--v-theme-surface), 0.85);
|
||||
border: 1px solid rgba(var(--v-theme-primary), 0.22);
|
||||
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.25);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
position: sticky;
|
||||
top: 24px;
|
||||
max-height: calc(100vh - 48px);
|
||||
overflow-y: auto;
|
||||
|
||||
@media (max-width: 960px) {
|
||||
position: relative;
|
||||
top: auto;
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
|
||||
.build-summary__head {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.build-summary__title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
color: rgba(var(--v-theme-on-surface), 0.95);
|
||||
margin-bottom: 6px;
|
||||
margin-top: 4px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.build-summary__cycle-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: rgba(var(--v-theme-on-surface), 0.65);
|
||||
padding: 4px 10px;
|
||||
background: rgba(var(--v-theme-on-surface), 0.05);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.build-summary__divider {
|
||||
height: 1px;
|
||||
background: rgba(var(--v-theme-on-surface), 0.08);
|
||||
margin: 16px 0;
|
||||
|
||||
&--strong {
|
||||
background: rgba(var(--v-theme-on-surface), 0.18);
|
||||
margin: 18px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.build-summary__section-label {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.1em;
|
||||
color: rgba(var(--v-theme-on-surface), 0.5);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.build-summary__lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.build-summary__line {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.build-summary__line--baseline {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.build-summary__line-text {
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.build-summary__line-label {
|
||||
font-size: 13px;
|
||||
color: rgba(var(--v-theme-on-surface), 0.92);
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.build-summary__line-detail {
|
||||
font-size: 11px;
|
||||
color: rgba(var(--v-theme-on-surface), 0.55);
|
||||
margin-top: 1px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.build-summary__line-amount {
|
||||
font-size: 13px;
|
||||
text-align: end;
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex-shrink: 0;
|
||||
color: rgba(var(--v-theme-on-surface), 0.85);
|
||||
}
|
||||
|
||||
.build-summary__line--baseline .build-summary__line-amount {
|
||||
color: rgba(var(--v-theme-on-surface), 0.95);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.build-summary__subtotal {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px dashed rgba(var(--v-theme-on-surface), 0.08);
|
||||
}
|
||||
|
||||
.build-summary__subtotal-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: rgba(var(--v-theme-on-surface), 0.7);
|
||||
}
|
||||
|
||||
.build-summary__subtotal-amount {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.build-summary__subtotal-price {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: rgba(var(--v-theme-on-surface), 0.95);
|
||||
}
|
||||
|
||||
.build-summary__subtotal-suffix {
|
||||
font-size: 12px;
|
||||
color: rgba(var(--v-theme-on-surface), 0.6);
|
||||
}
|
||||
|
||||
.build-summary__effective {
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
color: rgba(var(--v-theme-on-surface), 0.55);
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.build-summary__waived-amount {
|
||||
text-decoration: line-through;
|
||||
color: rgba(var(--v-theme-on-surface), 0.45);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.build-summary__setup-note {
|
||||
margin-top: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
background: rgba(var(--v-theme-warning), 0.08);
|
||||
border: 1px solid rgba(var(--v-theme-warning), 0.2);
|
||||
font-size: 11px;
|
||||
color: rgba(var(--v-theme-on-surface), 0.7);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
line-height: 1.4;
|
||||
|
||||
.v-icon {
|
||||
color: rgb(var(--v-theme-warning));
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.build-summary__total {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.build-summary__total-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: rgba(var(--v-theme-on-surface), 0.7);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.build-summary__total-amount {
|
||||
font-size: 36px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1;
|
||||
color: rgb(var(--v-theme-primary));
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.build-summary__total-detail {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: rgba(var(--v-theme-on-surface), 0.6);
|
||||
}
|
||||
|
||||
.build-summary__actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
@@ -1,119 +0,0 @@
|
||||
<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>
|
||||
@@ -1,9 +1,8 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
import { 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
|
||||
@@ -15,8 +14,6 @@ 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
|
||||
@@ -78,20 +75,6 @@ watch(() => props.plan?.id, () => {
|
||||
@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>
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Head, usePage, Link } from '@inertiajs/vue3'
|
||||
import { computed } from 'vue'
|
||||
import MarketingLayout from '@/Layouts/MarketingLayout.vue'
|
||||
import DedicatedConfigurator from '@/Components/Marketing/Dedicated/DedicatedConfigurator/index.vue'
|
||||
import BuildSummary from '@/Components/Marketing/Dedicated/DedicatedConfigurator/BuildSummary.vue'
|
||||
import { useDedicatedConfiguratorStore } from '@/stores/dedicatedConfigurator'
|
||||
import { crossDomainUrl } from '@/utils/resolvers'
|
||||
import type { DedicatedPlan, DedicatedConfigGroup } from '@/stores/dedicatedConfigurator'
|
||||
|
||||
@@ -45,6 +47,10 @@ const formattedPrice = computed<string>(() => {
|
||||
const price = parseFloat(plan.value.price) || 0
|
||||
return price % 1 === 0 ? `${price}` : price.toFixed(2)
|
||||
})
|
||||
|
||||
// Pull live state from the store so the sticky summary stays in sync with
|
||||
// every selection change without prop-drilling through the configurator.
|
||||
const store = useDedicatedConfiguratorStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -99,23 +105,42 @@ const formattedPrice = computed<string>(() => {
|
||||
</div>
|
||||
</VContainer>
|
||||
|
||||
<!-- Configurator -->
|
||||
<!-- Configurator + sticky build summary -->
|
||||
<div class="section-alt-bg marketing-section">
|
||||
<VContainer>
|
||||
<div class="text-center mb-6">
|
||||
<VChip color="primary" variant="tonal" size="small" class="mb-2">Configure</VChip>
|
||||
<h2 class="text-h4 font-weight-bold mb-2">Build it your way</h2>
|
||||
<p class="text-body-1 text-medium-emphasis">
|
||||
Pick a billing cycle and customize the upgrades. Total updates live; share link copies the exact config.
|
||||
Pick a billing cycle and customize the upgrades. The build summary on the right updates as you go.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="detail-configurator-wrap">
|
||||
<DedicatedConfigurator
|
||||
:plan="plan"
|
||||
:config-groups="configGroups"
|
||||
:account-url="accountUrl"
|
||||
/>
|
||||
<div class="detail-grid">
|
||||
<div class="detail-grid__configurator">
|
||||
<DedicatedConfigurator
|
||||
:plan="plan"
|
||||
:config-groups="configGroups"
|
||||
:account-url="accountUrl"
|
||||
/>
|
||||
</div>
|
||||
<div class="detail-grid__summary">
|
||||
<BuildSummary
|
||||
:plan="plan"
|
||||
:config-groups="configGroups"
|
||||
:selections="store.selections"
|
||||
:cycle="store.cycle"
|
||||
:baseline-price="store.baselinePrice"
|
||||
:cycle-subtotal="store.cycleSubtotal"
|
||||
:setup-fee="store.setupFee"
|
||||
:baseline-setup-fee="setupFee"
|
||||
:is-setup-fee-waived="store.isSetupFeeWaived"
|
||||
:monthly-effective="store.monthlyEffective"
|
||||
:cycle-total="store.cycleTotal"
|
||||
:checkout-url="store.checkoutUrl"
|
||||
:share-url="store.shareUrl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</VContainer>
|
||||
</div>
|
||||
@@ -187,9 +212,24 @@ const formattedPrice = computed<string>(() => {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.detail-configurator-wrap {
|
||||
max-width: 880px;
|
||||
margin: 0 auto;
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 380px;
|
||||
gap: 32px;
|
||||
align-items: start;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-grid__summary {
|
||||
position: relative;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
order: -1; // on mobile, show summary above configurator so total is visible first
|
||||
}
|
||||
}
|
||||
|
||||
.detail-bay-card {
|
||||
|
||||
Reference in New Issue
Block a user