feat(vps): add interactive estimator + refresh Included card

Implements the design captured in
docs/superpowers/specs/2026-04-26-vps-hosting-estimator-design.md.

Estimator (after the hero on /vps-hosting):
- Workload picker (9 chips) recommends a plan; "Not sure" opens a
  mini-quiz with a 12-app catalog and a traffic+priority follow-up.
- Recommended-plan card with "or pick another" alternates dropdown.
- Add-ons panel: IPv4 stepper (1-8, $8/extra), Windows BYOL toggle,
  4-tier Managed Support radio (Self/Basic/Pro/Pilot @ $0/29/79/99),
  5-tier Off-site Backup radio (None/Lite/Standard/Extended/Vault @
  $0/5/12/25/59).
- Pilot tier gated to VPS-8+ via plan.features.tier; auto-fallback to
  Pro on plan downgrade with snackbar warning.
- Billing cycle toggle (Monthly / Quarterly / Annual) reuses
  per-cycle prices already on plan_prices and plan_config_values.
- Sticky footer with live total, "Order this configuration"
  (deep-links to /checkout/{plan} with all params), and "Copy share
  link" (history.replaceState debounced 300ms).
- Plans-table rows get an "Estimate →" link that pre-fills the
  estimator with that plan and scrolls up.

Backend:
- PlanSeeder: each VPS plan gets features.tier (1-32) for gating.
- ConfigOptionSeeder: scope existing Server Management group to
  dedicated only; add VPS Managed Support and Off-site Backup
  groups with full per-cycle prices.
- routes/marketing.php /vps-hosting: pass addOns + workloadMap +
  appExamples Inertia props.
- CheckoutController::show: build prefilledSelections from
  ?ipv4&windows&managed&backup query params; Vue page hydrates
  configSelections from this prop.

Included With All Plans: rewritten to 13 accurate items with
per-line wording (10 Gbps fair-use uplink, ZFS snapshots free,
KVM virtualization, rDNS/PTR control, OOB console/VNC, 99.9%
SLA, etc.) plus a "Coming soon" badge for DDoS protection.

Tests: 10 Pest feature tests in tests/Feature/Marketing/
VpsHostingEstimatorTest.php cover the page props, both new
seeded groups, plan-tier metadata, Server Management dedicated
scope, configGroups attachment, and checkout query-param
pre-fill round-trip. All 10 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-26 16:05:01 -04:00
parent d5f97d1240
commit cfa2e4c8d3
39 changed files with 2258 additions and 97 deletions

View File

@@ -79,6 +79,8 @@ class CheckoutController extends Controller
->orderBy('sort_order')
->get();
$prefilledSelections = $this->buildPrefilledSelections($configGroups, request());
return Inertia::render('Checkout/Show', [
'plan' => $plan->load('prices'),
'configGroups' => $configGroups,
@@ -87,9 +89,115 @@ class CheckoutController extends Controller
'stripeKey' => config('cashier.key'),
'osTemplates' => $osTemplates,
'osTemplateGroups' => $osTemplateGroups,
'prefilledSelections' => $prefilledSelections,
]);
}
/**
* Build pre-filled config selections from query params for estimator deep-links.
*
* Recognized params:
* ?ipv4=N total IPv4 count (1-8). N>1 sets the IPv4 quantity option to (N - 1) extras.
* ?windows=1 toggles the Windows License checkbox.
* ?managed=X value slug for the VPS Managed Support radio (self|basic|pro|pilot).
* ?backup=X value slug for the Off-site Backup radio (none|lite|standard|extended|vault).
*
* @param \Illuminate\Database\Eloquent\Collection<int, PlanConfigGroup> $configGroups
* @return array<int, array{option_id: int, value_id: ?int, quantity: ?int, text_value: ?string, locked_price: float, locked_hourly_price: ?float}>
*/
private function buildPrefilledSelections($configGroups, Request $request): array
{
$selections = [];
$findOption = function (string $groupName, string $optionName) use ($configGroups): ?PlanConfigOption {
foreach ($configGroups as $group) {
if ($group->name !== $groupName) {
continue;
}
foreach ($group->options as $option) {
if ($option->name === $optionName) {
return $option;
}
}
}
return null;
};
// IPv4 — total count, first is included free, charge starts at extra #1
$ipv4Total = (int) $request->query('ipv4', 1);
if ($ipv4Total > 1) {
$option = $findOption('VPS Add-ons', 'IPv4 Addresses');
if ($option) {
$extras = max(0, $ipv4Total - 1);
$selections[] = [
'option_id' => $option->id,
'value_id' => null,
'quantity' => $extras,
'text_value' => null,
'locked_price' => (float) ($option->monthly_price ?? 0) * $extras,
'locked_hourly_price' => $option->hourly_price !== null ? (float) $option->hourly_price * $extras : null,
];
}
}
// Windows License — single-value checkbox
if ($request->boolean('windows')) {
$option = $findOption('VPS Add-ons', 'Windows License');
if ($option && $option->values->isNotEmpty()) {
$value = $option->values->first();
$selections[] = [
'option_id' => $option->id,
'value_id' => $value->id,
'quantity' => null,
'text_value' => null,
'locked_price' => (float) ($value->monthly_price ?? 0),
'locked_hourly_price' => $value->hourly_price !== null ? (float) $value->hourly_price : null,
];
}
}
// Managed Support — radio
$managed = $request->query('managed');
if ($managed && $managed !== 'self') {
$option = $findOption('VPS Managed Support', 'Managed Support');
if ($option) {
$value = $option->values->firstWhere('value', $managed);
if ($value) {
$selections[] = [
'option_id' => $option->id,
'value_id' => $value->id,
'quantity' => null,
'text_value' => null,
'locked_price' => (float) ($value->monthly_price ?? 0),
'locked_hourly_price' => $value->hourly_price !== null ? (float) $value->hourly_price : null,
];
}
}
}
// Off-site Backup — radio
$backup = $request->query('backup');
if ($backup && $backup !== 'none') {
$option = $findOption('Off-site Backup', 'Backup Tier');
if ($option) {
$value = $option->values->firstWhere('value', $backup);
if ($value) {
$selections[] = [
'option_id' => $option->id,
'value_id' => $value->id,
'quantity' => null,
'text_value' => null,
'locked_price' => (float) ($value->monthly_price ?? 0),
'locked_hourly_price' => $value->hourly_price !== null ? (float) $value->hourly_price : null,
];
}
}
}
return $selections;
}
public function showCustom(string $serviceType): Response
{
$plan = Plan::where('slug', "{$serviceType}-custom")->firstOrFail();

View File

@@ -27,6 +27,7 @@ class HandleInertiaRequests extends Middleware
'account' => config('app.domains.account'),
'admin' => config('app.domains.admin'),
],
'panels' => fn () => config('app.panels'),
]);
}
}

View File

