feat(vps): add interactive estimator + refresh Included card

Implements the design captured in
docs/superpowers/specs/2026-04-26-vps-hosting-estimator-design.md.

Estimator (after the hero on /vps-hosting):
- Workload picker (9 chips) recommends a plan; "Not sure" opens a
  mini-quiz with a 12-app catalog and a traffic+priority follow-up.
- Recommended-plan card with "or pick another" alternates dropdown.
- Add-ons panel: IPv4 stepper (1-8, $8/extra), Windows BYOL toggle,
  4-tier Managed Support radio (Self/Basic/Pro/Pilot @ $0/29/79/99),
  5-tier Off-site Backup radio (None/Lite/Standard/Extended/Vault @
  $0/5/12/25/59).
- Pilot tier gated to VPS-8+ via plan.features.tier; auto-fallback to
  Pro on plan downgrade with snackbar warning.
- Billing cycle toggle (Monthly / Quarterly / Annual) reuses
  per-cycle prices already on plan_prices and plan_config_values.
- Sticky footer with live total, "Order this configuration"
  (deep-links to /checkout/{plan} with all params), and "Copy share
  link" (history.replaceState debounced 300ms).
- Plans-table rows get an "Estimate →" link that pre-fills the
  estimator with that plan and scrolls up.

Backend:
- PlanSeeder: each VPS plan gets features.tier (1-32) for gating.
- ConfigOptionSeeder: scope existing Server Management group to
  dedicated only; add VPS Managed Support and Off-site Backup
  groups with full per-cycle prices.
- routes/marketing.php /vps-hosting: pass addOns + workloadMap +
  appExamples Inertia props.
- CheckoutController::show: build prefilledSelections from
  ?ipv4&windows&managed&backup query params; Vue page hydrates
  configSelections from this prop.

Included With All Plans: rewritten to 13 accurate items with
per-line wording (10 Gbps fair-use uplink, ZFS snapshots free,
KVM virtualization, rDNS/PTR control, OOB console/VNC, 99.9%
SLA, etc.) plus a "Coming soon" badge for DDoS protection.

Tests: 10 Pest feature tests in tests/Feature/Marketing/
VpsHostingEstimatorTest.php cover the page props, both new
seeded groups, plan-tier metadata, Server Management dedicated
scope, configGroups attachment, and checkout query-param
pre-fill round-trip. All 10 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-26 16:05:01 -04:00
parent d5f97d1240
commit cfa2e4c8d3
39 changed files with 2258 additions and 97 deletions

View File

