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:
2026-04-26 18:19:04 -04:00
parent 017b8b54c1
commit be3eaba2a1
5 changed files with 513 additions and 151 deletions

View File

@@ -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);

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,24 +105,43 @@ 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">
<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 {