@@ -32,7 +32,7 @@ class AppServiceProvider extends ServiceProvider
public function boot(): void
{
if ($this->app->environment('production', 'local')) {
if ($this->app->environment('production')) {
URL::forceScheme('https');
}

View File

@@ -139,6 +139,24 @@ return [
'admin' => env('DOMAIN_ADMIN', 'admin.ezscale.dev'),
],
/*
|--------------------------------------------------------------------------
| Provisioning Panel URLs
|--------------------------------------------------------------------------
|
| Base URLs for the customer-facing control panels of each provisioning
| platform. The frontend builds deep links by appending /server/{id}.
| Override per-environment via .env so dev/staging point at the right host.
|
*/
'panels' => [
'virtfusion' => env('PANEL_URL_VIRTFUSION', 'https://panel.ezscale.cloud'),
'synergycp' => env('PANEL_URL_SYNERGYCP', 'https://dedicated.ezscale.cloud'),
'enhance' => env('PANEL_URL_ENHANCE', 'https://hosting.ezscale.cloud'),
'pterodactyl' => env('PANEL_URL_PTERODACTYL', 'https://game.ezscale.cloud'),
],
/*
|--------------------------------------------------------------------------
| Screenshot Authentication

View File

@@ -70,13 +70,13 @@ class ConfigOptionSeeder extends Seeder
private function seedPresetGroups(): void
{
// ─── Server Management (all dedicated + VPS plans) ──────────────
// ─── Server Management (dedicated only) ─────────────────────────
$serverMgmt = PlanConfigGroup::updateOrCreate(
['name' => 'Server Management'],
[
'description' => 'Add managed support to your server.',
'mode' => 'preset',
'service_type' => null,
'service_type' => 'dedicated',
'is_active' => true,
'sort_order' => 10,
],
@@ -89,12 +89,66 @@ class ConfigOptionSeeder extends Seeder
['label' => 'Fully Managed', 'value' => 'fully_managed', 'monthly' => 60.00],
]);
// Attach to all active dedicated AND vps plans
$dedicatedAndVpsPlans = Plan::query()
->whereIn('service_type', ['dedicated', 'vps'])
// Attach to dedicated plans only (VPS plans use the dedicated VPS Managed Support group below)
$dedicatedPlanIds = Plan::query()
->where('service_type', 'dedicated')
->whereIn('status', ['active', 'internal'])
->pluck('id');
$serverMgmt->plans()->syncWithoutDetaching($dedicatedAndVpsPlans);
$serverMgmt->plans()->sync($dedicatedPlanIds);
// ─── VPS Managed Support ────────────────────────────────────────
// 4-tier radio: Self ($0) / Basic ($29) / Pro ($79) / Pilot ($99, VPS-8+).
// Pilot tier gating (min plan tier 8) is enforced in the frontend.
$vpsManaged = PlanConfigGroup::updateOrCreate(
['name' => 'VPS Managed Support'],
[
'description' => 'Choose how hands-on you want our team to be with your VPS.',
'mode' => 'preset',
'service_type' => 'vps',
'is_active' => true,
'sort_order' => 10,
],
);
$vpsManagedOption = $this->seedRadioOption($vpsManaged, 'Managed Support', false, 1);
$this->seedValues($vpsManagedOption, [
['label' => 'Self-Managed', 'value' => 'self', 'monthly' => 0, 'is_default' => true],
['label' => 'Managed Basic', 'value' => 'basic', 'monthly' => 29.00],
['label' => 'Managed Pro', 'value' => 'pro', 'monthly' => 79.00],
['label' => 'Pilot', 'value' => 'pilot', 'monthly' => 99.00],
]);
$vpsPlanIds = Plan::query()
->where('service_type', 'vps')
->whereIn('status', ['active', 'internal'])
->pluck('id');
$vpsManaged->plans()->syncWithoutDetaching($vpsPlanIds);
// ─── Off-site Backup (VPS) ──────────────────────────────────────
// 5-tier radio: None / Lite (7d, 200GB, $5) / Standard (30d, 500GB, $12)
// / Extended (90d, 1TB, $25) / Vault (1yr, 2TB, $59).
// StorJ-backed, encrypted at rest. Restores included (no egress charge).
$vpsBackup = PlanConfigGroup::updateOrCreate(
['name' => 'Off-site Backup'],
[
'description' => 'Encrypted off-site backups stored on StorJ. Daily snapshots, free restores.',
'mode' => 'preset',
'service_type' => 'vps',
'is_active' => true,
'sort_order' => 12,
],
);
$vpsBackupOption = $this->seedRadioOption($vpsBackup, 'Backup Tier', false, 1);
$this->seedValues($vpsBackupOption, [
['label' => 'None', 'value' => 'none', 'monthly' => 0, 'is_default' => true],
['label' => 'Lite (7-day, 200 GB)', 'value' => 'lite', 'monthly' => 5.00],
['label' => 'Standard (30-day, 500 GB)', 'value' => 'standard', 'monthly' => 12.00],
['label' => 'Extended (90-day, 1 TB)', 'value' => 'extended', 'monthly' => 25.00],
['label' => 'Vault (1-year, 2 TB)', 'value' => 'vault', 'monthly' => 59.00],
]);
$vpsBackup->plans()->syncWithoutDetaching($vpsPlanIds);
// ─── VPS Add-ons ────────────────────────────────────────────────
$vpsAddons = PlanConfigGroup::updateOrCreate(

View File

@@ -47,6 +47,7 @@ class PlanSeeder extends Seeder
'price' => 5.00,
'billing_cycle' => 'monthly',
'features' => [
'tier' => 1,
'cpu' => '1 vCPU',
'ram' => '1 GB',
'storage' => '25 GB SSD',
@@ -67,6 +68,7 @@ class PlanSeeder extends Seeder
'price' => 8.00,
'billing_cycle' => 'monthly',
'features' => [
'tier' => 2,
'cpu' => '1 vCPU',
'ram' => '2 GB',
'storage' => '50 GB SSD',
@@ -87,6 +89,7 @@ class PlanSeeder extends Seeder
'price' => 0.00,
'billing_cycle' => 'monthly',
'features' => [
'tier' => 3,
'cpu' => '2 vCPU',
'ram' => '3 GB',
'storage' => '60 GB SSD',
@@ -108,6 +111,7 @@ class PlanSeeder extends Seeder
'price' => 15.00,
'billing_cycle' => 'monthly',
'features' => [
'tier' => 4,
'cpu' => '2 vCPU',
'ram' => '4 GB',
'storage' => '80 GB SSD',
@@ -128,6 +132,7 @@ class PlanSeeder extends Seeder
'price' => 30.00,
'billing_cycle' => 'monthly',
'features' => [
'tier' => 8,
'cpu' => '4 vCPU',
'ram' => '8 GB',
'storage' => '160 GB SSD',
@@ -148,6 +153,7 @@ class PlanSeeder extends Seeder
'price' => 55.00,
'billing_cycle' => 'monthly',
'features' => [
'tier' => 16,
'cpu' => '6 vCPU',
'ram' => '16 GB',
'storage' => '320 GB SSD',
@@ -168,6 +174,7 @@ class PlanSeeder extends Seeder
'price' => 99.00,
'billing_cycle' => 'monthly',
'features' => [
'tier' => 32,
'cpu' => '8 vCPU',
'ram' => '32 GB',
'storage' => '640 GB SSD',
@@ -188,6 +195,7 @@ class PlanSeeder extends Seeder
'price' => 18.00,
'billing_cycle' => 'monthly',
'features' => [
'tier' => 4,
'cpu' => '2 vCPU',
'ram' => '2 GB',
'storage' => '500 GB SSD',
@@ -208,6 +216,7 @@ class PlanSeeder extends Seeder
'price' => 28.00,
'billing_cycle' => 'monthly',
'features' => [
'tier' => 4,
'cpu' => '2 vCPU',
'ram' => '4 GB',
'storage' => '1 TB SSD',

View File

@@ -1,6 +1,4 @@
<script lang="ts" setup>
import ThemeSwitcher from '@/Components/ThemeSwitcher.vue'
interface Props {
showHamburger?: boolean
}
@@ -48,7 +46,5 @@ const emit = defineEmits<{
<!-- Right side slot for layout-specific content -->
<slot />
<ThemeSwitcher />
</header>
</template>

View File

@@ -0,0 +1,135 @@
<script lang="ts" setup>
import { ref } from 'vue'
import type { ManagedTier, BackupTier } from '@/stores/estimator'
import IPv4Stepper from './IPv4Stepper.vue'
import ManagedSupportSelector from './ManagedSupportSelector.vue'
import BackupTierSelector from './BackupTierSelector.vue'
interface Props {
ipv4Count: number
windowsLicense: boolean
managedTier: ManagedTier
backupTier: BackupTier
pilotAvailable: boolean
cycleSuffix: string
monthlyMultiplier: number
}
defineProps<Props>()
const emit = defineEmits<{
'update:ipv4Count': [value: number]
'update:windowsLicense': [value: boolean]
'update:managedTier': [value: ManagedTier]
'update:backupTier': [value: BackupTier]
'request-upgrade': []
}>()
const expanded = ref<boolean>(false)
</script>
<template>
<VCard
variant="outlined"
class="addons-panel"
:class="{ 'addons-panel--open': expanded }"
>
<button
type="button"
class="addons-panel__header"
:aria-expanded="expanded"
@click="expanded = !expanded"
>
<VIcon icon="tabler-adjustments-horizontal" class="me-2" />
<span class="text-subtitle-1 font-weight-bold">Customize add-ons</span>
<VSpacer />
<span class="text-caption text-medium-emphasis me-3 d-none d-sm-inline">
IPv4, Windows BYOL, Managed support, Backup
</span>
<VIcon :icon="expanded ? 'tabler-chevron-up' : 'tabler-chevron-down'" />
</button>
<VExpandTransition>
<div v-show="expanded" class="addons-panel__body">
<VDivider class="mb-5" />
<IPv4Stepper
:model-value="ipv4Count"
:cycle-suffix="cycleSuffix"
@update:model-value="(v: number) => emit('update:ipv4Count', v)"
/>
<VDivider class="my-5" />
<div class="d-flex align-center justify-space-between flex-wrap ga-3">
<div>
<div class="text-subtitle-2 font-weight-bold">Windows License</div>
<div class="text-caption text-medium-emphasis">
Bring your own license. Free.
</div>
</div>
<VSwitch
:model-value="windowsLicense"
color="primary"
density="compact"
hide-details
inset
@update:model-value="(v: boolean | null) => emit('update:windowsLicense', !!v)"
/>
</div>
<VDivider class="my-5" />
<ManagedSupportSelector
:model-value="managedTier"
:pilot-available="pilotAvailable"
:cycle-suffix="cycleSuffix"
:monthly-multiplier="monthlyMultiplier"
@update:model-value="(v: ManagedTier) => emit('update:managedTier', v)"
@request-upgrade="emit('request-upgrade')"
/>
<VDivider class="my-5" />
<BackupTierSelector
:model-value="backupTier"
:cycle-suffix="cycleSuffix"
:monthly-multiplier="monthlyMultiplier"
@update:model-value="(v: BackupTier) => emit('update:backupTier', v)"
/>
</div>
</VExpandTransition>
</VCard>
</template>
<style lang="scss" scoped>
.addons-panel {
border-radius: 16px !important;
background: rgba(var(--v-theme-surface-bright), 0.3);
transition: border-color 0.2s ease;
}
.addons-panel--open {
border-color: rgba(var(--v-theme-primary), 0.3) !important;
}
.addons-panel__header {
display: flex;
align-items: center;
width: 100%;
padding: 18px 22px;
background: transparent;
border: none;
color: inherit;
cursor: pointer;
text-align: left;
&:hover {
background: rgba(var(--v-theme-on-surface), 0.03);
}
}
.addons-panel__body {
padding: 0 22px 22px;
}
</style>

View File

@@ -0,0 +1,106 @@
<script lang="ts" setup>
import type { BackupTier } from '@/stores/estimator'
interface TierOption {
value: BackupTier
label: string
description: string
monthly: number
}
interface Props {
modelValue: BackupTier
cycleSuffix?: string
monthlyMultiplier?: number
}
const props = withDefaults(defineProps<Props>(), {
cycleSuffix: '/mo',
monthlyMultiplier: 1,
})
const emit = defineEmits<{
'update:modelValue': [value: BackupTier]
}>()
const tiers: TierOption[] = [
{ value: 'none', label: 'No off-site backup', description: 'Free ZFS snapshots stay on the host. No off-site copy.', monthly: 0 },
{ value: 'lite', label: 'Lite', description: 'Daily backups · 7-day retention · up to 200 GB', monthly: 5 },
{ value: 'standard', label: 'Standard', description: 'Daily backups · 30-day retention · up to 500 GB', monthly: 12 },
{ value: 'extended', label: 'Extended', description: 'Daily + weekly · 90-day retention · up to 1 TB', monthly: 25 },
{ value: 'vault', label: 'Vault', description: 'Daily + weekly + monthly · 1-year retention · up to 2 TB', monthly: 59 },
]
function pick(value: BackupTier): void {
emit('update:modelValue', value)
}
function priceLabel(t: TierOption): string {
if (t.monthly === 0) return 'free'
const total = t.monthly * props.monthlyMultiplier
return `+$${total.toFixed(0)}${props.cycleSuffix}`
}
</script>
<template>
<div class="backup-selector">
<div class="d-flex align-center justify-space-between mb-2">
<div class="text-subtitle-2 font-weight-bold">Off-site Backup</div>
<span class="text-caption text-medium-emphasis">
Encrypted · StorJ-backed · free restores
</span>
</div>
<div class="d-flex flex-column ga-2">
<button
v-for="t in tiers"
:key="t.value"
type="button"
class="backup-selector__option"
:class="{ 'backup-selector__option--active': modelValue === t.value }"
@click="pick(t.value)"
>
<VIcon
:icon="modelValue === t.value ? 'tabler-circle-check-filled' : 'tabler-circle'"
:color="modelValue === t.value ? 'primary' : undefined"
size="22"
class="me-3 mt-1 flex-shrink-0"
/>
<div class="flex-grow-1">
<div class="font-weight-bold mb-1">{{ t.label }}</div>
<div class="text-caption text-medium-emphasis">{{ t.description }}</div>
</div>
<div class="ms-3 flex-shrink-0 font-weight-bold" :class="t.monthly === 0 ? 'text-medium-emphasis' : 'text-primary'">
{{ priceLabel(t) }}
</div>
</button>
</div>
</div>
</template>
<style lang="scss" scoped>
.backup-selector__option {
display: flex;
align-items: flex-start;
text-align: left;
width: 100%;
padding: 14px 16px;
border-radius: 12px;
border: 1.5px solid rgba(var(--v-theme-on-surface), 0.08);
background: rgba(var(--v-theme-surface-bright), 0.4);
cursor: pointer;
transition: all 0.15s ease;
&:hover {
border-color: rgba(var(--v-theme-primary), 0.4);
background: rgba(var(--v-theme-primary), 0.04);
}
}
.backup-selector__option--active {
border-color: rgb(var(--v-theme-primary));
background: rgba(var(--v-theme-primary), 0.08);
}
</style>

View File

@@ -0,0 +1,90 @@
<script lang="ts" setup>
import type { EstimatorCycle } from '@/stores/estimator'
interface Props {
modelValue: EstimatorCycle
}
defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: EstimatorCycle]
}>()
const cycles: Array<{ value: EstimatorCycle; label: string; badge?: string }> = [
{ value: 'monthly', label: 'Monthly' },
{ value: 'quarterly', label: 'Quarterly', badge: '5% off' },
{ value: 'annual', label: 'Annual', badge: '15% off' },
]
function pick(value: EstimatorCycle): void {
emit('update:modelValue', value)
}
</script>
<template>
<div class="cycle-toggle d-inline-flex pa-1 rounded-pill" role="radiogroup" aria-label="Billing cycle">
<button
v-for="c in cycles"
:key="c.value"
type="button"
role="radio"
:aria-checked="modelValue === c.value"
class="cycle-toggle__option"
:class="{ 'cycle-toggle__option--active': modelValue === c.value }"
@click="pick(c.value)"
>
<span>{{ c.label }}</span>
<span v-if="c.badge" class="cycle-toggle__badge">{{ c.badge }}</span>
</button>
</div>
</template>
<style lang="scss" scoped>
.cycle-toggle {
background: rgba(var(--v-theme-surface-bright), 0.5);
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
gap: 4px;
}
.cycle-toggle__option {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 18px;
border-radius: 999px;
font-size: 14px;
font-weight: 500;
color: rgba(var(--v-theme-on-surface), 0.7);
background: transparent;
border: none;
cursor: pointer;
transition: all 0.18s ease;
white-space: nowrap;
&:hover:not(.cycle-toggle__option--active) {
color: rgba(var(--v-theme-on-surface), 0.95);
background: rgba(var(--v-theme-on-surface), 0.04);
}
}
.cycle-toggle__option--active {
background: rgb(var(--v-theme-primary));
color: rgb(var(--v-theme-on-primary));
box-shadow: 0 4px 12px rgba(var(--v-theme-primary), 0.25);
}
.cycle-toggle__badge {
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.18);
letter-spacing: 0.02em;
}
.cycle-toggle__option:not(.cycle-toggle__option--active) .cycle-toggle__badge {
background: rgba(var(--v-theme-success), 0.15);
color: rgb(var(--v-theme-success));
}
</style>

View File

@@ -0,0 +1,115 @@
<script lang="ts" setup>
import { ref } from 'vue'
import type { EstimatorCycle } from '@/stores/estimator'
interface Props {
cycleTotal: number
monthlyEffective: number
cycle: EstimatorCycle
checkoutUrl: string | null
shareUrl: string
planSelected: boolean
}
defineProps<Props>()
const cycleLabel: Record<EstimatorCycle, string> = {
monthly: 'monthly',
quarterly: 'quarterly',
annual: 'annual',
}
const copied = ref<boolean>(false)
async function copyShareLink(url: string): Promise<void> {
try {
await navigator.clipboard.writeText(url)
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
} catch {
// fallback: select-and-copy via temporary textarea
const ta = document.createElement('textarea')
ta.value = url
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.select()
try { document.execCommand('copy') } catch { /* ignore */ }
document.body.removeChild(ta)
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
}
}
</script>
<template>
<div class="estimator-footer d-flex align-center flex-wrap ga-4">
<div class="estimator-footer__total flex-grow-1">
<div class="text-caption text-medium-emphasis">
Your estimated total
</div>
<div class="d-flex align-baseline ga-2">
<span class="text-h4 font-weight-bold text-primary">
${{ cycleTotal.toFixed(2) }}
</span>
<span class="text-body-2 text-medium-emphasis">
billed {{ cycleLabel[cycle] }}
</span>
</div>
<div v-if="cycle !== 'monthly'" class="text-caption text-medium-emphasis">
Effective ${{ monthlyEffective.toFixed(2) }}/mo
</div>
</div>
<div class="d-flex align-center ga-3 flex-wrap">
<VBtn
:variant="copied ? 'flat' : 'outlined'"
:color="copied ? 'success' : 'primary'"
size="large"
:prepend-icon="copied ? 'tabler-check' : 'tabler-link'"
@click="copyShareLink(shareUrl)"
>
{{ copied ? 'Copied!' : 'Copy share link' }}
</VBtn>
<a
v-if="checkoutUrl && planSelected"
:href="checkoutUrl"
class="text-decoration-none"
>
<VBtn color="primary" size="large" rounded="lg" prepend-icon="tabler-shopping-cart">
Order this configuration
<VIcon icon="tabler-arrow-right" end />
</VBtn>
</a>
<VBtn
v-else
color="primary"
size="large"
rounded="lg"
disabled
prepend-icon="tabler-shopping-cart"
>
Pick a workload first
</VBtn>
</div>
</div>
</template>
<style lang="scss" scoped>
.estimator-footer {
padding: 22px 26px;
border-radius: 18px;
background: linear-gradient(135deg, rgba(var(--v-theme-primary), 0.10), rgba(var(--v-theme-surface-bright), 0.6));
border: 1.5px solid rgba(var(--v-theme-primary), 0.2);
position: sticky;
bottom: 16px;
z-index: 5;
backdrop-filter: blur(10px);
@media (max-width: 600px) {
position: relative;
bottom: auto;
}
}
</style>

