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>
305 lines
9.2 KiB
TypeScript
305 lines
9.2 KiB
TypeScript
import { defineStore } from 'pinia'
|
|
import { computed, ref } from 'vue'
|
|
|
|
export interface EstimatorPlan {
|
|
id: number
|
|
slug: string
|
|
name: string
|
|
price: string
|
|
features: Record<string, string | number> | null
|
|
prices?: Array<{ billing_cycle: string; price: string }>
|
|
}
|
|
|
|
export interface EstimatorConfigValue {
|
|
id: number
|
|
label: string
|
|
value: string
|
|
monthly_price: string
|
|
quarterly_price: string
|
|
semi_annual_price: string
|
|
annual_price: string
|
|
}
|
|
|
|
export interface EstimatorConfigOption {
|
|
id: number
|
|
name: string
|
|
type: string
|
|
monthly_price: string | null
|
|
quarterly_price: string | null
|
|
semi_annual_price: string | null
|
|
annual_price: string | null
|
|
min_qty: number | null
|
|
max_qty: number | null
|
|
values: EstimatorConfigValue[]
|
|
}
|
|
|
|
export interface EstimatorAddOnGroup {
|
|
id: number
|
|
name: string
|
|
options: EstimatorConfigOption[]
|
|
}
|
|
|
|
export interface WorkloadEntry {
|
|
default: string | null
|
|
alternates: string[]
|
|
label: string
|
|
icon: string
|
|
}
|
|
|
|
export interface AppExample {
|
|
key: string
|
|
label: string
|
|
plan: string | null
|
|
icon: string
|
|
}
|
|
|
|
export type ManagedTier = 'self' | 'basic' | 'pro' | 'pilot'
|
|
export type BackupTier = 'none' | 'lite' | 'standard' | 'extended' | 'vault'
|
|
export type EstimatorCycle = 'monthly' | 'quarterly' | 'annual'
|
|
|
|
export const MIN_PILOT_TIER = 8
|
|
export const PILOT_FALLBACK_TIER: ManagedTier = 'pro'
|
|
|
|
export const CYCLE_MONTHS: Record<EstimatorCycle, number> = {
|
|
monthly: 1,
|
|
quarterly: 3,
|
|
annual: 12,
|
|
}
|
|
|
|
export const useEstimatorStore = defineStore('estimator', () => {
|
|
const plans = ref<EstimatorPlan[]>([])
|
|
const addOns = ref<EstimatorAddOnGroup[]>([])
|
|
const workloadMap = ref<Record<string, WorkloadEntry>>({})
|
|
const appExamples = ref<AppExample[]>([])
|
|
const accountUrl = ref<string>('')
|
|
|
|
const workload = ref<string | null>(null)
|
|
const planId = ref<number | null>(null)
|
|
const ipv4Count = ref<number>(1)
|
|
const windowsLicense = ref<boolean>(false)
|
|
const managedTier = ref<ManagedTier>('self')
|
|
const backupTier = ref<BackupTier>('none')
|
|
const cycle = ref<EstimatorCycle>('monthly')
|
|
|
|
function init(catalog: {
|
|
plans: EstimatorPlan[]
|
|
addOns: EstimatorAddOnGroup[]
|
|
workloadMap: Record<string, WorkloadEntry>
|
|
appExamples: AppExample[]
|
|
accountUrl: string
|
|
}): void {
|
|
plans.value = catalog.plans
|
|
addOns.value = catalog.addOns
|
|
workloadMap.value = catalog.workloadMap
|
|
appExamples.value = catalog.appExamples
|
|
accountUrl.value = catalog.accountUrl
|
|
}
|
|
|
|
function planBySlug(slug: string): EstimatorPlan | undefined {
|
|
return plans.value.find(p => p.slug === slug)
|
|
}
|
|
|
|
function planById(id: number): EstimatorPlan | undefined {
|
|
return plans.value.find(p => p.id === id)
|
|
}
|
|
|
|
const selectedPlan = computed<EstimatorPlan | null>(() => {
|
|
if (planId.value === null) return null
|
|
return planById(planId.value) ?? null
|
|
})
|
|
|
|
const selectedPlanTier = computed<number>(() => {
|
|
const tier = selectedPlan.value?.features?.tier
|
|
return typeof tier === 'number' ? tier : 0
|
|
})
|
|
|
|
const pilotAvailable = computed<boolean>(() => selectedPlanTier.value >= MIN_PILOT_TIER)
|
|
|
|
const recommendedPlanId = computed<number | null>(() => {
|
|
if (!workload.value) return null
|
|
const entry = workloadMap.value[workload.value]
|
|
if (!entry?.default) return null
|
|
return planBySlug(entry.default)?.id ?? null
|
|
})
|
|
|
|
function findOption(groupName: string, optionName: string): EstimatorConfigOption | null {
|
|
for (const g of addOns.value) {
|
|
if (g.name !== groupName) continue
|
|
for (const o of g.options) {
|
|
if (o.name === optionName) return o
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
function findValue(groupName: string, optionName: string, valueSlug: string): EstimatorConfigValue | null {
|
|
const opt = findOption(groupName, optionName)
|
|
if (!opt) return null
|
|
return opt.values.find(v => v.value === valueSlug) ?? null
|
|
}
|
|
|
|
function pickCyclePrice(
|
|
monthly: string | null | undefined,
|
|
quarterly: string | null | undefined,
|
|
annual: string | null | undefined,
|
|
c: EstimatorCycle,
|
|
): number {
|
|
const raw = c === 'monthly' ? monthly : c === 'quarterly' ? quarterly : annual
|
|
return raw ? parseFloat(raw) : 0
|
|
}
|
|
|
|
function planPriceForCycle(plan: EstimatorPlan, c: EstimatorCycle): number {
|
|
if (plan.prices && plan.prices.length > 0) {
|
|
const pp = plan.prices.find(p => p.billing_cycle === c)
|
|
if (pp) return parseFloat(pp.price)
|
|
}
|
|
return parseFloat(plan.price) * CYCLE_MONTHS[c]
|
|
}
|
|
|
|
const ipv4ExtraPrice = computed<number>(() => {
|
|
const opt = findOption('VPS Add-ons', 'IPv4 Addresses')
|
|
if (!opt) return 0
|
|
const extras = Math.max(0, ipv4Count.value - 1)
|
|
const price = pickCyclePrice(opt.monthly_price, opt.quarterly_price, opt.annual_price, cycle.value)
|
|
return extras * price
|
|
})
|
|
|
|
const managedPrice = computed<number>(() => {
|
|
if (managedTier.value === 'self') return 0
|
|
const v = findValue('VPS Managed Support', 'Managed Support', managedTier.value)
|
|
if (!v) return 0
|
|
return pickCyclePrice(v.monthly_price, v.quarterly_price, v.annual_price, cycle.value)
|
|
})
|
|
|
|
const backupPrice = computed<number>(() => {
|
|
if (backupTier.value === 'none') return 0
|
|
const v = findValue('Off-site Backup', 'Backup Tier', backupTier.value)
|
|
if (!v) return 0
|
|
return pickCyclePrice(v.monthly_price, v.quarterly_price, v.annual_price, cycle.value)
|
|
})
|
|
|
|
const planCyclePrice = computed<number>(() => {
|
|
if (!selectedPlan.value) return 0
|
|
return planPriceForCycle(selectedPlan.value, cycle.value)
|
|
})
|
|
|
|
const cycleTotal = computed<number>(() =>
|
|
planCyclePrice.value + ipv4ExtraPrice.value + managedPrice.value + backupPrice.value,
|
|
)
|
|
|
|
const monthlyEffectiveTotal = computed<number>(() => cycleTotal.value / CYCLE_MONTHS[cycle.value])
|
|
|
|
function setWorkload(key: string): void {
|
|
workload.value = key
|
|
const recommended = recommendedPlanId.value
|
|
if (recommended !== null) planId.value = recommended
|
|
}
|
|
|
|
function setPlan(id: number): void {
|
|
planId.value = id
|
|
if (managedTier.value === 'pilot' && !pilotAvailable.value) {
|
|
managedTier.value = PILOT_FALLBACK_TIER
|
|
}
|
|
}
|
|
|
|
function setManagedTier(tier: ManagedTier): void {
|
|
if (tier === 'pilot' && !pilotAvailable.value) return
|
|
managedTier.value = tier
|
|
}
|
|
|
|
const shareUrl = computed<string>(() => {
|
|
const params = new URLSearchParams()
|
|
if (workload.value) params.set('w', workload.value)
|
|
if (planId.value !== null) params.set('plan', String(planId.value))
|
|
if (ipv4Count.value > 1) params.set('ipv4', String(ipv4Count.value))
|
|
if (windowsLicense.value) params.set('windows', '1')
|
|
if (managedTier.value !== 'self') params.set('managed', managedTier.value)
|
|
if (backupTier.value !== 'none') params.set('backup', backupTier.value)
|
|
if (cycle.value !== 'monthly') params.set('cycle', cycle.value)
|
|
const qs = params.toString()
|
|
if (typeof window === 'undefined') return qs ? `/vps-hosting?${qs}` : '/vps-hosting'
|
|
return qs ? `${window.location.origin}${window.location.pathname}?${qs}` : `${window.location.origin}${window.location.pathname}`
|
|
})
|
|
|
|
const checkoutUrl = computed<string | null>(() => {
|
|
if (planId.value === null) return null
|
|
const params = new URLSearchParams()
|
|
if (ipv4Count.value > 1) params.set('ipv4', String(ipv4Count.value))
|
|
if (windowsLicense.value) params.set('windows', '1')
|
|
if (managedTier.value !== 'self') params.set('managed', managedTier.value)
|
|
if (backupTier.value !== 'none') params.set('backup', backupTier.value)
|
|
if (cycle.value !== 'monthly') params.set('cycle', cycle.value)
|
|
const qs = params.toString()
|
|
const base = `${accountUrl.value}/checkout/${planId.value}`
|
|
return qs ? `${base}?${qs}` : base
|
|
})
|
|
|
|
function hydrateFromUrl(search: string): void {
|
|
const p = new URLSearchParams(search)
|
|
|
|
const w = p.get('w')
|
|
if (w && workloadMap.value[w]) workload.value = w
|
|
|
|
const planParam = p.get('plan')
|
|
if (planParam) {
|
|
const id = parseInt(planParam, 10)
|
|
if (!Number.isNaN(id) && planById(id)) planId.value = id
|
|
}
|
|
|
|
const ipv4 = p.get('ipv4')
|
|
if (ipv4) {
|
|
const n = parseInt(ipv4, 10)
|
|
if (!Number.isNaN(n) && n >= 1 && n <= 8) ipv4Count.value = n
|
|
}
|
|
|
|
if (p.get('windows') === '1' || p.get('windows') === 'true') windowsLicense.value = true
|
|
|
|
const m = p.get('managed')
|
|
if (m && ['self', 'basic', 'pro', 'pilot'].includes(m)) managedTier.value = m as ManagedTier
|
|
|
|
const b = p.get('backup')
|
|
if (b && ['none', 'lite', 'standard', 'extended', 'vault'].includes(b)) backupTier.value = b as BackupTier
|
|
|
|
const c = p.get('cycle')
|
|
if (c && ['monthly', 'quarterly', 'annual'].includes(c)) cycle.value = c as EstimatorCycle
|
|
|
|
if (managedTier.value === 'pilot' && !pilotAvailable.value) {
|
|
managedTier.value = PILOT_FALLBACK_TIER
|
|
}
|
|
}
|
|
|
|
return {
|
|
plans,
|
|
addOns,
|
|
workloadMap,
|
|
appExamples,
|
|
accountUrl,
|
|
workload,
|
|
planId,
|
|
ipv4Count,
|
|
windowsLicense,
|
|
managedTier,
|
|
backupTier,
|
|
cycle,
|
|
selectedPlan,
|
|
selectedPlanTier,
|
|
pilotAvailable,
|
|
ipv4ExtraPrice,
|
|
managedPrice,
|
|
backupPrice,
|
|
planCyclePrice,
|
|
cycleTotal,
|
|
monthlyEffectiveTotal,
|
|
recommendedPlanId,
|
|
shareUrl,
|
|
checkoutUrl,
|
|
init,
|
|
setWorkload,
|
|
setPlan,
|
|
setManagedTier,
|
|
hydrateFromUrl,
|
|
findOption,
|
|
findValue,
|
|
}
|
|
})
|