Files
website/website/resources/ts/stores/estimator.ts
Andrew cfa2e4c8d3 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>
2026-04-26 16:05:01 -04:00

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,
}
})