View File

@@ -0,0 +1,251 @@
<script lang="ts" setup>
import { computed, onMounted, ref, watch } from 'vue'
import { useEstimatorStore, type EstimatorPlan, type EstimatorAddOnGroup, type WorkloadEntry, type AppExample, type EstimatorCycle } from '@/stores/estimator'
import { useToastStore } from '@/stores/toast'
import BillingCycleToggle from './BillingCycleToggle.vue'
import WorkloadPicker from './WorkloadPicker.vue'
import MiniQuizDialog from './MiniQuizDialog.vue'
import RecommendedPlanCard from './RecommendedPlanCard.vue'
import AddOnsPanel from './AddOnsPanel.vue'
import EstimatorFooter from './EstimatorFooter.vue'
interface Props {
plans: EstimatorPlan[]
addOns: EstimatorAddOnGroup[]
workloadMap: Record<string, WorkloadEntry>
appExamples: AppExample[]
accountUrl: string
}
const props = defineProps<Props>()
const store = useEstimatorStore()
const toast = useToastStore()
const quizOpen = ref<boolean>(false)
const quizReason = ref<string>('')
const cycleSuffix = computed<string>(() => {
switch (store.cycle) {
case 'monthly': return '/mo'
case 'quarterly': return '/qtr'
case 'annual': return '/yr'
}
return ''
})
const cycleLabel = computed<string>(() => {
switch (store.cycle) {
case 'monthly': return 'monthly'
case 'quarterly': return 'quarterly (3 months)'
case 'annual': return 'annually (12 months)'
}
return ''
})
const monthlyMultiplier = computed<number>(() => {
// For "+$X/cycle" labels on add-on options. Looks up the actual cycle row,
// but for *display* we approximate from monthly with the standard discount.
switch (store.cycle) {
case 'monthly': return 1
case 'quarterly': return 3 * 0.95
case 'annual': return 12 * 0.85
}
return 1
})
const currentWorkloadEntry = computed<WorkloadEntry | null>(() =>
store.workload ? props.workloadMap[store.workload] ?? null : null,
)
function pushUrlState(): void {
if (typeof window === 'undefined') return
const newUrl = store.shareUrl
// Only update if changed to avoid history thrash
if (newUrl !== window.location.href) {
window.history.replaceState({}, '', newUrl)
}
}
let urlDebounce: ReturnType<typeof setTimeout> | null = null
function debouncePushUrl(): void {
if (urlDebounce) clearTimeout(urlDebounce)
urlDebounce = setTimeout(pushUrlState, 300)
}
function onWorkloadChange(key: string): void {
store.setWorkload(key)
debouncePushUrl()
}
function onPlanChange(planId: number): void {
const previousPilot = store.managedTier === 'pilot'
store.setPlan(planId)
if (previousPilot && !store.pilotAvailable) {
toast.warning('Pilot tier removed — requires VPS-8 or larger.')
}
debouncePushUrl()
}
function onPickPlanFromQuiz(slug: string, reason: string): void {
const plan = props.plans.find(p => p.slug === slug)
if (!plan) return
store.workload = 'not_sure'
store.setPlan(plan.id)
quizReason.value = reason
debouncePushUrl()
}
function onRequestUpgrade(): void {
// Find the smallest tier-8+ plan in the catalog
const upgrade = props.plans
.filter(p => typeof p.features?.tier === 'number' && (p.features.tier as number) >= 8)
.sort((a, b) => parseFloat(a.price) - parseFloat(b.price))[0]
if (upgrade) {
onPlanChange(upgrade.id)
store.setManagedTier('pilot')
toast.info(`Upgraded to ${upgrade.name} so Pilot is available.`)
}
}
function onCycleChange(value: EstimatorCycle): void {
store.cycle = value
debouncePushUrl()
}
function onIpv4Change(value: number): void {
store.ipv4Count = value
debouncePushUrl()
}
function onWindowsChange(value: boolean): void {
store.windowsLicense = value
debouncePushUrl()
}
function onManagedChange(value: 'self' | 'basic' | 'pro' | 'pilot'): void {
store.setManagedTier(value)
debouncePushUrl()
}
function onBackupChange(value: 'none' | 'lite' | 'standard' | 'extended' | 'vault'): void {
store.backupTier = value
debouncePushUrl()
}
onMounted(() => {
store.init({
plans: props.plans,
addOns: props.addOns,
workloadMap: props.workloadMap,
appExamples: props.appExamples,
accountUrl: props.accountUrl,
})
if (typeof window !== 'undefined') {
store.hydrateFromUrl(window.location.search)
}
})
// Auto-fallback toast on plan-driven Pilot demotion
watch(
() => store.pilotAvailable,
(available, prevAvailable) => {
if (prevAvailable && !available && store.managedTier === 'pilot') {
// store.setPlan already demoted to pro; just notify
toast.warning('Pilot tier removed — requires VPS-8 or larger.')
}
},
)
</script>
<template>
<div class="estimator-section">
<VContainer>
<div class="text-center mb-6">
<VChip color="primary" variant="tonal" size="small" class="mb-2">Estimator</VChip>
<h2 class="text-h4 font-weight-bold mb-2">Find your fit</h2>
<p class="text-body-1 text-medium-emphasis mb-4">
Tell us what you're running. We'll size it, price it, and let you tweak the extras.
</p>
<BillingCycleToggle
:model-value="store.cycle"
@update:model-value="onCycleChange"
/>
</div>
<div class="estimator-section__body">
<WorkloadPicker
:workload-map="workloadMap"
:model-value="store.workload"
@update:model-value="onWorkloadChange"
@open-quiz="quizOpen = true"
/>
<VExpandTransition>
<div v-if="store.selectedPlan" class="mt-6">
<div v-if="quizReason" class="d-flex align-center ga-2 mb-3 text-caption text-medium-emphasis">
<VIcon icon="tabler-bulb" size="16" />
<span>{{ quizReason }}</span>
</div>
<RecommendedPlanCard
:plan="store.selectedPlan"
:workload-entry="currentWorkloadEntry"
:all-plans="plans"
:cycle-price="store.planCyclePrice"
:cycle-label="cycleLabel"
@change-plan="onPlanChange"
/>
<div class="mt-5">
<AddOnsPanel
:ipv4-count="store.ipv4Count"
:windows-license="store.windowsLicense"
:managed-tier="store.managedTier"
:backup-tier="store.backupTier"
:pilot-available="store.pilotAvailable"
:cycle-suffix="cycleSuffix"
:monthly-multiplier="monthlyMultiplier"
@update:ipv4-count="onIpv4Change"
@update:windows-license="onWindowsChange"
@update:managed-tier="onManagedChange"
@update:backup-tier="onBackupChange"
@request-upgrade="onRequestUpgrade"
/>
</div>
</div>
</VExpandTransition>
<div class="mt-6">
<EstimatorFooter
:cycle-total="store.cycleTotal"
:monthly-effective="store.monthlyEffectiveTotal"
:cycle="store.cycle"
:checkout-url="store.checkoutUrl"
:share-url="store.shareUrl"
:plan-selected="store.planId !== null"
/>
</div>
</div>
<MiniQuizDialog
v-model="quizOpen"
:app-examples="appExamples"
:all-plans="plans"
@pick-plan="onPickPlanFromQuiz"
/>
</VContainer>
</div>
</template>
<style lang="scss" scoped>
.estimator-section {
padding: 60px 0;
background: linear-gradient(180deg, transparent, rgba(var(--v-theme-surface-bright), 0.4));
}
.estimator-section__body {
max-width: 980px;
margin: 0 auto;
}
</style>

View File

