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