@@ -5,21 +5,17 @@ import MarketingLayout from '@/Layouts/MarketingLayout.vue'
import SectionHeader from '@/Components/Marketing/SectionHeader.vue'
import HeroSection from '@/Components/Marketing/HeroSection.vue'
import VpsHero from '@/Components/Marketing/VpsHero.vue'
import ScrollReveal from '@/Components/Marketing/ScrollReveal.vue'
import EstimatorSection from '@/Components/Marketing/Estimator/EstimatorSection.vue'
import { useEstimatorStore, type EstimatorPlan, type EstimatorAddOnGroup, type WorkloadEntry, type AppExample } from '@/stores/estimator'
import { crossDomainUrl } from '@/utils/resolvers'
defineOptions({ layout: MarketingLayout })
interface Plan {
id: number
name: string
slug: string
price: string
features: Record<string, string | number> | null
stock_quantity: number | null
}
interface PageProps {
plans: Plan[]
plans: EstimatorPlan[]
addOns: EstimatorAddOnGroup[]
workloadMap: Record<string, WorkloadEntry>
appExamples: AppExample[]
domains: { marketing: string; account: string; admin: string }
}
@@ -29,35 +25,51 @@ interface Feature {
description: string
}
interface IncludedItem {
text: string
comingSoon?: boolean
}
const page = usePage()
const props = computed(() => page.props as unknown as PageProps)
const accountUrl = computed<string>(() => `https://${props.value.domains?.account}`)
const plans = computed(() => props.value.plans || [])
const accountUrl = computed<string>(() => crossDomainUrl(props.value.domains?.account))
const plans = computed<EstimatorPlan[]>(() => props.value.plans || [])
const addOns = computed<EstimatorAddOnGroup[]>(() => props.value.addOns || [])
const workloadMap = computed<Record<string, WorkloadEntry>>(() => props.value.workloadMap || {})
const appExamples = computed<AppExample[]>(() => props.value.appExamples || [])
const estimator = useEstimatorStore()
const startingPrice = computed<string>(() => {
if (plans.value.length === 0) return '3.50'
if (plans.value.length === 0) return '5.00'
const lowest = Math.min(...plans.value.map(p => parseFloat(p.price)))
return lowest % 1 === 0 ? lowest.toString() : lowest.toFixed(2)
})
const features: Feature[] = [
{ icon: 'tabler-database', title: 'RAID 10 SSD Storage', description: 'Redundant SSD arrays for fast read/write speeds and data protection.' },
{ icon: 'tabler-shield-check', title: 'DDoS Protection', description: 'Enterprise-grade protection against volumetric attacks.' },
{ icon: 'tabler-rocket', title: 'Instant Provisioning', description: 'Your server is deployed within seconds of ordering.' },
{ icon: 'tabler-refresh', title: 'VM Backups', description: 'Built-in VM backup and snapshot functionality.' },
{ icon: 'tabler-server', title: 'KVM Virtualization', description: 'Full hardware virtualization for predictable, dedicated performance.' },
{ icon: 'tabler-rocket', title: 'Near-Instant Provisioning', description: 'Your VPS is deployed seconds after ordering.' },
{ icon: 'tabler-refresh', title: 'Free ZFS Snapshots', description: 'Built-in snapshots for quick rollbacks. Off-site backup add-ons available.' },
{ icon: 'tabler-terminal', title: 'Full Root Access', description: 'Complete control over your server environment.' },
{ icon: 'tabler-server', title: 'VirtFusion Panel', description: 'Powerful control panel for managing your VPS with ease.' },
{ icon: 'tabler-server', title: 'VirtFusion Panel', description: 'Out-of-band console + VNC access included.' },
]
const includedFeatures: string[] = [
'1 IPv4 & 1 /64 IPv6',
'Near instant provisioning',
'VM backups',
'Windows (BYOL) & Linux support',
'Full root access',
'VirtFusion control panel',
'RAID 10 backed storage',
'14-day money back guarantee',
const includedFeatures: IncludedItem[] = [
{ text: '1 free IPv4 + 1 /64 IPv6 block' },
{ text: 'IPv4 rDNS / PTR control' },
{ text: '10 Gbps shared uplink (fair-use per AUP)' },
{ text: 'RAID 10 SSD storage' },
{ text: 'ZFS storage snapshots (free)' },
{ text: 'KVM virtualization' },
{ text: 'Full root access' },
{ text: 'Linux & Windows (BYOL) support' },
{ text: 'VirtFusion control panel' },
{ text: 'Out-of-band console / VNC access' },
{ text: 'Near-instant provisioning' },
{ text: '99.9% uptime SLA' },
{ text: '14-day money-back guarantee' },
{ text: 'DDoS protection', comingSoon: true },
]
// Keys from features JSON that should not be shown as table columns
@@ -66,16 +78,28 @@ const internalKeys = new Set([
'os',
'ipv4',
'ipv6',
'tier',
])
function getFeature(plan: Plan, key: string): string {
function getFeature(plan: EstimatorPlan, key: string): string {
return String(plan.features?.[key] ?? '-')
}
function formatPrice(plan: Plan): string {
function formatPrice(plan: EstimatorPlan): string {
const price = parseFloat(plan.price) || 0
return price % 1 === 0 ? `$${price}` : `$${price.toFixed(2)}`
}
function prefillEstimator(planId: number): void {
estimator.setPlan(planId)
if (typeof window !== 'undefined') {
const url = estimator.shareUrl
window.history.replaceState({}, '', url)
// Smooth-scroll up to the estimator
const el = document.querySelector('.estimator-section')
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}
</script>
<template>
@@ -107,6 +131,15 @@ function formatPrice(plan: Plan): string {
</template>
</HeroSection>
<!-- Estimator -->
<EstimatorSection
:plans="plans"
:add-ons="addOns"
:workload-map="workloadMap"
:app-examples="appExamples"
:account-url="accountUrl"
/>
<!-- Features -->
<VContainer class="marketing-section">
<SectionHeader
@@ -163,9 +196,20 @@ function formatPrice(plan: Plan): string {
<td>{{ getFeature(plan, 'bandwidth') }}</td>
<td class="text-primary font-weight-bold">{{ formatPrice(plan) }}/mo</td>
<td>
<a :href="accountUrl + '/checkout/' + plan.id" class="text-decoration-none">
<VBtn color="primary" size="small" variant="tonal">Order Now</VBtn>
</a>
<div class="d-flex align-center ga-2">
<a :href="accountUrl + '/checkout/' + plan.id" class="text-decoration-none">
<VBtn color="primary" size="small" variant="tonal">Order Now</VBtn>
</a>
<VBtn
size="small"
variant="text"
color="primary"
class="text-caption"
@click="prefillEstimator(plan.id)"
>
Estimate
</VBtn>
</div>
</td>
</tr>
</tbody>
@@ -179,14 +223,28 @@ function formatPrice(plan: Plan): string {
<VRow>
<VCol
v-for="item in includedFeatures"
:key="item"
:key="item.text"
cols="12"
sm="6"
md="4"
>
<div class="d-flex align-center ga-2 mb-2">
<VIcon icon="tabler-circle-check" color="success" size="20" />
<span class="text-body-1">{{ item }}</span>
<VIcon
:icon="item.comingSoon ? 'tabler-clock' : 'tabler-circle-check'"
:color="item.comingSoon ? 'warning' : 'success'"
size="20"
/>
<span class="text-body-1">{{ item.text }}</span>
<VChip
v-if="item.comingSoon"
size="x-small"
color="warning"
variant="tonal"
density="compact"
class="ms-1"
>
Coming soon
</VChip>
</div>
</VCol>
</VRow>