Design captured from brainstorming: workload-driven estimator with plan + add-on configurator (managed support + off-site backup tiers), URL state, share link, checkout pre-fill, and rewritten Included list. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
20 KiB
VPS Hosting Page — Estimator + Included List Refresh
Date: 2026-04-26
Status: Approved (brainstorm), pending implementation plan
Owner: Andrew
Repo: EZSCALE/website
Goals
- Add an interactive estimator to the
/vps-hostingpage that lets visitors price a configuration (workload → plan → add-ons) without entering the order/checkout flow. The estimator does eventually link to checkout with the configuration pre-applied, but the estimator itself is read-only and bookmarkable. - Rewrite the "Included With All Plans" card with accurate, expanded content and a "coming soon" badge for DDoS protection.
- Extend the add-on inventory with two new product lines: Managed Support tiers and Off-site Backup tiers.
Non-goals (future work)
- Implementing the StorJ-backed backup orchestration (the add-on selection is captured at checkout, but backup execution is separate work).
- Implementing DDoS protection.
- Replicating the estimator on
/dedicated-servers,/web-hosting,/game-servers. The same pattern can be applied later. - Save-and-email-quote flow.
- Admin UI for editing the workload map (it stays hardcoded in the route closure).
- Funnel analytics for estimator usage.
Page structure
After the change, /vps-hosting reads top-to-bottom:
1. Hero (unchanged)
2. EstimatorSection ← NEW
3. Features grid (unchanged)
4. Plans Table (unchanged + per-row "Pre-fill estimator" link)
5. Included With All Plans (rewritten content)
6. CTA (unchanged)
Estimator UX
Layout style
Progressive reveal on a single screen. No forced wizard steps.
┌─ Billing cycle: [ Monthly | Quarterly | Annual ] ┐
│ │
│ What are you running? │
│ [Personal site] [Web app] [Dev/staging] [Discord bot] │
│ [VPN/proxy] [Storage target] [Database] [CI/CD] [Not sure] │
│ │
│ ─── (revealed once a workload is picked) ─── │
│ │
│ Recommended: VPS-4 2 vCPU · 4 GB RAM · 80 GB SSD $15/mo │
│ [or pick another plan ▾] │
│ │
│ Customize add-ons ▾ (collapsed by default) │
│ │
│ ─── sticky footer ─── │
│ Your total: $XX/mo (billed annually) │
│ [Order this configuration] [Copy share link] │
└──────────────────────────────────────────────────────────────┘
Workload picker
9 chips. Clicking sets the workload and the recommended plan. "Not sure" opens the mini-quiz dialog instead.
Workload → plan mapping
| Workload key | Default plan | Alternates shown |
|---|---|---|
personal_site |
VPS-2 ($8) | VPS-1, VPS-4 |
saas |
VPS-4 ($15) | VPS-2, VPS-8 |
dev_staging |
VPS-2 ($8) | VPS-1, VPS-4 |
discord_bot |
VPS-1 ($5) | VPS-2 |
vpn |
VPS-1 ($5) | VPS-2 |
storage_target |
Storage 1TB ($28) | Storage 500, VPS-4 |
database |
VPS-16 ($55) | VPS-8, VPS-32 |
cicd |
VPS-8 ($30) | VPS-4, VPS-16 |
not_sure |
(mini-quiz) | — |
Mini-quiz (for not_sure)
Step 1 — closest match (always shown):
12 example apps in a card grid:
| App | Maps to |
|---|---|
| WordPress / static blog | VPS-1 |
| Ghost / Mastodon (small) | VPS-2 |
| Bitwarden / Vaultwarden | VPS-1 |
| Plex / Jellyfin | Storage 500 |
| Nextcloud / file sync | Storage 1TB |
| Gitea / self-hosted git | VPS-2 |
| Mattermost / chat | VPS-4 |
| Postgres / MySQL DB | VPS-16 |
| Mautic / marketing app | VPS-4 |
| ELK stack / logging | VPS-8 |
| Docker swarm / k3s | VPS-8 |
| Other / not listed | (Step 2) |
Step 2 — only if "Other" picked:
- Expected traffic?
Low (~100/day)/Medium (~10k/day)/High (~100k/day) - Most important?
Cheapest/Reliability/Speed/Storage-heavy
The quiz returns a recommended plan with one-line reasoning ("VPS-4 fits because medium traffic + reliability priority").
Add-ons panel
Collapsed by default; expands on click. Inside:
- IPv4 stepper — 1 to 8. First IPv4 is included; extras are $8/each.
- Windows License — toggle, free (BYOL).
- Managed Support — 4-option radio. Pilot disabled when plan is below VPS-8 (tooltip + "Upgrade plan" link).
- Off-site Backup — 5-option radio with retention/quota helper text.
Pilot tier gating
ManagedSupportSelector reads the current plan. If it is below VPS-8 (i.e., VPS-1, VPS-2, VPS-4, Storage 500, Storage 1TB), the Pilot radio is disabled with tooltip "Pilot requires VPS-8 or higher. [Upgrade plan]". Clicking the upgrade link sets the plan to VPS-8 and re-enables Pilot.
If the user already has Pilot selected and downgrades, the selection auto-falls back to Pro and a snackbar warns "Pilot tier removed — requires VPS-8 or larger."
Billing cycle handling
Three options: Monthly, Quarterly (5% discount), Annual (15% discount). Semi-annual is omitted from the toggle to keep it tidy; existing pricing-page math stays as-is.
The base plan and every add-on already have per-cycle prices in the database (plan_prices and plan_config_values rows). The estimator looks up the matching cycle row for each item and sums them — it does not multiply by a percentage. This guarantees parity with the pricing page and the actual checkout total.
URL state and sharing
On mount, the estimator parses the query string and hydrates state. Recognized params:
w— workload key (saas,database, ...)plan— plan id (numeric)ipv4— number 1-8windows—0|1managed—self|basic|pro|pilotbackup—none|lite|standard|extended|vaultcycle—monthly|quarterly|annual
Missing params get sensible defaults (workload null, planId null, all add-ons off, cycle monthly). Invalid params are silently ignored (e.g., plan=999 → ignored; managed=pilot&plan=vps-1 → falls back to managed=pro plus a snackbar warning).
After hydration, any state change debounces (300 ms) and updates the URL via history.replaceState — no scroll, no reload, browser back/forward works.
CTAs (sticky footer)
- Order this configuration — primary, navigates to
{accountUrl}/checkout/{plan_id}?ipv4=2&windows=1&managed=pilot&backup=standard&cycle=annual. The existing checkout route reads these query params and pre-selects the matching config options. - Copy share link — secondary, copies
window.location.hrefto clipboard. Snackbar: "Link copied — anyone with this link sees the same configuration."
Total calculation
cycleTotal =
plan.prices.find(p => p.billing_cycle === cycle).price
+ (ipv4Count - 1) * ipv4ConfigValue.prices[cycle]
+ windowsConfigValue.prices[cycle] // always $0
+ managedTierConfigValue.prices[cycle] // chosen tier
+ backupTierConfigValue.prices[cycle] // chosen tier
When cycle === 'monthly', the value is per month. When cycle === 'quarterly', it is the 3-month bill. When cycle === 'annual', the 12-month bill. The footer also shows an "effective monthly" line: cycleTotal / months.
Backend
Database
No schema changes. The existing plan_config_groups / plan_config_options / plan_config_values schema covers everything.
database/seeders/ConfigOptionSeeder.php is extended to seed two new groups:
Managed Support group (NEW)
| Value name | hourly | monthly | quarterly | semi_annual | annual | metadata |
|---|---|---|---|---|---|---|
| Self-Managed | 0 | 0 | 0 | 0 | 0 | is_default: true |
| Managed Basic | 0.04 | 29 | 82.65 | 156.60 | 295.80 | |
| Managed Pro | 0.11 | 79 | 225.15 | 426.60 | 805.80 | |
| Pilot | 0.14 | 99 | 282.15 | 534.60 | 1009.80 | min_plan_tier: 8 |
(Quarterly/semi-annual/annual prices computed from monthly at the standard 5% / 10% / 15% discounts that the existing seeder uses for other groups; reuse quarterlyPrice() / semiAnnualPrice() / annualPrice() helpers in the seeder.)
The metadata.min_plan_tier: 8 field on the Pilot value is the gating signal. The frontend reads it via the eager-loaded addOns Inertia prop. Plans expose a numeric "tier" — VPS-1 = 1, VPS-2 = 2, VPS-4 = 4, VPS-8 = 8, VPS-16 = 16, VPS-32 = 32, Storage 500 = 4 (treated as VPS-4 equivalent), Storage 1TB = 4. Plan tier is derived from features.cpu × features.ram heuristic, or simpler: a metadata.tier field added to each plan row in the existing PlanSeeder.php.
Off-site Backup group (NEW)
| Value name | hourly | monthly | quarterly | semi_annual | annual | metadata |
|---|---|---|---|---|---|---|
| None | 0 | 0 | 0 | 0 | 0 | is_default: true |
| Lite | 0.007 | 5 | 14.25 | 27.00 | 51.00 | retention_days: 7, quota_gb: 200 |
| Standard | 0.017 | 12 | 34.20 | 64.80 | 122.40 | retention_days: 30, quota_gb: 500 |
| Extended | 0.035 | 25 | 71.25 | 135.00 | 255.00 | retention_days: 90, quota_gb: 1024 |
| Vault | 0.082 | 59 | 168.15 | 318.60 | 601.80 | retention_days: 365, quota_gb: 2048 |
The metadata fields (retention_days, quota_gb) are surfaced to the frontend so the Backup selector can show "Daily backups, 30-day retention, up to 500 GB" helper text per option without hardcoding.
Route
routes/marketing.php — the /vps-hosting closure adds two more props:
addOns—PlanConfigGroup::where('service_type', 'vps')->with('options.values')->get()(eager loaded).workloadMap— inline static array mapping workload keys → plan slugs (default + alternates) and the 12 mini-quiz apps. Kept in the route closure for now; if it grows or needs reuse, extract toconfig/marketing.php.
The Inertia::render('Marketing/VpsHosting', ...) call passes both alongside the existing plans prop.
Checkout pre-fill
The existing account.<domain>/checkout/{plan} route already supports config option selection. We need to verify (during implementation) that it accepts the query-string keys ipv4, windows, managed, backup, cycle and pre-populates the corresponding form fields. If it currently only reads from the subscription_config_selections table (post-checkout), a small enhancement to the checkout controller is required — read query params and seed the form's initial state.
Frontend
File map
resources/ts/
├─ Pages/Marketing/VpsHosting.vue (modified)
├─ Components/Marketing/Estimator/ (NEW)
│ ├─ EstimatorSection.vue
│ ├─ BillingCycleToggle.vue
│ ├─ WorkloadPicker.vue
│ ├─ MiniQuizDialog.vue
│ ├─ RecommendedPlanCard.vue
│ ├─ AddOnsPanel.vue
│ ├─ IPv4Stepper.vue
│ ├─ ManagedSupportSelector.vue
│ ├─ BackupTierSelector.vue
│ └─ EstimatorFooter.vue
└─ stores/estimator.ts (NEW Pinia store)
Pinia store
Single source of truth.
state = {
workload: string | null,
planId: number | null,
ipv4Count: number, // 1..8
windowsLicense: boolean,
managedTier: 'self' | 'basic' | 'pro' | 'pilot',
backupTier: 'none' | 'lite' | 'standard' | 'extended' | 'vault',
cycle: 'monthly' | 'quarterly' | 'annual',
}
getters = {
recommendedPlanId, // workload + workloadMap
monthlyEffectiveTotal, // base + add-ons normalized to /mo
cycleTotal, // sum of cycle-specific prices
shareUrl, // /vps-hosting + query string
checkoutUrl, // accountUrl + checkout link
pilotAvailable, // boolean: planId tier >= 8
}
actions = {
hydrateFromUrl,
setWorkload,
setPlan, // also re-evaluates managedTier (auto-fallback)
setManagedTier,
// ...
}
Component contracts
| Component | Owns | Talks to store |
|---|---|---|
EstimatorSection |
Layout, scroll-reveal animations, mini-quiz mounting | reads/writes everything |
BillingCycleToggle |
UI for cycle (chip group) | writes cycle |
WorkloadPicker |
9 chips with icons | writes workload, planId |
MiniQuizDialog |
12-app grid + 2 follow-up Qs | writes workload, planId |
RecommendedPlanCard |
Display + "change plan ▾" dropdown | reads/writes planId |
AddOnsPanel |
Collapse/expand, holds 4 sub-controls | reads all |
IPv4Stepper |
+/- buttons, validation | writes ipv4Count |
ManagedSupportSelector |
4 radios + Pilot disabled state | reads planId, writes managedTier |
BackupTierSelector |
5 radios with retention helper | writes backupTier |
EstimatorFooter |
Sticky positioning, total display, 2 CTAs | reads cycleTotal, checkoutUrl, shareUrl |
Edge cases
| Case | Behavior |
|---|---|
Shared URL with plan=999 (invalid) |
Ignored; falls back to recommendation if workload set, else planId stays null |
Shared URL with managed=pilot&plan=vps-1 |
Auto-fallback to managed=pro; snackbar warning |
addOns Inertia prop empty (seeder not run) |
AddOnsPanel hidden; "Order this configuration" still functions with base plan |
| User picks workload, then changes plan to one that invalidates Pilot | Toast: "Pilot tier removed — requires VPS-8 or larger" |
| Mobile (< 600 px) | Single column; footer docks to viewport bottom; AddOnsPanel collapsed by default |
| Workload picker on mobile | 2-column chip grid (vs 3-col desktop); MiniQuiz goes full-screen |
| Browser back/forward | URL state means estimator restores; no SPA-traps |
cycle=annual in shared URL |
Annual toggle pre-selected on landing |
| Plans Table "Pre-fill estimator" link | Sets planId only; leaves workload null |
"Included With All Plans" — final list
Hardcoded in VpsHosting.vue (no backend config). 13 items + 1 "coming soon" badge.
1. 1 free IPv4 + 1 /64 IPv6 block
2. IPv4 rDNS / PTR control
3. 10 Gbps shared uplink (fair-use per AUP)
4. RAID 10 SSD storage
5. ZFS storage snapshots (free)
6. KVM virtualization
7. Full root access
8. Linux & Windows (BYOL) support
9. VirtFusion control panel
10. Out-of-band console / VNC access
11. Near-instant provisioning
12. 99.9% uptime SLA
13. 14-day money-back guarantee
Coming soon (badge): DDoS protection
Testing
Pest feature tests — tests/Feature/Marketing/VpsHostingEstimatorTest.php
vps-hosting page loads with addOns prop populatedvps-hosting page exposes workloadMap with all 9 keysseeder creates managed support group with 4 values including Pilotseeder creates backup tier group with 5 valuespilot value has metadata.min_plan_tier = 8checkout route accepts ipv4, windows, managed, backup, cycle query params and pre-selects them
Vitest unit tests — resources/ts/stores/estimator.test.ts
monthlyEffectiveTotal correctly sums plan + add-ons across cyclescycleTotal returns the cycle-specific row price (not a calculated discount)recommendedPlanId returns the expected plan for each of 9 workloadspilot is disabled when plan tier < 8URL hydration parses all 7 query params correctlyURL hydration falls back gracefully on invalid planIdURL hydration auto-fallback fires when managed=pilot but plan=vps-1shareUrl reflects current state
Playwright E2E (one happy path)
tests/e2e/vps-estimator.spec.ts:
- Open
/vps-hosting - Click "Web app / SaaS backend" → assert VPS-4 appears
- Expand add-ons; assert Pilot radio is disabled
- Click "Upgrade plan" → assert plan switches to VPS-8 and Pilot is selectable
- Pick Pilot + Backup Standard + Annual cycle
- Assert footer total equals VPS-8 annual + Pilot annual + Backup Standard annual (computed from seeded prices)
- Click "Copy share link" → URL contains
plan=,managed=pilot,backup=standard,cycle=annual
Open implementation questions
These are deferred to the implementation plan, not blockers for the spec:
- Verify the existing
account.<domain>/checkout/{plan}controller reads query params for config pre-selection. If not, add the param-handling code path. - Confirm whether plan tier is best derived from
metadata.tier(added to PlanSeeder) or from a heuristic onfeatures.cpu×features.ram. Recommendation: explicitmetadata.tierfield — clearer, less brittle. - Decide whether the workload map and 12-app list live inline in the route closure or in
config/marketing.php. Default to inline; promote later if reused.
Acceptance criteria
- EstimatorSection appears between hero and Features grid on
/vps-hosting. - All 9 workload chips work; "Not sure" opens the mini-quiz.
- Mini-quiz Step 1 (12 apps) maps each app to the documented plan.
- Mini-quiz Step 2 fires only when "Other" is picked.
- Add-ons panel shows IPv4 / Windows / Managed / Backup with correct pricing per cycle.
- Pilot is disabled below VPS-8 with tooltip + Upgrade Plan link.
- Auto-fallback fires when downgrading from a Pilot-eligible plan.
- Billing cycle toggle (Monthly / Quarterly / Annual) updates totals using the seeded per-cycle price rows.
- URL state hydrates and updates correctly; shared link fully restores configuration.
- "Order this configuration" navigates to checkout with all params; checkout pre-selects them.
- "Copy share link" copies
window.location.hrefand shows a snackbar. - "Included With All Plans" card displays 13 items + DDoS coming-soon badge.
- Plans Table rows have "Pre-fill estimator" links that set
planId. - All Pest, Vitest, and Playwright tests pass.
- Mobile (< 600 px) layout works without horizontal scroll; footer is bottom-docked.
- Existing
npm run buildandphp artisan test --compactboth pass.