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>
374 lines
20 KiB
Markdown
374 lines
20 KiB
Markdown
# 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.
|