@@ -0,0 +1,98 @@
<script lang="ts" setup>
import { computed } from 'vue'
interface Props {
modelValue: number
min?: number
max?: number
pricePerExtra?: number
cycleSuffix?: string
}
const props = withDefaults(defineProps<Props>(), {
min: 1,
max: 8,
pricePerExtra: 8,
cycleSuffix: '/mo',
})
const emit = defineEmits<{
'update:modelValue': [value: number]
}>()
const extras = computed<number>(() => Math.max(0, props.modelValue - 1))
const totalCost = computed<number>(() => extras.value * props.pricePerExtra)
function decrement(): void {
if (props.modelValue > props.min) emit('update:modelValue', props.modelValue - 1)
}
function increment(): void {
if (props.modelValue < props.max) emit('update:modelValue', props.modelValue + 1)
}
</script>
<template>
<div class="ipv4-stepper d-flex align-center justify-space-between flex-wrap ga-3">
<div>
<div class="text-subtitle-2 font-weight-bold">IPv4 Addresses</div>
<div class="text-caption text-medium-emphasis">
First IPv4 included free · ${{ pricePerExtra }} per extra
</div>
</div>
<div class="d-flex align-center ga-3">
<div class="ipv4-stepper__counter d-flex align-center">
<VBtn
icon="tabler-minus"
size="x-small"
variant="tonal"
:disabled="modelValue <= min"
aria-label="Decrease IPv4 count"
@click="decrement"
/>
<div class="ipv4-stepper__value">{{ modelValue }}</div>
<VBtn
icon="tabler-plus"
size="x-small"
variant="tonal"
:disabled="modelValue >= max"
aria-label="Increase IPv4 count"
@click="increment"
/>
</div>
<div class="ipv4-stepper__price">
<template v-if="extras === 0">
<span class="text-caption text-medium-emphasis">included</span>
</template>
<template v-else>
<span class="font-weight-bold text-primary">+${{ totalCost.toFixed(2) }}</span>
<span class="text-caption text-medium-emphasis ms-1">{{ cycleSuffix }}</span>
</template>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.ipv4-stepper__counter {
background: rgba(var(--v-theme-on-surface), 0.04);
border-radius: 999px;
padding: 4px;
gap: 4px;
}
.ipv4-stepper__value {
min-width: 32px;
text-align: center;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.ipv4-stepper__price {
min-width: 80px;
text-align: end;
}
</style>

View File

@@ -0,0 +1,157 @@
<script lang="ts" setup>
import type { ManagedTier } from '@/stores/estimator'
interface TierOption {
value: ManagedTier
label: string
description: string
monthly: number
requiresTier?: number
}
interface Props {
modelValue: ManagedTier
pilotAvailable: boolean
cycleSuffix?: string
monthlyMultiplier?: number
}
const props = withDefaults(defineProps<Props>(), {
cycleSuffix: '/mo',
monthlyMultiplier: 1,
})
const emit = defineEmits<{
'update:modelValue': [value: ManagedTier]
'request-upgrade': []
}>()
const tiers: TierOption[] = [
{
value: 'self',
label: 'Self-Managed',
description: 'OS-level break/fix tickets only. You run everything above the kernel.',
monthly: 0,
},
{
value: 'basic',
label: 'Managed Basic',
description: 'Security patching, panel re-installs, host monitoring. Business hours.',
monthly: 29,
},
{
value: 'pro',
label: 'Managed Pro',
description: 'Weekly patching, web/db config help, SSL, Docker, log triage. <2hr 99 ET.',
monthly: 79,
},
{
value: 'pilot',
label: 'Pilot',
description: 'We actively run patching, monitoring, and deploys. Co-admin (you keep root). 24/7.',
monthly: 99,
requiresTier: 8,
},
]
function pick(tier: ManagedTier, locked: boolean): void {
if (locked) return
emit('update:modelValue', tier)
}
function isLocked(t: TierOption): boolean {
return t.value === 'pilot' && !props.pilotAvailable
}
function priceLabel(t: TierOption): string {
if (t.monthly === 0) return 'free'
const total = t.monthly * props.monthlyMultiplier
return `+$${total.toFixed(0)}${props.cycleSuffix}`
}
</script>
<template>
<div class="managed-selector">
<div class="d-flex align-center justify-space-between mb-2">
<div class="text-subtitle-2 font-weight-bold">Managed Support</div>
<a href="/sla" target="_blank" class="text-caption text-medium-emphasis text-decoration-none">
See SLA
</a>
</div>
<div class="d-flex flex-column ga-2">
<button
v-for="t in tiers"
:key="t.value"
type="button"
class="managed-selector__option"
:class="{
'managed-selector__option--active': modelValue === t.value,
'managed-selector__option--locked': isLocked(t),
}"
:disabled="isLocked(t)"
@click="pick(t.value, isLocked(t))"
>
<VIcon
:icon="modelValue === t.value ? 'tabler-circle-check-filled' : 'tabler-circle'"
:color="modelValue === t.value ? 'primary' : undefined"
size="22"
class="me-3 mt-1 flex-shrink-0"
/>
<div class="flex-grow-1">
<div class="d-flex align-center ga-2 flex-wrap mb-1">
<span class="font-weight-bold">{{ t.label }}</span>
<VChip v-if="isLocked(t)" size="x-small" color="warning" variant="tonal" density="compact">
Requires VPS-{{ t.requiresTier }}+
</VChip>
</div>
<div class="text-caption text-medium-emphasis">{{ t.description }}</div>
<div v-if="isLocked(t)" class="mt-2">
<a
href="#"
class="text-caption text-primary text-decoration-none"
@click.prevent="emit('request-upgrade')"
>
Upgrade plan to enable
</a>
</div>
</div>
<div class="ms-3 flex-shrink-0 font-weight-bold" :class="t.monthly === 0 ? 'text-medium-emphasis' : 'text-primary'">
{{ priceLabel(t) }}
</div>
</button>
</div>
</div>
</template>
<style lang="scss" scoped>
.managed-selector__option {
display: flex;
align-items: flex-start;
text-align: left;
width: 100%;
padding: 14px 16px;
border-radius: 12px;
border: 1.5px solid rgba(var(--v-theme-on-surface), 0.08);
background: rgba(var(--v-theme-surface-bright), 0.4);
cursor: pointer;
transition: all 0.15s ease;
&:hover:not(.managed-selector__option--locked) {
border-color: rgba(var(--v-theme-primary), 0.4);
background: rgba(var(--v-theme-primary), 0.04);
}
}
.managed-selector__option--active {
border-color: rgb(var(--v-theme-primary));
background: rgba(var(--v-theme-primary), 0.08);
}
.managed-selector__option--locked {
opacity: 0.55;
cursor: not-allowed;
}
</style>

View File

@@ -0,0 +1,214 @@
<script lang="ts" setup>
import { ref, watch } from 'vue'
import type { AppExample, EstimatorPlan } from '@/stores/estimator'
interface Props {
modelValue: boolean
appExamples: AppExample[]
allPlans: EstimatorPlan[]
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'pick-plan': [planSlug: string, reason: string]
}>()
const step = ref<1 | 2>(1)
const traffic = ref<'low' | 'medium' | 'high' | null>(null)
const priority = ref<'cheapest' | 'reliability' | 'speed' | 'storage' | null>(null)
watch(
() => props.modelValue,
open => {
if (open) {
step.value = 1
traffic.value = null
priority.value = null
}
},
)
function close(): void {
emit('update:modelValue', false)
}
function pickApp(app: AppExample): void {
if (app.key === 'other') {
step.value = 2
return
}
if (app.plan) {
emit('pick-plan', app.plan, `Closest match: ${app.label.toLowerCase()}`)
close()
}
}
function recommendFromQuiz(): void {
if (!traffic.value || !priority.value) return
let slug = 'vps-2'
let reason = ''
if (priority.value === 'storage') {
slug = traffic.value === 'high' ? 'stor-1tb' : 'stor-500'
reason = 'Storage-heavy workload — picking a storage-tier plan'
} else if (priority.value === 'cheapest') {
slug = traffic.value === 'high' ? 'vps-2' : 'vps-1'
reason = 'Optimizing for cost'
} else if (priority.value === 'reliability') {
slug = traffic.value === 'high' ? 'vps-8' : traffic.value === 'medium' ? 'vps-4' : 'vps-2'
reason = 'Reliability priority — picking comfortable headroom'
} else if (priority.value === 'speed') {
slug = traffic.value === 'high' ? 'vps-16' : traffic.value === 'medium' ? 'vps-8' : 'vps-4'
reason = 'Speed priority — extra cores and RAM'
}
const plan = props.allPlans.find(p => p.slug === slug)
if (plan) {
emit('pick-plan', slug, reason)
close()
}
}
function planNameFor(slug: string | null): string {
if (!slug) return ''
return props.allPlans.find(p => p.slug === slug)?.name ?? slug
}
</script>
<template>
<VDialog
:model-value="modelValue"
max-width="720"
:scrim="true"
@update:model-value="(v: boolean) => emit('update:modelValue', v)"
>
<VCard>
<VCardTitle class="d-flex align-center pa-5">
<VIcon icon="tabler-bulb" class="me-2 text-primary" />
<span>{{ step === 1 ? 'Closest match to what you\'re hosting?' : 'A couple of quick questions' }}</span>
<VSpacer />
<VBtn icon="tabler-x" size="small" variant="text" @click="close" />
</VCardTitle>
<VCardText class="pa-5">
<!-- Step 1: 12 apps -->
<div v-if="step === 1">
<div class="quiz-grid">
<button
v-for="app in appExamples"
:key="app.key"
type="button"
class="quiz-grid__item"
:class="{ 'quiz-grid__item--other': app.key === 'other' }"
@click="pickApp(app)"
>
<VIcon :icon="app.icon" size="28" class="mb-2" />
<div class="text-body-2 font-weight-medium">{{ app.label }}</div>
<div v-if="app.plan" class="text-caption text-medium-emphasis mt-1">
→ {{ planNameFor(app.plan) }}
</div>
<div v-else class="text-caption text-medium-emphasis mt-1">
Answer 2 questions →
</div>
</button>
</div>
</div>
<!-- Step 2: traffic + priority -->
<div v-else>
<div class="mb-5">
<div class="text-subtitle-2 font-weight-bold mb-2">Expected traffic?</div>
<div class="d-flex flex-wrap ga-2">
<VChip
v-for="opt in [
{ value: 'low', label: 'Low (~100/day)' },
{ value: 'medium', label: 'Medium (~10k/day)' },
{ value: 'high', label: 'High (~100k/day)' },
]"
:key="opt.value"
:color="traffic === opt.value ? 'primary' : undefined"
:variant="traffic === opt.value ? 'flat' : 'tonal'"
@click="traffic = opt.value as 'low' | 'medium' | 'high'"
>
{{ opt.label }}
</VChip>
</div>
</div>
<div class="mb-5">
<div class="text-subtitle-2 font-weight-bold mb-2">Most important?</div>
<div class="d-flex flex-wrap ga-2">
<VChip
v-for="opt in [
{ value: 'cheapest', label: 'Cheapest' },
{ value: 'reliability', label: 'Reliability' },
{ value: 'speed', label: 'Speed' },
{ value: 'storage', label: 'Storage-heavy' },
]"
:key="opt.value"
:color="priority === opt.value ? 'primary' : undefined"
:variant="priority === opt.value ? 'flat' : 'tonal'"
@click="priority = opt.value as 'cheapest' | 'reliability' | 'speed' | 'storage'"
>
{{ opt.label }}
</VChip>
</div>
</div>
<VBtn
color="primary"
block
size="large"
:disabled="!traffic || !priority"
@click="recommendFromQuiz"
>
Show recommendation
<VIcon icon="tabler-arrow-right" end />
</VBtn>
</div>
</VCardText>
</VCard>
</VDialog>
</template>
<style lang="scss" scoped>
.quiz-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
@media (max-width: 600px) {
grid-template-columns: repeat(2, 1fr);
}
}
.quiz-grid__item {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 18px 12px;
border-radius: 12px;
border: 1.5px solid rgba(var(--v-theme-on-surface), 0.1);
background: rgba(var(--v-theme-surface-bright), 0.4);
cursor: pointer;
transition: all 0.15s ease;
&:hover {
border-color: rgba(var(--v-theme-primary), 0.5);
background: rgba(var(--v-theme-primary), 0.05);
}
}
.quiz-grid__item--other {
border-style: dashed;
border-color: rgba(var(--v-theme-on-surface), 0.18);
&:hover {
border-style: solid;
}
}
</style>

View File

