# 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.
