Files
website/docs/superpowers/specs/2026-04-26-vps-hosting-estimator-design.md
Andrew d5f97d1240 docs(spec): VPS hosting estimator + included list refresh design
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>
2026-04-26 15:44:12 -04:00

374 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# VPS Hosting Page — Estimator + Included List Refresh
**Date:** 2026-04-26
**Status:** Approved (brainstorm), pending implementation plan
**Owner:** Andrew
**Repo:** `EZSCALE/website`
## Goals
1. Add an interactive **estimator** to the `/vps-hosting` page 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.
2. Rewrite the **"Included With All Plans"** card with accurate, expanded content and a "coming soon" badge for DDoS protection.
3. 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:**
1. Expected traffic? `Low (~100/day)` / `Medium (~10k/day)` / `High (~100k/day)`
2. 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-8
- `windows``0` | `1`
- `managed``self` | `basic` | `pro` | `pilot`
- `backup``none` | `lite` | `standard` | `extended` | `vault`
- `cycle``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.href` to 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 to `config/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.
```ts
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 populated`
- `vps-hosting page exposes workloadMap with all 9 keys`
- `seeder creates managed support group with 4 values including Pilot`
- `seeder creates backup tier group with 5 values`
- `pilot value has metadata.min_plan_tier = 8`
- `checkout 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 cycles`
- `cycleTotal returns the cycle-specific row price (not a calculated discount)`
- `recommendedPlanId returns the expected plan for each of 9 workloads`
- `pilot is disabled when plan tier < 8`
- `URL hydration parses all 7 query params correctly`
- `URL hydration falls back gracefully on invalid planId`
- `URL hydration auto-fallback fires when managed=pilot but plan=vps-1`
- `shareUrl reflects current state`
### Playwright E2E (one happy path)
`tests/e2e/vps-estimator.spec.ts`:
1. Open `/vps-hosting`
2. Click *"Web app / SaaS backend"* → assert *VPS-4* appears
3. Expand add-ons; assert Pilot radio is disabled
4. Click *"Upgrade plan"* → assert plan switches to VPS-8 and Pilot is selectable
5. Pick Pilot + Backup Standard + Annual cycle
6. Assert footer total equals VPS-8 annual + Pilot annual + Backup Standard annual (computed from seeded prices)
7. 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 on `features.cpu` × `features.ram`. Recommendation: explicit `metadata.tier` field — 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.href` and 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 build` and `php artisan test --compact` both pass.