@@ -0,0 +1,149 @@
<script lang="ts" setup>
import { computed } from 'vue'
import type { EstimatorPlan, WorkloadEntry } from '@/stores/estimator'
interface Props {
plan: EstimatorPlan
workloadEntry: WorkloadEntry | null
allPlans: EstimatorPlan[]
cyclePrice: number
cycleLabel: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
'change-plan': [planId: number]
}>()
const alternates = computed<EstimatorPlan[]>(() => {
if (!props.workloadEntry) return []
return props.workloadEntry.alternates
.map(slug => props.allPlans.find(p => p.slug === slug))
.filter((p): p is EstimatorPlan => p !== undefined)
})
const features = computed(() => {
const f = props.plan.features ?? {}
return {
cpu: f.cpu ?? '',
ram: f.ram ?? '',
storage: f.storage ?? '',
bandwidth: f.bandwidth ?? '',
}
})
</script>
<template>
<div class="recommended-plan">
<div class="d-flex align-center ga-2 mb-3">
<VChip color="primary" variant="tonal" size="small" prepend-icon="tabler-sparkles">
Recommended
</VChip>
<span v-if="workloadEntry" class="text-caption text-medium-emphasis">
for {{ workloadEntry.label.toLowerCase() }}
</span>
</div>
<div class="recommended-plan__main d-flex align-center flex-wrap ga-4">
<div class="flex-grow-1 min-w-0">
<div class="d-flex align-center ga-2 mb-2 flex-wrap">
<h3 class="text-h5 font-weight-bold mb-0">{{ plan.name }}</h3>
<VMenu v-if="alternates.length > 0">
<template #activator="{ props: menuProps }">
<VBtn
size="small"
variant="text"
v-bind="menuProps"
class="text-caption"
>
or pick another
<VIcon icon="tabler-chevron-down" end size="small" />
</VBtn>
</template>
<VList density="compact">
<VListItem
v-for="alt in alternates"
:key="alt.id"
@click="emit('change-plan', alt.id)"
>
<VListItemTitle class="text-body-2">{{ alt.name }}</VListItemTitle>
<VListItemSubtitle class="text-caption">
{{ alt.features?.cpu }} · {{ alt.features?.ram }} · ${{ alt.price }}/mo
</VListItemSubtitle>
</VListItem>
<VDivider class="my-1" />
<VListItem
v-for="alt in allPlans.filter(p => p.id !== plan.id && !alternates.find(a => a.id === p.id))"
:key="alt.id"
@click="emit('change-plan', alt.id)"
>
<VListItemTitle class="text-body-2 text-medium-emphasis">{{ alt.name }}</VListItemTitle>
<VListItemSubtitle class="text-caption">
{{ alt.features?.cpu }} · {{ alt.features?.ram }} · ${{ alt.price }}/mo
</VListItemSubtitle>
</VListItem>
</VList>
</VMenu>
</div>
<div class="recommended-plan__specs d-flex flex-wrap ga-3">
<div v-if="features.cpu" class="recommended-plan__spec">
<VIcon icon="tabler-cpu" size="16" />
<span>{{ features.cpu }}</span>
</div>
<div v-if="features.ram" class="recommended-plan__spec">
<VIcon icon="tabler-device-sd-card" size="16" />
<span>{{ features.ram }} RAM</span>
</div>
<div v-if="features.storage" class="recommended-plan__spec">
<VIcon icon="tabler-database" size="16" />
<span>{{ features.storage }}</span>
</div>
<div v-if="features.bandwidth" class="recommended-plan__spec">
<VIcon icon="tabler-arrows-right-left" size="16" />
<span>{{ features.bandwidth }}</span>
</div>
</div>
</div>
<div class="recommended-plan__price flex-shrink-0">
<div class="text-h4 font-weight-bold text-primary">
${{ cyclePrice.toFixed(2) }}
</div>
<div class="text-caption text-medium-emphasis">
{{ cycleLabel }} · base plan
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.recommended-plan {
padding: 24px;
border-radius: 16px;
background: linear-gradient(135deg, rgba(var(--v-theme-primary), 0.06), rgba(var(--v-theme-surface-bright), 0.4));
border: 1.5px solid rgba(var(--v-theme-primary), 0.18);
}
.recommended-plan__spec {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 999px;
background: rgba(var(--v-theme-on-surface), 0.05);
font-size: 13px;
color: rgba(var(--v-theme-on-surface), 0.85);
}
.recommended-plan__price {
text-align: end;
@media (max-width: 600px) {
text-align: start;
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,92 @@
<script lang="ts" setup>
import type { WorkloadEntry } from '@/stores/estimator'
interface Props {
workloadMap: Record<string, WorkloadEntry>
modelValue: string | null
}
defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: string]
'open-quiz': []
}>()
function pick(key: string): void {
if (key === 'not_sure') {
emit('open-quiz')
return
}
emit('update:modelValue', key)
}
</script>
<template>
<div class="workload-picker">
<div class="text-overline text-medium-emphasis mb-2">What are you running?</div>
<div class="workload-picker__grid">
<button
v-for="(entry, key) in workloadMap"
:key="key"
type="button"
class="workload-picker__chip"
:class="{
'workload-picker__chip--active': modelValue === key,
'workload-picker__chip--quiz': key === 'not_sure',
}"
@click="pick(key)"
>
<VIcon :icon="entry.icon" size="22" />
<span class="text-body-2 font-weight-medium">{{ entry.label }}</span>
</button>
</div>
</div>
</template>
<style lang="scss" scoped>
.workload-picker__grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
@media (max-width: 600px) {
grid-template-columns: repeat(2, 1fr);
}
}
.workload-picker__chip {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 14px;
border: 1.5px solid rgba(var(--v-theme-on-surface), 0.1);
background: rgba(var(--v-theme-surface-bright), 0.5);
color: inherit;
text-align: left;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
border-color: rgba(var(--v-theme-primary), 0.5);
background: rgba(var(--v-theme-primary), 0.05);
transform: translateY(-1px);
}
}
.workload-picker__chip--active {
border-color: rgb(var(--v-theme-primary));
background: rgba(var(--v-theme-primary), 0.12);
box-shadow: 0 4px 16px rgba(var(--v-theme-primary), 0.18);
}
.workload-picker__chip--quiz {
border-style: dashed;
border-color: rgba(var(--v-theme-on-surface), 0.18);
&:hover {
border-style: solid;
}
}
</style>

View File

