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:
@@ -79,6 +79,8 @@ class CheckoutController extends Controller
|
|||||||
->orderBy('sort_order')
|
->orderBy('sort_order')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
|
$prefilledSelections = $this->buildPrefilledSelections($configGroups, request());
|
||||||
|
|
||||||
return Inertia::render('Checkout/Show', [
|
return Inertia::render('Checkout/Show', [
|
||||||
'plan' => $plan->load('prices'),
|
'plan' => $plan->load('prices'),
|
||||||
'configGroups' => $configGroups,
|
'configGroups' => $configGroups,
|
||||||
@@ -87,9 +89,115 @@ class CheckoutController extends Controller
|
|||||||
'stripeKey' => config('cashier.key'),
|
'stripeKey' => config('cashier.key'),
|
||||||
'osTemplates' => $osTemplates,
|
'osTemplates' => $osTemplates,
|
||||||
'osTemplateGroups' => $osTemplateGroups,
|
'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
|
public function showCustom(string $serviceType): Response
|
||||||
{
|
{
|
||||||
$plan = Plan::where('slug', "{$serviceType}-custom")->firstOrFail();
|
$plan = Plan::where('slug', "{$serviceType}-custom")->firstOrFail();
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class HandleInertiaRequests extends Middleware
|
|||||||
'account' => config('app.domains.account'),
|
'account' => config('app.domains.account'),
|
||||||
'admin' => config('app.domains.admin'),
|
'admin' => config('app.domains.admin'),
|
||||||
],
|
],
|
||||||
|
'panels' => fn () => config('app.panels'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
|
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
if ($this->app->environment('production', 'local')) {
|
if ($this->app->environment('production')) {
|
||||||
URL::forceScheme('https');
|
URL::forceScheme('https');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -139,6 +139,24 @@ return [
|
|||||||
'admin' => env('DOMAIN_ADMIN', 'admin.ezscale.dev'),
|
'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
|
| Screenshot Authentication
|
||||||
|
|||||||
@@ -70,13 +70,13 @@ class ConfigOptionSeeder extends Seeder
|
|||||||
|
|
||||||
private function seedPresetGroups(): void
|
private function seedPresetGroups(): void
|
||||||
{
|
{
|
||||||
// ─── Server Management (all dedicated + VPS plans) ──────────────
|
// ─── Server Management (dedicated only) ─────────────────────────
|
||||||
$serverMgmt = PlanConfigGroup::updateOrCreate(
|
$serverMgmt = PlanConfigGroup::updateOrCreate(
|
||||||
['name' => 'Server Management'],
|
['name' => 'Server Management'],
|
||||||
[
|
[
|
||||||
'description' => 'Add managed support to your server.',
|
'description' => 'Add managed support to your server.',
|
||||||
'mode' => 'preset',
|
'mode' => 'preset',
|
||||||
'service_type' => null,
|
'service_type' => 'dedicated',
|
||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
'sort_order' => 10,
|
'sort_order' => 10,
|
||||||
],
|
],
|
||||||
@@ -89,12 +89,66 @@ class ConfigOptionSeeder extends Seeder
|
|||||||
['label' => 'Fully Managed', 'value' => 'fully_managed', 'monthly' => 60.00],
|
['label' => 'Fully Managed', 'value' => 'fully_managed', 'monthly' => 60.00],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Attach to all active dedicated AND vps plans
|
// Attach to dedicated plans only (VPS plans use the dedicated VPS Managed Support group below)
|
||||||
$dedicatedAndVpsPlans = Plan::query()
|
$dedicatedPlanIds = Plan::query()
|
||||||
->whereIn('service_type', ['dedicated', 'vps'])
|
->where('service_type', 'dedicated')
|
||||||
->whereIn('status', ['active', 'internal'])
|
->whereIn('status', ['active', 'internal'])
|
||||||
->pluck('id');
|
->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 ────────────────────────────────────────────────
|
// ─── VPS Add-ons ────────────────────────────────────────────────
|
||||||
$vpsAddons = PlanConfigGroup::updateOrCreate(
|
$vpsAddons = PlanConfigGroup::updateOrCreate(
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ class PlanSeeder extends Seeder
|
|||||||
'price' => 5.00,
|
'price' => 5.00,
|
||||||
'billing_cycle' => 'monthly',
|
'billing_cycle' => 'monthly',
|
||||||
'features' => [
|
'features' => [
|
||||||
|
'tier' => 1,
|
||||||
'cpu' => '1 vCPU',
|
'cpu' => '1 vCPU',
|
||||||
'ram' => '1 GB',
|
'ram' => '1 GB',
|
||||||
'storage' => '25 GB SSD',
|
'storage' => '25 GB SSD',
|
||||||
@@ -67,6 +68,7 @@ class PlanSeeder extends Seeder
|
|||||||
'price' => 8.00,
|
'price' => 8.00,
|
||||||
'billing_cycle' => 'monthly',
|
'billing_cycle' => 'monthly',
|
||||||
'features' => [
|
'features' => [
|
||||||
|
'tier' => 2,
|
||||||
'cpu' => '1 vCPU',
|
'cpu' => '1 vCPU',
|
||||||
'ram' => '2 GB',
|
'ram' => '2 GB',
|
||||||
'storage' => '50 GB SSD',
|
'storage' => '50 GB SSD',
|
||||||
@@ -87,6 +89,7 @@ class PlanSeeder extends Seeder
|
|||||||
'price' => 0.00,
|
'price' => 0.00,
|
||||||
'billing_cycle' => 'monthly',
|
'billing_cycle' => 'monthly',
|
||||||
'features' => [
|
'features' => [
|
||||||
|
'tier' => 3,
|
||||||
'cpu' => '2 vCPU',
|
'cpu' => '2 vCPU',
|
||||||
'ram' => '3 GB',
|
'ram' => '3 GB',
|
||||||
'storage' => '60 GB SSD',
|
'storage' => '60 GB SSD',
|
||||||
@@ -108,6 +111,7 @@ class PlanSeeder extends Seeder
|
|||||||
'price' => 15.00,
|
'price' => 15.00,
|
||||||
'billing_cycle' => 'monthly',
|
'billing_cycle' => 'monthly',
|
||||||
'features' => [
|
'features' => [
|
||||||
|
'tier' => 4,
|
||||||
'cpu' => '2 vCPU',
|
'cpu' => '2 vCPU',
|
||||||
'ram' => '4 GB',
|
'ram' => '4 GB',
|
||||||
'storage' => '80 GB SSD',
|
'storage' => '80 GB SSD',
|
||||||
@@ -128,6 +132,7 @@ class PlanSeeder extends Seeder
|
|||||||
'price' => 30.00,
|
'price' => 30.00,
|
||||||
'billing_cycle' => 'monthly',
|
'billing_cycle' => 'monthly',
|
||||||
'features' => [
|
'features' => [
|
||||||
|
'tier' => 8,
|
||||||
'cpu' => '4 vCPU',
|
'cpu' => '4 vCPU',
|
||||||
'ram' => '8 GB',
|
'ram' => '8 GB',
|
||||||
'storage' => '160 GB SSD',
|
'storage' => '160 GB SSD',
|
||||||
@@ -148,6 +153,7 @@ class PlanSeeder extends Seeder
|
|||||||
'price' => 55.00,
|
'price' => 55.00,
|
||||||
'billing_cycle' => 'monthly',
|
'billing_cycle' => 'monthly',
|
||||||
'features' => [
|
'features' => [
|
||||||
|
'tier' => 16,
|
||||||
'cpu' => '6 vCPU',
|
'cpu' => '6 vCPU',
|
||||||
'ram' => '16 GB',
|
'ram' => '16 GB',
|
||||||
'storage' => '320 GB SSD',
|
'storage' => '320 GB SSD',
|
||||||
@@ -168,6 +174,7 @@ class PlanSeeder extends Seeder
|
|||||||
'price' => 99.00,
|
'price' => 99.00,
|
||||||
'billing_cycle' => 'monthly',
|
'billing_cycle' => 'monthly',
|
||||||
'features' => [
|
'features' => [
|
||||||
|
'tier' => 32,
|
||||||
'cpu' => '8 vCPU',
|
'cpu' => '8 vCPU',
|
||||||
'ram' => '32 GB',
|
'ram' => '32 GB',
|
||||||
'storage' => '640 GB SSD',
|
'storage' => '640 GB SSD',
|
||||||
@@ -188,6 +195,7 @@ class PlanSeeder extends Seeder
|
|||||||
'price' => 18.00,
|
'price' => 18.00,
|
||||||
'billing_cycle' => 'monthly',
|
'billing_cycle' => 'monthly',
|
||||||
'features' => [
|
'features' => [
|
||||||
|
'tier' => 4,
|
||||||
'cpu' => '2 vCPU',
|
'cpu' => '2 vCPU',
|
||||||
'ram' => '2 GB',
|
'ram' => '2 GB',
|
||||||
'storage' => '500 GB SSD',
|
'storage' => '500 GB SSD',
|
||||||
@@ -208,6 +216,7 @@ class PlanSeeder extends Seeder
|
|||||||
'price' => 28.00,
|
'price' => 28.00,
|
||||||
'billing_cycle' => 'monthly',
|
'billing_cycle' => 'monthly',
|
||||||
'features' => [
|
'features' => [
|
||||||
|
'tier' => 4,
|
||||||
'cpu' => '2 vCPU',
|
'cpu' => '2 vCPU',
|
||||||
'ram' => '4 GB',
|
'ram' => '4 GB',
|
||||||
'storage' => '1 TB SSD',
|
'storage' => '1 TB SSD',
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import ThemeSwitcher from '@/Components/ThemeSwitcher.vue'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
showHamburger?: boolean
|
showHamburger?: boolean
|
||||||
}
|
}
|
||||||
@@ -48,7 +46,5 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
<!-- Right side slot for layout-specific content -->
|
<!-- Right side slot for layout-specific content -->
|
||||||
<slot />
|
<slot />
|
||||||
|
|
||||||
<ThemeSwitcher />
|
|
||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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 9–9 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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -8,6 +8,7 @@ import CommandPalette from '@/Components/CommandPalette.vue'
|
|||||||
import NotificationPanel from '@/Components/NotificationPanel.vue'
|
import NotificationPanel from '@/Components/NotificationPanel.vue'
|
||||||
import ToastStack from '@/Components/ToastStack.vue'
|
import ToastStack from '@/Components/ToastStack.vue'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
|
import { crossDomainUrl } from '@/utils/resolvers'
|
||||||
|
|
||||||
interface AuthUser {
|
interface AuthUser {
|
||||||
name: string
|
name: string
|
||||||
@@ -25,7 +26,7 @@ const page = usePage()
|
|||||||
const props = computed(() => page.props as unknown as PageProps)
|
const props = computed(() => page.props as unknown as PageProps)
|
||||||
const user = computed(() => props.value.auth?.user)
|
const user = computed(() => props.value.auth?.user)
|
||||||
const isImpersonating = computed(() => props.value.impersonating)
|
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 sidebarCollapsed = ref<boolean>(false)
|
||||||
const mobileOpen = ref<boolean>(false)
|
const mobileOpen = ref<boolean>(false)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import CommandPalette from '@/Components/CommandPalette.vue'
|
|||||||
import NotificationPanel from '@/Components/NotificationPanel.vue'
|
import NotificationPanel from '@/Components/NotificationPanel.vue'
|
||||||
import ToastStack from '@/Components/ToastStack.vue'
|
import ToastStack from '@/Components/ToastStack.vue'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
|
import { crossDomainUrl } from '@/utils/resolvers'
|
||||||
|
|
||||||
interface AuthUser {
|
interface AuthUser {
|
||||||
name: string
|
name: string
|
||||||
@@ -23,7 +24,7 @@ interface PageProps {
|
|||||||
const page = usePage()
|
const page = usePage()
|
||||||
const props = computed(() => page.props as unknown as PageProps)
|
const props = computed(() => page.props as unknown as PageProps)
|
||||||
const user = computed(() => props.value.auth?.user)
|
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 sidebarCollapsed = ref<boolean>(false)
|
||||||
const mobileOpen = ref<boolean>(false)
|
const mobileOpen = ref<boolean>(false)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { Link, usePage } from '@inertiajs/vue3'
|
import { Link, usePage } from '@inertiajs/vue3'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { crossDomainUrl } from '@/utils/resolvers'
|
||||||
import logoWhite from '@images/ezscale_logo_white.png'
|
import logoWhite from '@images/ezscale_logo_white.png'
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
@@ -9,7 +10,7 @@ interface PageProps {
|
|||||||
|
|
||||||
const page = usePage()
|
const page = usePage()
|
||||||
const props = computed(() => page.props as unknown as PageProps)
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { usePage } from '@inertiajs/vue3'
|
|||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { useTheme } from 'vuetify'
|
import { useTheme } from 'vuetify'
|
||||||
import { marketingNavItems } from '@/navigation/marketing'
|
import { marketingNavItems } from '@/navigation/marketing'
|
||||||
import ThemeSwitcher from '@/Components/ThemeSwitcher.vue'
|
import { crossDomainUrl } from '@/utils/resolvers'
|
||||||
import logoWhite from '@images/ezscale_logo_white.png'
|
import logoWhite from '@images/ezscale_logo_white.png'
|
||||||
|
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
@@ -35,7 +35,7 @@ interface PageProps {
|
|||||||
|
|
||||||
const page = usePage()
|
const page = usePage()
|
||||||
const props = computed(() => page.props as unknown as PageProps)
|
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 = {
|
const footerLinks = {
|
||||||
products: [
|
products: [
|
||||||
@@ -132,8 +132,6 @@ const mobileMenuOpen = ref(false)
|
|||||||
<VSpacer />
|
<VSpacer />
|
||||||
|
|
||||||
<div class="d-flex align-center ga-2">
|
<div class="d-flex align-center ga-2">
|
||||||
<ThemeSwitcher />
|
|
||||||
|
|
||||||
<a :href="accountUrl + '/login'" class="text-decoration-none d-none d-sm-inline">
|
<a :href="accountUrl + '/login'" class="text-decoration-none d-none d-sm-inline">
|
||||||
<VBtn variant="text" size="small">Login</VBtn>
|
<VBtn variant="text" size="small">Login</VBtn>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { Link, useForm, router, usePage } from '@inertiajs/vue3'
|
import { Link, useForm, router, usePage } from '@inertiajs/vue3'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import AccountLayout from '@/Layouts/AccountLayout.vue'
|
import AccountLayout from '@/Layouts/AccountLayout.vue'
|
||||||
|
import { crossDomainUrl } from '@/utils/resolvers'
|
||||||
|
|
||||||
defineOptions({ layout: AccountLayout })
|
defineOptions({ layout: AccountLayout })
|
||||||
|
|
||||||
@@ -38,7 +39,7 @@ interface PageProps {
|
|||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
const page = usePage()
|
const page = usePage()
|
||||||
const pageProps = computed(() => page.props as unknown as PageProps)
|
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)
|
const removeLoading = ref<number | null>(null)
|
||||||
|
|
||||||
@@ -110,7 +111,7 @@ function proceedToCheckout(): void {
|
|||||||
<div class="text-body-2 text-medium-emphasis mb-6">
|
<div class="text-body-2 text-medium-emphasis mb-6">
|
||||||
Browse our plans and add items to get started.
|
Browse our plans and add items to get started.
|
||||||
</div>
|
</div>
|
||||||
<Link :href="`https://${pageProps.domains?.marketing}/pricing`">
|
<Link :href="crossDomainUrl(pageProps.domains?.marketing) + '/pricing'">
|
||||||
<VBtn color="primary" prepend-icon="tabler-package">
|
<VBtn color="primary" prepend-icon="tabler-package">
|
||||||
Browse Plans
|
Browse Plans
|
||||||
</VBtn>
|
</VBtn>
|
||||||
@@ -235,7 +236,7 @@ function proceedToCheckout(): void {
|
|||||||
Proceed to Checkout
|
Proceed to Checkout
|
||||||
</VBtn>
|
</VBtn>
|
||||||
|
|
||||||
<Link :href="`https://${pageProps.domains?.marketing}/pricing`" class="text-decoration-none">
|
<Link :href="crossDomainUrl(pageProps.domains?.marketing) + '/pricing'" class="text-decoration-none">
|
||||||
<VBtn
|
<VBtn
|
||||||
variant="text"
|
variant="text"
|
||||||
block
|
block
|
||||||
|
|||||||
@@ -21,6 +21,15 @@ interface OSTemplateGroup {
|
|||||||
templates: OSTemplate[]
|
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 {
|
interface Props {
|
||||||
plan: Plan
|
plan: Plan
|
||||||
paymentMethods: PaymentMethod[]
|
paymentMethods: PaymentMethod[]
|
||||||
@@ -30,6 +39,7 @@ interface Props {
|
|||||||
osTemplateGroups: OSTemplateGroup[]
|
osTemplateGroups: OSTemplateGroup[]
|
||||||
configGroups?: PlanConfigGroup[]
|
configGroups?: PlanConfigGroup[]
|
||||||
mode?: 'standard' | 'custom'
|
mode?: 'standard' | 'custom'
|
||||||
|
prefilledSelections?: PrefilledSelection[]
|
||||||
}
|
}
|
||||||
|
|
||||||
defineOptions({ layout: AccountLayout })
|
defineOptions({ layout: AccountLayout })
|
||||||
@@ -63,16 +73,11 @@ const generatedPrivateKey = ref('')
|
|||||||
const isGenerating = ref(false)
|
const isGenerating = ref(false)
|
||||||
const expandedPanels = ref([0]) // Default to first panel (index 0) expanded
|
const expandedPanels = ref([0]) // Default to first panel (index 0) expanded
|
||||||
|
|
||||||
// Configurable options state
|
// Configurable options state — seeded from server-provided prefilledSelections (estimator deep-link)
|
||||||
const configSelections = ref<Array<{
|
const configSelections = ref<PrefilledSelection[]>(props.prefilledSelections ? [...props.prefilledSelections] : [])
|
||||||
option_id: number
|
const configTotalPrice = ref<number>(
|
||||||
value_id: number | null
|
props.prefilledSelections?.reduce((sum, s) => sum + (s.locked_price || 0), 0) ?? 0
|
||||||
quantity: number | null
|
)
|
||||||
text_value: string | null
|
|
||||||
locked_price: number
|
|
||||||
locked_hourly_price: number | null
|
|
||||||
}>>([])
|
|
||||||
const configTotalPrice = ref<number>(0)
|
|
||||||
|
|
||||||
const hasConfigGroups = computed<boolean>(() => {
|
const hasConfigGroups = computed<boolean>(() => {
|
||||||
return (props.configGroups ?? []).length > 0
|
return (props.configGroups ?? []).length > 0
|
||||||
|
|||||||
@@ -302,7 +302,7 @@ const sections: Section[] = [
|
|||||||
</div>
|
</div>
|
||||||
<div class="d-flex align-center ga-2">
|
<div class="d-flex align-center ga-2">
|
||||||
<VIcon icon="tabler-world" size="20" class="text-medium-emphasis" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import SectionHeader from '@/Components/Marketing/SectionHeader.vue'
|
|||||||
import HeroSection from '@/Components/Marketing/HeroSection.vue'
|
import HeroSection from '@/Components/Marketing/HeroSection.vue'
|
||||||
import DedicatedHero from '@/Components/Marketing/DedicatedHero.vue'
|
import DedicatedHero from '@/Components/Marketing/DedicatedHero.vue'
|
||||||
import ScrollReveal from '@/Components/Marketing/ScrollReveal.vue'
|
import ScrollReveal from '@/Components/Marketing/ScrollReveal.vue'
|
||||||
|
import { crossDomainUrl } from '@/utils/resolvers'
|
||||||
|
|
||||||
defineOptions({ layout: MarketingLayout })
|
defineOptions({ layout: MarketingLayout })
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@ interface PageProps {
|
|||||||
|
|
||||||
const page = usePage()
|
const page = usePage()
|
||||||
const props = computed(() => page.props as unknown as PageProps)
|
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 plans = computed(() => props.value.plans || [])
|
||||||
|
|
||||||
const features = [
|
const features = [
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import SectionHeader from '@/Components/Marketing/SectionHeader.vue'
|
|||||||
import HeroSection from '@/Components/Marketing/HeroSection.vue'
|
import HeroSection from '@/Components/Marketing/HeroSection.vue'
|
||||||
import NetworkHero from '@/Components/Marketing/NetworkHero.vue'
|
import NetworkHero from '@/Components/Marketing/NetworkHero.vue'
|
||||||
import ScrollReveal from '@/Components/Marketing/ScrollReveal.vue'
|
import ScrollReveal from '@/Components/Marketing/ScrollReveal.vue'
|
||||||
|
import { crossDomainUrl } from '@/utils/resolvers'
|
||||||
|
|
||||||
defineOptions({ layout: MarketingLayout })
|
defineOptions({ layout: MarketingLayout })
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ interface PageProps {
|
|||||||
|
|
||||||
const page = usePage()
|
const page = usePage()
|
||||||
const pageProps = computed(() => page.props as unknown as PageProps)
|
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 {
|
function formatStartingPrice(price: string | null, fallback: string): string {
|
||||||
if (!price) return fallback
|
if (!price) return fallback
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { computed, ref, watch } from 'vue'
|
|||||||
import MarketingLayout from '@/Layouts/MarketingLayout.vue'
|
import MarketingLayout from '@/Layouts/MarketingLayout.vue'
|
||||||
import BuildYourOwn from '@/Components/Marketing/BuildYourOwn.vue'
|
import BuildYourOwn from '@/Components/Marketing/BuildYourOwn.vue'
|
||||||
import ScrollReveal from '@/Components/Marketing/ScrollReveal.vue'
|
import ScrollReveal from '@/Components/Marketing/ScrollReveal.vue'
|
||||||
|
import { crossDomainUrl } from '@/utils/resolvers'
|
||||||
import type { PlanConfigGroup } from '@/types'
|
import type { PlanConfigGroup } from '@/types'
|
||||||
|
|
||||||
defineOptions({ layout: MarketingLayout })
|
defineOptions({ layout: MarketingLayout })
|
||||||
@@ -47,7 +48,7 @@ interface PageProps {
|
|||||||
|
|
||||||
const page = usePage()
|
const page = usePage()
|
||||||
const props = computed(() => page.props as unknown as PageProps)
|
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
|
// BYO mode toggle
|
||||||
const pricingMode = ref<'preset' | 'byo'>('preset')
|
const pricingMode = ref<'preset' | 'byo'>('preset')
|
||||||
|
|||||||
@@ -300,7 +300,7 @@ const sections: Section[] = [
|
|||||||
</div>
|
</div>
|
||||||
<div class="d-flex align-center ga-2">
|
<div class="d-flex align-center ga-2">
|
||||||
<VIcon icon="tabler-world" size="20" class="text-medium-emphasis" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
|
|||||||
@@ -366,7 +366,7 @@ const creditTiers: CreditTier[] = [
|
|||||||
</div>
|
</div>
|
||||||
<div class="d-flex align-center ga-2">
|
<div class="d-flex align-center ga-2">
|
||||||
<VIcon icon="tabler-world" size="20" class="text-medium-emphasis" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
|
|||||||
@@ -318,7 +318,7 @@ const sections: Section[] = [
|
|||||||
</div>
|
</div>
|
||||||
<div class="d-flex align-center ga-2">
|
<div class="d-flex align-center ga-2">
|
||||||
<VIcon icon="tabler-world" size="20" class="text-medium-emphasis" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
|
|||||||
@@ -5,21 +5,17 @@ import MarketingLayout from '@/Layouts/MarketingLayout.vue'
|
|||||||
import SectionHeader from '@/Components/Marketing/SectionHeader.vue'
|
import SectionHeader from '@/Components/Marketing/SectionHeader.vue'
|
||||||
import HeroSection from '@/Components/Marketing/HeroSection.vue'
|
import HeroSection from '@/Components/Marketing/HeroSection.vue'
|
||||||
import VpsHero from '@/Components/Marketing/VpsHero.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 })
|
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 {
|
interface PageProps {
|
||||||
plans: Plan[]
|
plans: EstimatorPlan[]
|
||||||
|
addOns: EstimatorAddOnGroup[]
|
||||||
|
workloadMap: Record<string, WorkloadEntry>
|
||||||
|
appExamples: AppExample[]
|
||||||
domains: { marketing: string; account: string; admin: string }
|
domains: { marketing: string; account: string; admin: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,35 +25,51 @@ interface Feature {
|
|||||||
description: string
|
description: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface IncludedItem {
|
||||||
|
text: string
|
||||||
|
comingSoon?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
const page = usePage()
|
const page = usePage()
|
||||||
const props = computed(() => page.props as unknown as PageProps)
|
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 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>(() => {
|
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)))
|
const lowest = Math.min(...plans.value.map(p => parseFloat(p.price)))
|
||||||
return lowest % 1 === 0 ? lowest.toString() : lowest.toFixed(2)
|
return lowest % 1 === 0 ? lowest.toString() : lowest.toFixed(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
const features: Feature[] = [
|
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-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-server', title: 'KVM Virtualization', description: 'Full hardware virtualization for predictable, dedicated performance.' },
|
||||||
{ icon: 'tabler-rocket', title: 'Instant Provisioning', description: 'Your server is deployed within seconds of ordering.' },
|
{ icon: 'tabler-rocket', title: 'Near-Instant Provisioning', description: 'Your VPS is deployed seconds after ordering.' },
|
||||||
{ icon: 'tabler-refresh', title: 'VM Backups', description: 'Built-in VM backup and snapshot functionality.' },
|
{ 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-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[] = [
|
const includedFeatures: IncludedItem[] = [
|
||||||
'1 IPv4 & 1 /64 IPv6',
|
{ text: '1 free IPv4 + 1 /64 IPv6 block' },
|
||||||
'Near instant provisioning',
|
{ text: 'IPv4 rDNS / PTR control' },
|
||||||
'VM backups',
|
{ text: '10 Gbps shared uplink (fair-use per AUP)' },
|
||||||
'Windows (BYOL) & Linux support',
|
{ text: 'RAID 10 SSD storage' },
|
||||||
'Full root access',
|
{ text: 'ZFS storage snapshots (free)' },
|
||||||
'VirtFusion control panel',
|
{ text: 'KVM virtualization' },
|
||||||
'RAID 10 backed storage',
|
{ text: 'Full root access' },
|
||||||
'14-day money back guarantee',
|
{ 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
|
// Keys from features JSON that should not be shown as table columns
|
||||||
@@ -66,16 +78,28 @@ const internalKeys = new Set([
|
|||||||
'os',
|
'os',
|
||||||
'ipv4',
|
'ipv4',
|
||||||
'ipv6',
|
'ipv6',
|
||||||
|
'tier',
|
||||||
])
|
])
|
||||||
|
|
||||||
function getFeature(plan: Plan, key: string): string {
|
function getFeature(plan: EstimatorPlan, key: string): string {
|
||||||
return String(plan.features?.[key] ?? '-')
|
return String(plan.features?.[key] ?? '-')
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPrice(plan: Plan): string {
|
function formatPrice(plan: EstimatorPlan): string {
|
||||||
const price = parseFloat(plan.price) || 0
|
const price = parseFloat(plan.price) || 0
|
||||||
return price % 1 === 0 ? `$${price}` : `$${price.toFixed(2)}`
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -107,6 +131,15 @@ function formatPrice(plan: Plan): string {
|
|||||||
</template>
|
</template>
|
||||||
</HeroSection>
|
</HeroSection>
|
||||||
|
|
||||||
|
<!-- Estimator -->
|
||||||
|
<EstimatorSection
|
||||||
|
:plans="plans"
|
||||||
|
:add-ons="addOns"
|
||||||
|
:workload-map="workloadMap"
|
||||||
|
:app-examples="appExamples"
|
||||||
|
:account-url="accountUrl"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Features -->
|
<!-- Features -->
|
||||||
<VContainer class="marketing-section">
|
<VContainer class="marketing-section">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
@@ -163,9 +196,20 @@ function formatPrice(plan: Plan): string {
|
|||||||
<td>{{ getFeature(plan, 'bandwidth') }}</td>
|
<td>{{ getFeature(plan, 'bandwidth') }}</td>
|
||||||
<td class="text-primary font-weight-bold">{{ formatPrice(plan) }}/mo</td>
|
<td class="text-primary font-weight-bold">{{ formatPrice(plan) }}/mo</td>
|
||||||
<td>
|
<td>
|
||||||
|
<div class="d-flex align-center ga-2">
|
||||||
<a :href="accountUrl + '/checkout/' + plan.id" class="text-decoration-none">
|
<a :href="accountUrl + '/checkout/' + plan.id" class="text-decoration-none">
|
||||||
<VBtn color="primary" size="small" variant="tonal">Order Now</VBtn>
|
<VBtn color="primary" size="small" variant="tonal">Order Now</VBtn>
|
||||||
</a>
|
</a>
|
||||||
|
<VBtn
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
color="primary"
|
||||||
|
class="text-caption"
|
||||||
|
@click="prefillEstimator(plan.id)"
|
||||||
|
>
|
||||||
|
Estimate →
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -179,14 +223,28 @@ function formatPrice(plan: Plan): string {
|
|||||||
<VRow>
|
<VRow>
|
||||||
<VCol
|
<VCol
|
||||||
v-for="item in includedFeatures"
|
v-for="item in includedFeatures"
|
||||||
:key="item"
|
:key="item.text"
|
||||||
cols="12"
|
cols="12"
|
||||||
sm="6"
|
sm="6"
|
||||||
md="4"
|
md="4"
|
||||||
>
|
>
|
||||||
<div class="d-flex align-center ga-2 mb-2">
|
<div class="d-flex align-center ga-2 mb-2">
|
||||||
<VIcon icon="tabler-circle-check" color="success" size="20" />
|
<VIcon
|
||||||
<span class="text-body-1">{{ item }}</span>
|
: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>
|
</div>
|
||||||
</VCol>
|
</VCol>
|
||||||
</VRow>
|
</VRow>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import SectionHeader from '@/Components/Marketing/SectionHeader.vue'
|
|||||||
import HeroSection from '@/Components/Marketing/HeroSection.vue'
|
import HeroSection from '@/Components/Marketing/HeroSection.vue'
|
||||||
import WebHostingHero from '@/Components/Marketing/WebHostingHero.vue'
|
import WebHostingHero from '@/Components/Marketing/WebHostingHero.vue'
|
||||||
import ScrollReveal from '@/Components/Marketing/ScrollReveal.vue'
|
import ScrollReveal from '@/Components/Marketing/ScrollReveal.vue'
|
||||||
|
import { crossDomainUrl } from '@/utils/resolvers'
|
||||||
|
|
||||||
defineOptions({ layout: MarketingLayout })
|
defineOptions({ layout: MarketingLayout })
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@ interface Feature {
|
|||||||
|
|
||||||
const page = usePage()
|
const page = usePage()
|
||||||
const props = computed(() => page.props as unknown as PageProps)
|
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 plans = computed(() => props.value.plans || [])
|
||||||
|
|
||||||
const startingPrice = computed<string>(() => {
|
const startingPrice = computed<string>(() => {
|
||||||
|
|||||||
@@ -46,8 +46,12 @@ function formatDateTime(dateStr: string | null): string {
|
|||||||
return new Date(dateStr).toLocaleString()
|
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>(() => {
|
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')
|
const isSuspended = computed<boolean>(() => props.service.status === 'suspended')
|
||||||
|
|||||||
@@ -7,22 +7,11 @@ import { themes } from './theme'
|
|||||||
import 'vuetify/styles'
|
import 'vuetify/styles'
|
||||||
|
|
||||||
export default function installVuetify(app: App): void {
|
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({
|
const vuetify = createVuetify({
|
||||||
defaults,
|
defaults,
|
||||||
icons,
|
icons,
|
||||||
theme: {
|
theme: {
|
||||||
defaultTheme,
|
defaultTheme: 'dark',
|
||||||
themes,
|
themes,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
304
website/resources/ts/stores/estimator.ts
Normal file
304
website/resources/ts/stores/estimator.ts
Normal 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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -65,16 +65,21 @@ export function resolveServiceTypeColor(type: string): StatusColor {
|
|||||||
return map[type] ?? 'secondary'
|
return map[type] ?? 'secondary'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolvePlatformUrl(platform: string, platformServiceId: string | null): string | null {
|
export function crossDomainUrl(domain: string | undefined | null): string {
|
||||||
if (!platformServiceId) return null
|
if (!domain) return ''
|
||||||
|
const scheme = typeof window !== 'undefined' ? window.location.protocol : 'https:'
|
||||||
const urls: Record<string, string> = {
|
return `${scheme}//${domain}`
|
||||||
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}`,
|
|
||||||
}
|
}
|
||||||
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 {
|
export function resolveTicketStatusColor(status: string): StatusColor {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<meta property="og:title" content="EZSCALE - Cloud Hosting Made Simple">
|
<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: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:type" content="website">
|
||||||
<meta property="og:url" content="https://ezscale.cloud">
|
<meta property="og:url" content="{{ url()->current() }}">
|
||||||
<meta name="twitter:card" content="summary">
|
<meta name="twitter:card" content="summary">
|
||||||
|
|
||||||
@vite(['resources/ts/app.ts'])
|
@vite(['resources/ts/app.ts'])
|
||||||
|
|||||||
@@ -31,12 +31,53 @@ Route::get('/vps-hosting', function () {
|
|||||||
$plans = Plan::query()
|
$plans = Plan::query()
|
||||||
->where('service_type', 'vps')
|
->where('service_type', 'vps')
|
||||||
->where('status', 'active')
|
->where('status', 'active')
|
||||||
|
->with('prices')
|
||||||
->orderBy('sort_order')
|
->orderBy('sort_order')
|
||||||
->orderBy('price')
|
->orderBy('price')
|
||||||
->get();
|
->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', [
|
return Inertia::render('Marketing/VpsHosting', [
|
||||||
'plans' => $plans,
|
'plans' => $plans,
|
||||||
|
'addOns' => $addOns,
|
||||||
|
'workloadMap' => $workloadMap,
|
||||||
|
'appExamples' => $appExamples,
|
||||||
]);
|
]);
|
||||||
})->name('vps-hosting');
|
})->name('vps-hosting');
|
||||||
Route::get('/dedicated-servers', function () {
|
Route::get('/dedicated-servers', function () {
|
||||||
|
|||||||
156
website/tests/Feature/Marketing/VpsHostingEstimatorTest.php
Normal file
156
website/tests/Feature/Marketing/VpsHostingEstimatorTest.php
Normal 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([]);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user