From d5f97d1240f52aec3bd18930f1b69317808a26e60b13f84496b019f23db9c0c0 Mon Sep 17 00:00:00 2001 From: Andrew Date: Sun, 26 Apr 2026 15:44:12 -0400 Subject: [PATCH] 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) --- ...2026-04-26-vps-hosting-estimator-design.md | 373 ++++++++++++++++++ 1 file changed, 373 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-26-vps-hosting-estimator-design.md diff --git a/docs/superpowers/specs/2026-04-26-vps-hosting-estimator-design.md b/docs/superpowers/specs/2026-04-26-vps-hosting-estimator-design.md new file mode 100644 index 0000000..789f018 --- /dev/null +++ b/docs/superpowers/specs/2026-04-26-vps-hosting-estimator-design.md @@ -0,0 +1,373 @@ +# 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./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./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.