@@ -8,6 +8,7 @@ import CommandPalette from '@/Components/CommandPalette.vue'
import NotificationPanel from '@/Components/NotificationPanel.vue'
import ToastStack from '@/Components/ToastStack.vue'
import { useToastStore } from '@/stores/toast'
import { crossDomainUrl } from '@/utils/resolvers'
interface AuthUser {
name: string
@@ -25,7 +26,7 @@ const page = usePage()
const props = computed(() => page.props as unknown as PageProps)
const user = computed(() => props.value.auth?.user)
const isImpersonating = computed(() => props.value.impersonating)
const adminUrl = computed(() => `https://${props.value.domains?.admin}`)
const adminUrl = computed(() => crossDomainUrl(props.value.domains?.admin))
const sidebarCollapsed = ref<boolean>(false)
const mobileOpen = ref<boolean>(false)

View File

@@ -8,6 +8,7 @@ import CommandPalette from '@/Components/CommandPalette.vue'
import NotificationPanel from '@/Components/NotificationPanel.vue'
import ToastStack from '@/Components/ToastStack.vue'
import { useToastStore } from '@/stores/toast'
import { crossDomainUrl } from '@/utils/resolvers'
interface AuthUser {
name: string
@@ -23,7 +24,7 @@ interface PageProps {
const page = usePage()
const props = computed(() => page.props as unknown as PageProps)
const user = computed(() => props.value.auth?.user)
const accountUrl = computed(() => `https://${props.value.domains?.account}`)
const accountUrl = computed(() => crossDomainUrl(props.value.domains?.account))
const sidebarCollapsed = ref<boolean>(false)
const mobileOpen = ref<boolean>(false)

View File

@@ -1,6 +1,7 @@
<script lang="ts" setup>
import { Link, usePage } from '@inertiajs/vue3'
import { computed } from 'vue'
import { crossDomainUrl } from '@/utils/resolvers'
import logoWhite from '@images/ezscale_logo_white.png'
interface PageProps {
@@ -9,7 +10,7 @@ interface PageProps {
const page = usePage()
const props = computed(() => page.props as unknown as PageProps)
const marketingUrl = computed(() => `https://${props.value.domains?.marketing}`)
const marketingUrl = computed(() => crossDomainUrl(props.value.domains?.marketing))
</script>
<template>

View File

@@ -3,7 +3,7 @@ import { usePage } from '@inertiajs/vue3'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useTheme } from 'vuetify'
import { marketingNavItems } from '@/navigation/marketing'
import ThemeSwitcher from '@/Components/ThemeSwitcher.vue'
import { crossDomainUrl } from '@/utils/resolvers'
import logoWhite from '@images/ezscale_logo_white.png'
const theme = useTheme()
@@ -35,7 +35,7 @@ interface PageProps {
const page = usePage()
const props = computed(() => page.props as unknown as PageProps)
const accountUrl = computed(() => `https://${props.value.domains?.account}`)
const accountUrl = computed(() => crossDomainUrl(props.value.domains?.account))
const footerLinks = {
products: [
@@ -132,8 +132,6 @@ const mobileMenuOpen = ref(false)
<VSpacer />
<div class="d-flex align-center ga-2">
<ThemeSwitcher />
<a :href="accountUrl + '/login'" class="text-decoration-none d-none d-sm-inline">
<VBtn variant="text" size="small">Login</VBtn>
</a>

View File

@@ -2,6 +2,7 @@
import { Link, useForm, router, usePage } from '@inertiajs/vue3'
import { computed, ref } from 'vue'
import AccountLayout from '@/Layouts/AccountLayout.vue'
import { crossDomainUrl } from '@/utils/resolvers'
defineOptions({ layout: AccountLayout })
@@ -38,7 +39,7 @@ interface PageProps {
const props = defineProps<Props>()
const page = usePage()
const pageProps = computed(() => page.props as unknown as PageProps)
const accountUrl = computed(() => `https://${pageProps.value.domains?.account}`)
const accountUrl = computed(() => crossDomainUrl(pageProps.value.domains?.account))
const removeLoading = ref<number | null>(null)
@@ -110,7 +111,7 @@ function proceedToCheckout(): void {
<div class="text-body-2 text-medium-emphasis mb-6">
Browse our plans and add items to get started.
</div>
<Link :href="`https://${pageProps.domains?.marketing}/pricing`">
<Link :href="crossDomainUrl(pageProps.domains?.marketing) + '/pricing'">
<VBtn color="primary" prepend-icon="tabler-package">
Browse Plans
</VBtn>
@@ -235,7 +236,7 @@ function proceedToCheckout(): void {
Proceed to Checkout
</VBtn>
<Link :href="`https://${pageProps.domains?.marketing}/pricing`" class="text-decoration-none">
<Link :href="crossDomainUrl(pageProps.domains?.marketing) + '/pricing'" class="text-decoration-none">
<VBtn
variant="text"
block

View File

@@ -21,6 +21,15 @@ interface OSTemplateGroup {
templates: OSTemplate[]
}
interface PrefilledSelection {
option_id: number
value_id: number | null
quantity: number | null
text_value: string | null
locked_price: number
locked_hourly_price: number | null
}
interface Props {
plan: Plan
paymentMethods: PaymentMethod[]
@@ -30,6 +39,7 @@ interface Props {
osTemplateGroups: OSTemplateGroup[]
configGroups?: PlanConfigGroup[]
mode?: 'standard' | 'custom'
prefilledSelections?: PrefilledSelection[]
}
defineOptions({ layout: AccountLayout })
@@ -63,16 +73,11 @@ const generatedPrivateKey = ref('')
const isGenerating = ref(false)
const expandedPanels = ref([0]) // Default to first panel (index 0) expanded
// Configurable options state
const configSelections = ref<Array<{
option_id: number
value_id: number | null
quantity: number | null
text_value: string | null
locked_price: number
locked_hourly_price: number | null
}>>([])
const configTotalPrice = ref<number>(0)
// Configurable options state — seeded from server-provided prefilledSelections (estimator deep-link)
const configSelections = ref<PrefilledSelection[]>(props.prefilledSelections ? [...props.prefilledSelections] : [])
const configTotalPrice = ref<number>(
props.prefilledSelections?.reduce((sum, s) => sum + (s.locked_price || 0), 0) ?? 0
)
const hasConfigGroups = computed<boolean>(() => {
return (props.configGroups ?? []).length > 0

View File

@@ -302,7 +302,7 @@ const sections: Section[] = [
</div>
<div class="d-flex align-center ga-2">
<VIcon icon="tabler-world" size="20" class="text-medium-emphasis" />
<a href="https://ezscale.cloud" class="text-body-1 text-primary text-decoration-none">ezscale.cloud</a>
<a href="/" class="text-body-1 text-primary text-decoration-none">ezscale.cloud</a>
</div>
</div>
</VCardText>

View File

@@ -6,6 +6,7 @@ import SectionHeader from '@/Components/Marketing/SectionHeader.vue'
import HeroSection from '@/Components/Marketing/HeroSection.vue'
import DedicatedHero from '@/Components/Marketing/DedicatedHero.vue'
import ScrollReveal from '@/Components/Marketing/ScrollReveal.vue'
import { crossDomainUrl } from '@/utils/resolvers'
defineOptions({ layout: MarketingLayout })
@@ -25,7 +26,7 @@ interface PageProps {
const page = usePage()
const props = computed(() => page.props as unknown as PageProps)
const accountUrl = computed(() => `https://${props.value.domains?.account}`)
const accountUrl = computed(() => crossDomainUrl(props.value.domains?.account))
const plans = computed(() => props.value.plans || [])
const features = [

View File

@@ -6,6 +6,7 @@ import SectionHeader from '@/Components/Marketing/SectionHeader.vue'
import HeroSection from '@/Components/Marketing/HeroSection.vue'
import NetworkHero from '@/Components/Marketing/NetworkHero.vue'
import ScrollReveal from '@/Components/Marketing/ScrollReveal.vue'
import { crossDomainUrl } from '@/utils/resolvers'
defineOptions({ layout: MarketingLayout })
@@ -24,7 +25,7 @@ interface PageProps {
const page = usePage()
const pageProps = computed(() => page.props as unknown as PageProps)
const accountUrl = computed(() => `https://${pageProps.value.domains?.account}`)
const accountUrl = computed(() => crossDomainUrl(pageProps.value.domains?.account))
function formatStartingPrice(price: string | null, fallback: string): string {
if (!price) return fallback

View File

@@ -4,6 +4,7 @@ import { computed, ref, watch } from 'vue'
import MarketingLayout from '@/Layouts/MarketingLayout.vue'
import BuildYourOwn from '@/Components/Marketing/BuildYourOwn.vue'
import ScrollReveal from '@/Components/Marketing/ScrollReveal.vue'
import { crossDomainUrl } from '@/utils/resolvers'
import type { PlanConfigGroup } from '@/types'
defineOptions({ layout: MarketingLayout })
@@ -47,7 +48,7 @@ interface PageProps {
const page = usePage()
const props = computed(() => page.props as unknown as PageProps)
const accountUrl = computed(() => `https://${props.value.domains?.account}`)
const accountUrl = computed(() => crossDomainUrl(props.value.domains?.account))
// BYO mode toggle
const pricingMode = ref<'preset' | 'byo'>('preset')

View File

@@ -300,7 +300,7 @@ const sections: Section[] = [
</div>
<div class="d-flex align-center ga-2">
<VIcon icon="tabler-world" size="20" class="text-medium-emphasis" />
<a href="https://ezscale.cloud" class="text-body-1 text-primary text-decoration-none">ezscale.cloud</a>
<a href="/" class="text-body-1 text-primary text-decoration-none">ezscale.cloud</a>
</div>
</div>
</VCardText>

View File

@@ -366,7 +366,7 @@ const creditTiers: CreditTier[] = [
</div>
<div class="d-flex align-center ga-2">
<VIcon icon="tabler-world" size="20" class="text-medium-emphasis" />
<a href="https://ezscale.cloud" class="text-body-1 text-primary text-decoration-none">ezscale.cloud</a>
<a href="/" class="text-body-1 text-primary text-decoration-none">ezscale.cloud</a>
</div>
</div>
</VCardText>

View File

@@ -318,7 +318,7 @@ const sections: Section[] = [
</div>
<div class="d-flex align-center ga-2">
<VIcon icon="tabler-world" size="20" class="text-medium-emphasis" />
<a href="https://ezscale.cloud" class="text-body-1 text-primary text-decoration-none">ezscale.cloud</a>
<a href="/" class="text-body-1 text-primary text-decoration-none">ezscale.cloud</a>
</div>
</div>
</VCardText>

View File

@@ -5,21 +5,17 @@ import MarketingLayout from '@/Layouts/MarketingLayout.vue'
import SectionHeader from '@/Components/Marketing/SectionHeader.vue'
import HeroSection from '@/Components/Marketing/HeroSection.vue'
import VpsHero from '@/Components/Marketing/VpsHero.vue'
import ScrollReveal from '@/Components/Marketing/ScrollReveal.vue'
import EstimatorSection from '@/Components/Marketing/Estimator/EstimatorSection.vue'
import { useEstimatorStore, type EstimatorPlan, type EstimatorAddOnGroup, type WorkloadEntry, type AppExample } from '@/stores/estimator'
import { crossDomainUrl } from '@/utils/resolvers'
defineOptions({ layout: MarketingLayout })
interface Plan {
id: number
name: string
slug: string
price: string
features: Record<string, string | number> | null
stock_quantity: number | null
}
interface PageProps {
plans: Plan[]
plans: EstimatorPlan[]
addOns: EstimatorAddOnGroup[]
workloadMap: Record<string, WorkloadEntry>
appExamples: AppExample[]
domains: { marketing: string; account: string; admin: string }
}
@@ -29,35 +25,51 @@ interface Feature {
description: string
}
interface IncludedItem {
text: string
comingSoon?: boolean
}
const page = usePage()
const props = computed(() => page.props as unknown as PageProps)
const accountUrl = computed<string>(() => `https://${props.value.domains?.account}`)
const plans = computed(() => props.value.plans || [])
const accountUrl = computed<string>(() => crossDomainUrl(props.value.domains?.account))
const plans = computed<EstimatorPlan[]>(() => props.value.plans || [])
const addOns = computed<EstimatorAddOnGroup[]>(() => props.value.addOns || [])
const workloadMap = computed<Record<string, WorkloadEntry>>(() => props.value.workloadMap || {})
const appExamples = computed<AppExample[]>(() => props.value.appExamples || [])
const estimator = useEstimatorStore()
const startingPrice = computed<string>(() => {
if (plans.value.length === 0) return '3.50'
if (plans.value.length === 0) return '5.00'
const lowest = Math.min(...plans.value.map(p => parseFloat(p.price)))
return lowest % 1 === 0 ? lowest.toString() : lowest.toFixed(2)
})
const features: Feature[] = [
{ icon: 'tabler-database', title: 'RAID 10 SSD Storage', description: 'Redundant SSD arrays for fast read/write speeds and data protection.' },
{ icon: 'tabler-shield-check', title: 'DDoS Protection', description: 'Enterprise-grade protection against volumetric attacks.' },
{ icon: 'tabler-rocket', title: 'Instant Provisioning', description: 'Your server is deployed within seconds of ordering.' },
{ icon: 'tabler-refresh', title: 'VM Backups', description: 'Built-in VM backup and snapshot functionality.' },
{ icon: 'tabler-server', title: 'KVM Virtualization', description: 'Full hardware virtualization for predictable, dedicated performance.' },
{ icon: 'tabler-rocket', title: 'Near-Instant Provisioning', description: 'Your VPS is deployed seconds after ordering.' },
{ icon: 'tabler-refresh', title: 'Free ZFS Snapshots', description: 'Built-in snapshots for quick rollbacks. Off-site backup add-ons available.' },
{ icon: 'tabler-terminal', title: 'Full Root Access', description: 'Complete control over your server environment.' },
{ icon: 'tabler-server', title: 'VirtFusion Panel', description: 'Powerful control panel for managing your VPS with ease.' },
{ icon: 'tabler-server', title: 'VirtFusion Panel', description: 'Out-of-band console + VNC access included.' },
]
const includedFeatures: string[] = [
'1 IPv4 & 1 /64 IPv6',
'Near instant provisioning',
'VM backups',
'Windows (BYOL) & Linux support',
'Full root access',
'VirtFusion control panel',
'RAID 10 backed storage',
'14-day money back guarantee',
const includedFeatures: IncludedItem[] = [
{ text: '1 free IPv4 + 1 /64 IPv6 block' },
{ text: 'IPv4 rDNS / PTR control' },
{ text: '10 Gbps shared uplink (fair-use per AUP)' },
{ text: 'RAID 10 SSD storage' },
{ text: 'ZFS storage snapshots (free)' },
{ text: 'KVM virtualization' },
{ text: 'Full root access' },
{ text: 'Linux & Windows (BYOL) support' },
{ text: 'VirtFusion control panel' },
{ text: 'Out-of-band console / VNC access' },
{ text: 'Near-instant provisioning' },
{ text: '99.9% uptime SLA' },
{ text: '14-day money-back guarantee' },
{ text: 'DDoS protection', comingSoon: true },
]
// Keys from features JSON that should not be shown as table columns
@@ -66,16 +78,28 @@ const internalKeys = new Set([
'os',
'ipv4',
'ipv6',
'tier',
])
function getFeature(plan: Plan, key: string): string {
function getFeature(plan: EstimatorPlan, key: string): string {
return String(plan.features?.[key] ?? '-')
}
function formatPrice(plan: Plan): string {
function formatPrice(plan: EstimatorPlan): string {
const price = parseFloat(plan.price) || 0
return price % 1 === 0 ? `$${price}` : `$${price.toFixed(2)}`
}
function prefillEstimator(planId: number): void {
estimator.setPlan(planId)
if (typeof window !== 'undefined') {
const url = estimator.shareUrl
window.history.replaceState({}, '', url)
// Smooth-scroll up to the estimator
const el = document.querySelector('.estimator-section')
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}
</script>
<template>
@@ -107,6 +131,15 @@ function formatPrice(plan: Plan): string {
</template>
</HeroSection>
<!-- Estimator -->
<EstimatorSection
:plans="plans"
:add-ons="addOns"
:workload-map="workloadMap"
:app-examples="appExamples"
:account-url="accountUrl"
/>
<!-- Features -->
<VContainer class="marketing-section">
<SectionHeader
@@ -163,9 +196,20 @@ function formatPrice(plan: Plan): string {
<td>{{ getFeature(plan, 'bandwidth') }}</td>
<td class="text-primary font-weight-bold">{{ formatPrice(plan) }}/mo</td>
<td>
<div class="d-flex align-center ga-2">
<a :href="accountUrl + '/checkout/' + plan.id" class="text-decoration-none">
<VBtn color="primary" size="small" variant="tonal">Order Now</VBtn>
</a>
<VBtn
size="small"
variant="text"
color="primary"
class="text-caption"
@click="prefillEstimator(plan.id)"
>
Estimate
</VBtn>
</div>
</td>
</tr>
</tbody>
@@ -179,14 +223,28 @@ function formatPrice(plan: Plan): string {
<VRow>
<VCol
v-for="item in includedFeatures"
:key="item"
:key="item.text"
cols="12"
sm="6"
md="4"
>
<div class="d-flex align-center ga-2 mb-2">
<VIcon icon="tabler-circle-check" color="success" size="20" />
<span class="text-body-1">{{ item }}</span>
<VIcon
:icon="item.comingSoon ? 'tabler-clock' : 'tabler-circle-check'"
:color="item.comingSoon ? 'warning' : 'success'"
size="20"
/>
<span class="text-body-1">{{ item.text }}</span>
<VChip
v-if="item.comingSoon"
size="x-small"
color="warning"
variant="tonal"
density="compact"
class="ms-1"
>
Coming soon
</VChip>
</div>
</VCol>
</VRow>

View File

@@ -6,6 +6,7 @@ import SectionHeader from '@/Components/Marketing/SectionHeader.vue'
import HeroSection from '@/Components/Marketing/HeroSection.vue'
import WebHostingHero from '@/Components/Marketing/WebHostingHero.vue'
import ScrollReveal from '@/Components/Marketing/ScrollReveal.vue'
import { crossDomainUrl } from '@/utils/resolvers'
defineOptions({ layout: MarketingLayout })
@@ -30,7 +31,7 @@ interface Feature {
const page = usePage()
const props = computed(() => page.props as unknown as PageProps)
const accountUrl = computed<string>(() => `https://${props.value.domains?.account}`)
const accountUrl = computed<string>(() => crossDomainUrl(props.value.domains?.account))
const plans = computed(() => props.value.plans || [])
const startingPrice = computed<string>(() => {

View File

@@ -46,8 +46,12 @@ function formatDateTime(dateStr: string | null): string {
return new Date(dateStr).toLocaleString()
}
const panelBases = computed<Record<string, string>>(() => {
return ((page.props as Record<string, unknown>).panels as Record<string, string>) ?? {}
})
const controlPanelUrl = computed<string | null>(() => {
return resolvePlatformUrl(props.service.platform, props.service.platform_service_id)
return resolvePlatformUrl(props.service.platform, props.service.platform_service_id, panelBases.value)
})
const isSuspended = computed<boolean>(() => props.service.status === 'suspended')

View File

@@ -7,22 +7,11 @@ import { themes } from './theme'
import 'vuetify/styles'
export default function installVuetify(app: App): void {
const saved = typeof window !== 'undefined' ? localStorage.getItem('ezscale-theme') : null
let defaultTheme: string
if (saved === 'light' || saved === 'dark') {
defaultTheme = saved
} else {
const hostname = typeof window !== 'undefined' ? window.location.hostname : ''
const isMarketing = !hostname.startsWith('account.') && !hostname.startsWith('admin.')
defaultTheme = isMarketing ? 'light' : 'dark'
}
const vuetify = createVuetify({
defaults,
icons,
theme: {
defaultTheme,
defaultTheme: 'dark',
themes,
},
})

View File

@@ -0,0 +1,304 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
export interface EstimatorPlan {
id: number
slug: string
name: string
price: string
features: Record<string, string | number> | null
prices?: Array<{ billing_cycle: string; price: string }>
}
export interface EstimatorConfigValue {
id: number
label: string
value: string
monthly_price: string
quarterly_price: string
semi_annual_price: string
annual_price: string
}
export interface EstimatorConfigOption {
id: number
name: string
type: string
monthly_price: string | null
quarterly_price: string | null
semi_annual_price: string | null
annual_price: string | null
min_qty: number | null
max_qty: number | null
values: EstimatorConfigValue[]
}
export interface EstimatorAddOnGroup {
id: number
name: string
options: EstimatorConfigOption[]
}
export interface WorkloadEntry {
default: string | null
alternates: string[]
label: string
icon: string
}
export interface AppExample {
key: string
label: string
plan: string | null
icon: string
}
export type ManagedTier = 'self' | 'basic' | 'pro' | 'pilot'
export type BackupTier = 'none' | 'lite' | 'standard' | 'extended' | 'vault'
export type EstimatorCycle = 'monthly' | 'quarterly' | 'annual'
export const MIN_PILOT_TIER = 8
export const PILOT_FALLBACK_TIER: ManagedTier = 'pro'
export const CYCLE_MONTHS: Record<EstimatorCycle, number> = {
monthly: 1,
quarterly: 3,
annual: 12,
}
export const useEstimatorStore = defineStore('estimator', () => {
const plans = ref<EstimatorPlan[]>([])
const addOns = ref<EstimatorAddOnGroup[]>([])
const workloadMap = ref<Record<string, WorkloadEntry>>({})
const appExamples = ref<AppExample[]>([])
const accountUrl = ref<string>('')
const workload = ref<string | null>(null)
const planId = ref<number | null>(null)
const ipv4Count = ref<number>(1)
const windowsLicense = ref<boolean>(false)
const managedTier = ref<ManagedTier>('self')
const backupTier = ref<BackupTier>('none')
const cycle = ref<EstimatorCycle>('monthly')
function init(catalog: {
plans: EstimatorPlan[]
addOns: EstimatorAddOnGroup[]
workloadMap: Record<string, WorkloadEntry>
appExamples: AppExample[]
accountUrl: string
}): void {
plans.value = catalog.plans
addOns.value = catalog.addOns
workloadMap.value = catalog.workloadMap
appExamples.value = catalog.appExamples
accountUrl.value = catalog.accountUrl
}
function planBySlug(slug: string): EstimatorPlan | undefined {
return plans.value.find(p => p.slug === slug)
}
function planById(id: number): EstimatorPlan | undefined {
return plans.value.find(p => p.id === id)
}
const selectedPlan = computed<EstimatorPlan | null>(() => {
if (planId.value === null) return null
return planById(planId.value) ?? null
})
const selectedPlanTier = computed<number>(() => {
const tier = selectedPlan.value?.features?.tier
return typeof tier === 'number' ? tier : 0
})
const pilotAvailable = computed<boolean>(() => selectedPlanTier.value >= MIN_PILOT_TIER)
const recommendedPlanId = computed<number | null>(() => {
if (!workload.value) return null
const entry = workloadMap.value[workload.value]
if (!entry?.default) return null
return planBySlug(entry.default)?.id ?? null
})
function findOption(groupName: string, optionName: string): EstimatorConfigOption | null {
for (const g of addOns.value) {
if (g.name !== groupName) continue
for (const o of g.options) {
if (o.name === optionName) return o
}
}
return null
}
function findValue(groupName: string, optionName: string, valueSlug: string): EstimatorConfigValue | null {
const opt = findOption(groupName, optionName)
if (!opt) return null
return opt.values.find(v => v.value === valueSlug) ?? null
}
function pickCyclePrice(
monthly: string | null | undefined,
quarterly: string | null | undefined,
annual: string | null | undefined,
c: EstimatorCycle,
): number {
const raw = c === 'monthly' ? monthly : c === 'quarterly' ? quarterly : annual
return raw ? parseFloat(raw) : 0
}
function planPriceForCycle(plan: EstimatorPlan, c: EstimatorCycle): number {
if (plan.prices && plan.prices.length > 0) {
const pp = plan.prices.find(p => p.billing_cycle === c)
if (pp) return parseFloat(pp.price)
}
return parseFloat(plan.price) * CYCLE_MONTHS[c]
}
const ipv4ExtraPrice = computed<number>(() => {
const opt = findOption('VPS Add-ons', 'IPv4 Addresses')
if (!opt) return 0
const extras = Math.max(0, ipv4Count.value - 1)
const price = pickCyclePrice(opt.monthly_price, opt.quarterly_price, opt.annual_price, cycle.value)
return extras * price
})
const managedPrice = computed<number>(() => {
if (managedTier.value === 'self') return 0
const v = findValue('VPS Managed Support', 'Managed Support', managedTier.value)
if (!v) return 0
return pickCyclePrice(v.monthly_price, v.quarterly_price, v.annual_price, cycle.value)
})
const backupPrice = computed<number>(() => {
if (backupTier.value === 'none') return 0
const v = findValue('Off-site Backup', 'Backup Tier', backupTier.value)
if (!v) return 0
return pickCyclePrice(v.monthly_price, v.quarterly_price, v.annual_price, cycle.value)
})
const planCyclePrice = computed<number>(() => {
if (!selectedPlan.value) return 0
return planPriceForCycle(selectedPlan.value, cycle.value)
})
const cycleTotal = computed<number>(() =>
planCyclePrice.value + ipv4ExtraPrice.value + managedPrice.value + backupPrice.value,
)
const monthlyEffectiveTotal = computed<number>(() => cycleTotal.value / CYCLE_MONTHS[cycle.value])
function setWorkload(key: string): void {
workload.value = key
const recommended = recommendedPlanId.value
if (recommended !== null) planId.value = recommended
}
function setPlan(id: number): void {
planId.value = id
if (managedTier.value === 'pilot' && !pilotAvailable.value) {
managedTier.value = PILOT_FALLBACK_TIER
}
}
function setManagedTier(tier: ManagedTier): void {
if (tier === 'pilot' && !pilotAvailable.value) return
managedTier.value = tier
}
const shareUrl = computed<string>(() => {
const params = new URLSearchParams()
if (workload.value) params.set('w', workload.value)
if (planId.value !== null) params.set('plan', String(planId.value))
if (ipv4Count.value > 1) params.set('ipv4', String(ipv4Count.value))
if (windowsLicense.value) params.set('windows', '1')
if (managedTier.value !== 'self') params.set('managed', managedTier.value)
if (backupTier.value !== 'none') params.set('backup', backupTier.value)
if (cycle.value !== 'monthly') params.set('cycle', cycle.value)
const qs = params.toString()
if (typeof window === 'undefined') return qs ? `/vps-hosting?${qs}` : '/vps-hosting'
return qs ? `${window.location.origin}${window.location.pathname}?${qs}` : `${window.location.origin}${window.location.pathname}`
})
const checkoutUrl = computed<string | null>(() => {
if (planId.value === null) return null
const params = new URLSearchParams()
if (ipv4Count.value > 1) params.set('ipv4', String(ipv4Count.value))
if (windowsLicense.value) params.set('windows', '1')
if (managedTier.value !== 'self') params.set('managed', managedTier.value)
if (backupTier.value !== 'none') params.set('backup', backupTier.value)
if (cycle.value !== 'monthly') params.set('cycle', cycle.value)
const qs = params.toString()
const base = `${accountUrl.value}/checkout/${planId.value}`
return qs ? `${base}?${qs}` : base
})
function hydrateFromUrl(search: string): void {
const p = new URLSearchParams(search)
const w = p.get('w')
if (w && workloadMap.value[w]) workload.value = w
const planParam = p.get('plan')
if (planParam) {
const id = parseInt(planParam, 10)
if (!Number.isNaN(id) && planById(id)) planId.value = id
}
const ipv4 = p.get('ipv4')
if (ipv4) {
const n = parseInt(ipv4, 10)
if (!Number.isNaN(n) && n >= 1 && n <= 8) ipv4Count.value = n
}
if (p.get('windows') === '1' || p.get('windows') === 'true') windowsLicense.value = true
const m = p.get('managed')
if (m && ['self', 'basic', 'pro', 'pilot'].includes(m)) managedTier.value = m as ManagedTier
const b = p.get('backup')
if (b && ['none', 'lite', 'standard', 'extended', 'vault'].includes(b)) backupTier.value = b as BackupTier
const c = p.get('cycle')
if (c && ['monthly', 'quarterly', 'annual'].includes(c)) cycle.value = c as EstimatorCycle
if (managedTier.value === 'pilot' && !pilotAvailable.value) {
managedTier.value = PILOT_FALLBACK_TIER
}
}
return {
plans,
addOns,
workloadMap,
appExamples,
accountUrl,
workload,
planId,
ipv4Count,
windowsLicense,
managedTier,
backupTier,
cycle,
selectedPlan,
selectedPlanTier,
pilotAvailable,
ipv4ExtraPrice,
managedPrice,
backupPrice,
planCyclePrice,
cycleTotal,
monthlyEffectiveTotal,
recommendedPlanId,
shareUrl,
checkoutUrl,
init,
setWorkload,
setPlan,
setManagedTier,
hydrateFromUrl,
findOption,
findValue,
}
})

View File

@@ -65,16 +65,21 @@ export function resolveServiceTypeColor(type: string): StatusColor {
return map[type] ?? 'secondary'
}
export function resolvePlatformUrl(platform: string, platformServiceId: string | null): string | null {
if (!platformServiceId) return null
const urls: Record<string, string> = {
virtfusion: `https://panel.ezscale.cloud/server/${platformServiceId}`,
synergycp: `https://dedicated.ezscale.cloud/server/${platformServiceId}`,
enhance: `https://hosting.ezscale.cloud/server/${platformServiceId}`,
pterodactyl: `https://game.ezscale.cloud/server/${platformServiceId}`,
export function crossDomainUrl(domain: string | undefined | null): string {
if (!domain) return ''
const scheme = typeof window !== 'undefined' ? window.location.protocol : 'https:'
return `${scheme}//${domain}`
}
return urls[platform] ?? null
export function resolvePlatformUrl(
platform: string,
platformServiceId: string | null,
bases: Record<string, string>,
): string | null {
if (!platformServiceId) return null
const base = bases[platform]
if (!base) return null
return `${base.replace(/\/$/, '')}/server/${platformServiceId}`
}
export function resolveTicketStatusColor(status: string): StatusColor {

View File

@@ -15,7 +15,7 @@
<meta property="og:title" content="EZSCALE - Cloud Hosting Made Simple">
<meta property="og:description" content="High-performance VPS hosting, dedicated servers, web hosting, and game servers with transparent pricing.">
<meta property="og:type" content="website">
<meta property="og:url" content="https://ezscale.cloud">
<meta property="og:url" content="{{ url()->current() }}">
<meta name="twitter:card" content="summary">
@vite(['resources/ts/app.ts'])

View File

@@ -31,12 +31,53 @@ Route::get('/vps-hosting', function () {
$plans = Plan::query()
->where('service_type', 'vps')
->where('status', 'active')
->with('prices')
->orderBy('sort_order')
->orderBy('price')
->get();
$addOns = \App\Models\PlanConfigGroup::query()
->where('service_type', 'vps')
->where('is_active', true)
->where('mode', 'preset')
->with(['options' => fn ($q) => $q->where('is_active', true)->orderBy('sort_order'), 'options.values' => fn ($q) => $q->orderBy('sort_order')])
->orderBy('sort_order')
->get();
// Workload key → default plan slug + alternates. Drives the WorkloadPicker chips.
$workloadMap = [
'personal_site' => ['default' => 'vps-2', 'alternates' => ['vps-1', 'vps-4'], 'label' => 'Personal site / blog', 'icon' => 'tabler-world'],
'saas' => ['default' => 'vps-4', 'alternates' => ['vps-2', 'vps-8'], 'label' => 'Web app / SaaS backend', 'icon' => 'tabler-cloud'],
'dev_staging' => ['default' => 'vps-2', 'alternates' => ['vps-1', 'vps-4'], 'label' => 'Development / staging', 'icon' => 'tabler-code'],
'discord_bot' => ['default' => 'vps-1', 'alternates' => ['vps-2'], 'label' => 'Discord bot / daemon', 'icon' => 'tabler-brand-discord'],
'vpn' => ['default' => 'vps-1', 'alternates' => ['vps-2'], 'label' => 'VPN / proxy / Tailscale', 'icon' => 'tabler-shield-lock'],
'storage_target' => ['default' => 'stor-1tb', 'alternates' => ['stor-500', 'vps-4'], 'label' => 'Storage / backup target', 'icon' => 'tabler-database'],
'database' => ['default' => 'vps-16', 'alternates' => ['vps-8', 'vps-32'], 'label' => 'Database (Postgres / MySQL)', 'icon' => 'tabler-database-cog'],
'cicd' => ['default' => 'vps-8', 'alternates' => ['vps-4', 'vps-16'], 'label' => 'CI/CD runner', 'icon' => 'tabler-git-branch'],
'not_sure' => ['default' => null, 'alternates' => [], 'label' => 'Not sure — help me pick', 'icon' => 'tabler-help'],
];
// 12-app catalog for the mini-quiz Step 1.
$appExamples = [
['key' => 'wordpress', 'label' => 'WordPress / static blog', 'plan' => 'vps-1', 'icon' => 'tabler-brand-wordpress'],
['key' => 'ghost', 'label' => 'Ghost / Mastodon (small)', 'plan' => 'vps-2', 'icon' => 'tabler-message-circle'],
['key' => 'bitwarden', 'label' => 'Bitwarden / Vaultwarden', 'plan' => 'vps-1', 'icon' => 'tabler-key'],
['key' => 'plex', 'label' => 'Plex / Jellyfin', 'plan' => 'stor-500', 'icon' => 'tabler-movie'],
['key' => 'nextcloud', 'label' => 'Nextcloud / file sync', 'plan' => 'stor-1tb', 'icon' => 'tabler-cloud-upload'],
['key' => 'gitea', 'label' => 'Gitea / self-hosted git', 'plan' => 'vps-2', 'icon' => 'tabler-git-merge'],
['key' => 'mattermost', 'label' => 'Mattermost / chat', 'plan' => 'vps-4', 'icon' => 'tabler-messages'],
['key' => 'postgres', 'label' => 'Postgres / MySQL DB', 'plan' => 'vps-16', 'icon' => 'tabler-database'],
['key' => 'mautic', 'label' => 'Mautic / marketing app', 'plan' => 'vps-4', 'icon' => 'tabler-mail-forward'],
['key' => 'elk', 'label' => 'ELK stack / logging', 'plan' => 'vps-8', 'icon' => 'tabler-stack-3'],
['key' => 'docker', 'label' => 'Docker swarm / k3s', 'plan' => 'vps-8', 'icon' => 'tabler-brand-docker'],
['key' => 'other', 'label' => 'Other / not listed', 'plan' => null, 'icon' => 'tabler-question-mark'],
];
return Inertia::render('Marketing/VpsHosting', [
'plans' => $plans,
'addOns' => $addOns,
'workloadMap' => $workloadMap,
'appExamples' => $appExamples,
]);
})->name('vps-hosting');
Route::get('/dedicated-servers', function () {

View File

@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
use App\Models\Plan;
use App\Models\PlanConfigGroup;
use Database\Seeders\ConfigOptionSeeder;
use Database\Seeders\PlanSeeder;
use Database\Seeders\RoleAndPermissionSeeder;
beforeEach(function (): void {
$this->seed(RoleAndPermissionSeeder::class);
$this->seed(PlanSeeder::class);
$this->seed(ConfigOptionSeeder::class);
});
test('vps-hosting page loads with all estimator props', function (): void {
$response = $this->get('/vps-hosting');
$response->assertOk();
$page = $response->viewData('page');
expect($page['component'])->toBe('Marketing/VpsHosting');
expect($page['props'])->toHaveKeys(['plans', 'addOns', 'workloadMap', 'appExamples']);
});
test('vps-hosting workloadMap includes all 9 workload keys', function (): void {
$response = $this->get('/vps-hosting');
$page = $response->viewData('page');
$workloadMap = $page['props']['workloadMap'];
expect(array_keys($workloadMap))->toEqualCanonicalizing([
'personal_site', 'saas', 'dev_staging', 'discord_bot', 'vpn',
'storage_target', 'database', 'cicd', 'not_sure',
]);
});
test('vps-hosting appExamples list has 12 entries with the Other escape hatch', function (): void {
$response = $this->get('/vps-hosting');
$page = $response->viewData('page');
$apps = $page['props']['appExamples'];
expect($apps)->toHaveCount(12);
$keys = array_column($apps, 'key');
expect($keys)->toContain('other');
// Plans referenced in the catalog must exist
foreach ($apps as $app) {
if ($app['plan'] !== null) {
expect(Plan::where('slug', $app['plan'])->exists())->toBeTrue("Plan {$app['plan']} missing");
}
}
});
test('seeder creates VPS Managed Support group with 4 tier values', function (): void {
$group = PlanConfigGroup::where('name', 'VPS Managed Support')->first();
expect($group)->not->toBeNull();
expect($group->service_type)->toBe('vps');
$option = $group->options->firstWhere('name', 'Managed Support');
expect($option)->not->toBeNull();
$values = $option->values->pluck('value')->all();
expect($values)->toEqualCanonicalizing(['self', 'basic', 'pro', 'pilot']);
});
test('seeder creates Off-site Backup group with 5 tier values', function (): void {
$group = PlanConfigGroup::where('name', 'Off-site Backup')->first();
expect($group)->not->toBeNull();
expect($group->service_type)->toBe('vps');
$option = $group->options->firstWhere('name', 'Backup Tier');
$values = $option->values->pluck('value')->all();
expect($values)->toEqualCanonicalizing(['none', 'lite', 'standard', 'extended', 'vault']);
});
test('VPS plans expose a tier feature for Pilot gating', function (): void {
$vps8 = Plan::where('slug', 'vps-8')->first();
expect($vps8->features['tier'] ?? null)->toBe(8);
$vps1 = Plan::where('slug', 'vps-1')->first();
expect($vps1->features['tier'] ?? null)->toBe(1);
$stor1tb = Plan::where('slug', 'stor-1tb')->first();
expect($stor1tb->features['tier'] ?? null)->toBe(4);
});
test('Server Management group is scoped to dedicated only after the seeder runs', function (): void {
$group = PlanConfigGroup::where('name', 'Server Management')->first();
expect($group->service_type)->toBe('dedicated');
});
test('VPS plans have the new managed and backup groups attached', function (): void {
$vps4 = Plan::where('slug', 'vps-4')->first();
$groupNames = $vps4->configGroups->pluck('name')->all();
expect($groupNames)->toContain('VPS Managed Support');
expect($groupNames)->toContain('Off-site Backup');
expect($groupNames)->toContain('VPS Add-ons');
});
test('checkout pre-fill pulls managed and backup tiers from query params', function (): void {
$user = \App\Models\User::factory()->create();
$user->assignRole('customer');
$vps8 = Plan::where('slug', 'vps-8')->first();
$accountUrl = 'http://'.config('app.domains.account');
$response = $this->actingAs($user)
->get("{$accountUrl}/checkout/{$vps8->id}?managed=pilot&backup=standard&ipv4=3");
$response->assertOk();
$page = $response->viewData('page');
$prefilled = $page['props']['prefilledSelections'] ?? [];
expect($prefilled)->toBeArray();
expect(count($prefilled))->toBeGreaterThanOrEqual(3);
// Find the managed support entry
$managedGroup = PlanConfigGroup::where('name', 'VPS Managed Support')->first();
$managedOption = $managedGroup->options->firstWhere('name', 'Managed Support');
$pilotValue = $managedOption->values->firstWhere('value', 'pilot');
$managedSelection = collect($prefilled)->firstWhere('option_id', $managedOption->id);
expect($managedSelection)->not->toBeNull();
expect($managedSelection['value_id'])->toBe($pilotValue->id);
// Find the backup entry
$backupGroup = PlanConfigGroup::where('name', 'Off-site Backup')->first();
$backupOption = $backupGroup->options->firstWhere('name', 'Backup Tier');
$standardValue = $backupOption->values->firstWhere('value', 'standard');
$backupSelection = collect($prefilled)->firstWhere('option_id', $backupOption->id);
expect($backupSelection['value_id'])->toBe($standardValue->id);
// IPv4 — total of 3 should produce 2 extras
$ipv4Group = PlanConfigGroup::where('name', 'VPS Add-ons')->first();
$ipv4Option = $ipv4Group->options->firstWhere('name', 'IPv4 Addresses');
$ipv4Selection = collect($prefilled)->firstWhere('option_id', $ipv4Option->id);
expect($ipv4Selection['quantity'])->toBe(2);
});
test('checkout pre-fill skips defaults when no params present', function (): void {
$user = \App\Models\User::factory()->create();
$user->assignRole('customer');
$vps4 = Plan::where('slug', 'vps-4')->first();
$accountUrl = 'http://'.config('app.domains.account');
$response = $this->actingAs($user)->get("{$accountUrl}/checkout/{$vps4->id}");
$response->assertOk();
$page = $response->viewData('page');
expect($page['props']['prefilledSelections'])->toBe([]);
});