diff --git a/website/app/Http/Controllers/Account/CheckoutController.php b/website/app/Http/Controllers/Account/CheckoutController.php index c4dd8d9..fc6a33f 100644 --- a/website/app/Http/Controllers/Account/CheckoutController.php +++ b/website/app/Http/Controllers/Account/CheckoutController.php @@ -79,6 +79,8 @@ class CheckoutController extends Controller ->orderBy('sort_order') ->get(); + $prefilledSelections = $this->buildPrefilledSelections($configGroups, request()); + return Inertia::render('Checkout/Show', [ 'plan' => $plan->load('prices'), 'configGroups' => $configGroups, @@ -87,9 +89,115 @@ class CheckoutController extends Controller 'stripeKey' => config('cashier.key'), 'osTemplates' => $osTemplates, 'osTemplateGroups' => $osTemplateGroups, + 'prefilledSelections' => $prefilledSelections, ]); } + /** + * Build pre-filled config selections from query params for estimator deep-links. + * + * Recognized params: + * ?ipv4=N — total IPv4 count (1-8). N>1 sets the IPv4 quantity option to (N - 1) extras. + * ?windows=1 — toggles the Windows License checkbox. + * ?managed=X — value slug for the VPS Managed Support radio (self|basic|pro|pilot). + * ?backup=X — value slug for the Off-site Backup radio (none|lite|standard|extended|vault). + * + * @param \Illuminate\Database\Eloquent\Collection $configGroups + * @return array + */ + private function buildPrefilledSelections($configGroups, Request $request): array + { + $selections = []; + + $findOption = function (string $groupName, string $optionName) use ($configGroups): ?PlanConfigOption { + foreach ($configGroups as $group) { + if ($group->name !== $groupName) { + continue; + } + foreach ($group->options as $option) { + if ($option->name === $optionName) { + return $option; + } + } + } + + return null; + }; + + // IPv4 — total count, first is included free, charge starts at extra #1 + $ipv4Total = (int) $request->query('ipv4', 1); + if ($ipv4Total > 1) { + $option = $findOption('VPS Add-ons', 'IPv4 Addresses'); + if ($option) { + $extras = max(0, $ipv4Total - 1); + $selections[] = [ + 'option_id' => $option->id, + 'value_id' => null, + 'quantity' => $extras, + 'text_value' => null, + 'locked_price' => (float) ($option->monthly_price ?? 0) * $extras, + 'locked_hourly_price' => $option->hourly_price !== null ? (float) $option->hourly_price * $extras : null, + ]; + } + } + + // Windows License — single-value checkbox + if ($request->boolean('windows')) { + $option = $findOption('VPS Add-ons', 'Windows License'); + if ($option && $option->values->isNotEmpty()) { + $value = $option->values->first(); + $selections[] = [ + 'option_id' => $option->id, + 'value_id' => $value->id, + 'quantity' => null, + 'text_value' => null, + 'locked_price' => (float) ($value->monthly_price ?? 0), + 'locked_hourly_price' => $value->hourly_price !== null ? (float) $value->hourly_price : null, + ]; + } + } + + // Managed Support — radio + $managed = $request->query('managed'); + if ($managed && $managed !== 'self') { + $option = $findOption('VPS Managed Support', 'Managed Support'); + if ($option) { + $value = $option->values->firstWhere('value', $managed); + if ($value) { + $selections[] = [ + 'option_id' => $option->id, + 'value_id' => $value->id, + 'quantity' => null, + 'text_value' => null, + 'locked_price' => (float) ($value->monthly_price ?? 0), + 'locked_hourly_price' => $value->hourly_price !== null ? (float) $value->hourly_price : null, + ]; + } + } + } + + // Off-site Backup — radio + $backup = $request->query('backup'); + if ($backup && $backup !== 'none') { + $option = $findOption('Off-site Backup', 'Backup Tier'); + if ($option) { + $value = $option->values->firstWhere('value', $backup); + if ($value) { + $selections[] = [ + 'option_id' => $option->id, + 'value_id' => $value->id, + 'quantity' => null, + 'text_value' => null, + 'locked_price' => (float) ($value->monthly_price ?? 0), + 'locked_hourly_price' => $value->hourly_price !== null ? (float) $value->hourly_price : null, + ]; + } + } + } + + return $selections; + } + public function showCustom(string $serviceType): Response { $plan = Plan::where('slug', "{$serviceType}-custom")->firstOrFail(); diff --git a/website/app/Http/Middleware/HandleInertiaRequests.php b/website/app/Http/Middleware/HandleInertiaRequests.php index fea525d..3f6febc 100644 --- a/website/app/Http/Middleware/HandleInertiaRequests.php +++ b/website/app/Http/Middleware/HandleInertiaRequests.php @@ -27,6 +27,7 @@ class HandleInertiaRequests extends Middleware 'account' => config('app.domains.account'), 'admin' => config('app.domains.admin'), ], + 'panels' => fn () => config('app.panels'), ]); } } diff --git a/website/app/Providers/AppServiceProvider.php b/website/app/Providers/AppServiceProvider.php index c738f9d..31e51ca 100644 --- a/website/app/Providers/AppServiceProvider.php +++ b/website/app/Providers/AppServiceProvider.php @@ -32,7 +32,7 @@ class AppServiceProvider extends ServiceProvider public function boot(): void { - if ($this->app->environment('production', 'local')) { + if ($this->app->environment('production')) { URL::forceScheme('https'); } diff --git a/website/config/app.php b/website/config/app.php index e4af86a..4332306 100644 --- a/website/config/app.php +++ b/website/config/app.php @@ -139,6 +139,24 @@ return [ 'admin' => env('DOMAIN_ADMIN', 'admin.ezscale.dev'), ], + /* + |-------------------------------------------------------------------------- + | Provisioning Panel URLs + |-------------------------------------------------------------------------- + | + | Base URLs for the customer-facing control panels of each provisioning + | platform. The frontend builds deep links by appending /server/{id}. + | Override per-environment via .env so dev/staging point at the right host. + | + */ + + 'panels' => [ + 'virtfusion' => env('PANEL_URL_VIRTFUSION', 'https://panel.ezscale.cloud'), + 'synergycp' => env('PANEL_URL_SYNERGYCP', 'https://dedicated.ezscale.cloud'), + 'enhance' => env('PANEL_URL_ENHANCE', 'https://hosting.ezscale.cloud'), + 'pterodactyl' => env('PANEL_URL_PTERODACTYL', 'https://game.ezscale.cloud'), + ], + /* |-------------------------------------------------------------------------- | Screenshot Authentication diff --git a/website/database/seeders/ConfigOptionSeeder.php b/website/database/seeders/ConfigOptionSeeder.php index 0f0dfa5..9fb42f2 100644 --- a/website/database/seeders/ConfigOptionSeeder.php +++ b/website/database/seeders/ConfigOptionSeeder.php @@ -70,13 +70,13 @@ class ConfigOptionSeeder extends Seeder private function seedPresetGroups(): void { - // ─── Server Management (all dedicated + VPS plans) ────────────── + // ─── Server Management (dedicated only) ───────────────────────── $serverMgmt = PlanConfigGroup::updateOrCreate( ['name' => 'Server Management'], [ 'description' => 'Add managed support to your server.', 'mode' => 'preset', - 'service_type' => null, + 'service_type' => 'dedicated', 'is_active' => true, 'sort_order' => 10, ], @@ -89,12 +89,66 @@ class ConfigOptionSeeder extends Seeder ['label' => 'Fully Managed', 'value' => 'fully_managed', 'monthly' => 60.00], ]); - // Attach to all active dedicated AND vps plans - $dedicatedAndVpsPlans = Plan::query() - ->whereIn('service_type', ['dedicated', 'vps']) + // Attach to dedicated plans only (VPS plans use the dedicated VPS Managed Support group below) + $dedicatedPlanIds = Plan::query() + ->where('service_type', 'dedicated') ->whereIn('status', ['active', 'internal']) ->pluck('id'); - $serverMgmt->plans()->syncWithoutDetaching($dedicatedAndVpsPlans); + $serverMgmt->plans()->sync($dedicatedPlanIds); + + // ─── VPS Managed Support ──────────────────────────────────────── + // 4-tier radio: Self ($0) / Basic ($29) / Pro ($79) / Pilot ($99, VPS-8+). + // Pilot tier gating (min plan tier 8) is enforced in the frontend. + $vpsManaged = PlanConfigGroup::updateOrCreate( + ['name' => 'VPS Managed Support'], + [ + 'description' => 'Choose how hands-on you want our team to be with your VPS.', + 'mode' => 'preset', + 'service_type' => 'vps', + 'is_active' => true, + 'sort_order' => 10, + ], + ); + + $vpsManagedOption = $this->seedRadioOption($vpsManaged, 'Managed Support', false, 1); + $this->seedValues($vpsManagedOption, [ + ['label' => 'Self-Managed', 'value' => 'self', 'monthly' => 0, 'is_default' => true], + ['label' => 'Managed Basic', 'value' => 'basic', 'monthly' => 29.00], + ['label' => 'Managed Pro', 'value' => 'pro', 'monthly' => 79.00], + ['label' => 'Pilot', 'value' => 'pilot', 'monthly' => 99.00], + ]); + + $vpsPlanIds = Plan::query() + ->where('service_type', 'vps') + ->whereIn('status', ['active', 'internal']) + ->pluck('id'); + $vpsManaged->plans()->syncWithoutDetaching($vpsPlanIds); + + // ─── Off-site Backup (VPS) ────────────────────────────────────── + // 5-tier radio: None / Lite (7d, 200GB, $5) / Standard (30d, 500GB, $12) + // / Extended (90d, 1TB, $25) / Vault (1yr, 2TB, $59). + // StorJ-backed, encrypted at rest. Restores included (no egress charge). + $vpsBackup = PlanConfigGroup::updateOrCreate( + ['name' => 'Off-site Backup'], + [ + 'description' => 'Encrypted off-site backups stored on StorJ. Daily snapshots, free restores.', + 'mode' => 'preset', + 'service_type' => 'vps', + 'is_active' => true, + 'sort_order' => 12, + ], + ); + + $vpsBackupOption = $this->seedRadioOption($vpsBackup, 'Backup Tier', false, 1); + $this->seedValues($vpsBackupOption, [ + ['label' => 'None', 'value' => 'none', 'monthly' => 0, 'is_default' => true], + ['label' => 'Lite (7-day, 200 GB)', 'value' => 'lite', 'monthly' => 5.00], + ['label' => 'Standard (30-day, 500 GB)', 'value' => 'standard', 'monthly' => 12.00], + ['label' => 'Extended (90-day, 1 TB)', 'value' => 'extended', 'monthly' => 25.00], + ['label' => 'Vault (1-year, 2 TB)', 'value' => 'vault', 'monthly' => 59.00], + ]); + + $vpsBackup->plans()->syncWithoutDetaching($vpsPlanIds); // ─── VPS Add-ons ──────────────────────────────────────────────── $vpsAddons = PlanConfigGroup::updateOrCreate( diff --git a/website/database/seeders/PlanSeeder.php b/website/database/seeders/PlanSeeder.php index 9c52f44..20566e0 100644 --- a/website/database/seeders/PlanSeeder.php +++ b/website/database/seeders/PlanSeeder.php @@ -47,6 +47,7 @@ class PlanSeeder extends Seeder 'price' => 5.00, 'billing_cycle' => 'monthly', 'features' => [ + 'tier' => 1, 'cpu' => '1 vCPU', 'ram' => '1 GB', 'storage' => '25 GB SSD', @@ -67,6 +68,7 @@ class PlanSeeder extends Seeder 'price' => 8.00, 'billing_cycle' => 'monthly', 'features' => [ + 'tier' => 2, 'cpu' => '1 vCPU', 'ram' => '2 GB', 'storage' => '50 GB SSD', @@ -87,6 +89,7 @@ class PlanSeeder extends Seeder 'price' => 0.00, 'billing_cycle' => 'monthly', 'features' => [ + 'tier' => 3, 'cpu' => '2 vCPU', 'ram' => '3 GB', 'storage' => '60 GB SSD', @@ -108,6 +111,7 @@ class PlanSeeder extends Seeder 'price' => 15.00, 'billing_cycle' => 'monthly', 'features' => [ + 'tier' => 4, 'cpu' => '2 vCPU', 'ram' => '4 GB', 'storage' => '80 GB SSD', @@ -128,6 +132,7 @@ class PlanSeeder extends Seeder 'price' => 30.00, 'billing_cycle' => 'monthly', 'features' => [ + 'tier' => 8, 'cpu' => '4 vCPU', 'ram' => '8 GB', 'storage' => '160 GB SSD', @@ -148,6 +153,7 @@ class PlanSeeder extends Seeder 'price' => 55.00, 'billing_cycle' => 'monthly', 'features' => [ + 'tier' => 16, 'cpu' => '6 vCPU', 'ram' => '16 GB', 'storage' => '320 GB SSD', @@ -168,6 +174,7 @@ class PlanSeeder extends Seeder 'price' => 99.00, 'billing_cycle' => 'monthly', 'features' => [ + 'tier' => 32, 'cpu' => '8 vCPU', 'ram' => '32 GB', 'storage' => '640 GB SSD', @@ -188,6 +195,7 @@ class PlanSeeder extends Seeder 'price' => 18.00, 'billing_cycle' => 'monthly', 'features' => [ + 'tier' => 4, 'cpu' => '2 vCPU', 'ram' => '2 GB', 'storage' => '500 GB SSD', @@ -208,6 +216,7 @@ class PlanSeeder extends Seeder 'price' => 28.00, 'billing_cycle' => 'monthly', 'features' => [ + 'tier' => 4, 'cpu' => '2 vCPU', 'ram' => '4 GB', 'storage' => '1 TB SSD', diff --git a/website/resources/ts/Components/AppTopNavbar.vue b/website/resources/ts/Components/AppTopNavbar.vue index 355ae43..a1a1cc7 100644 --- a/website/resources/ts/Components/AppTopNavbar.vue +++ b/website/resources/ts/Components/AppTopNavbar.vue @@ -1,6 +1,4 @@ + + + + diff --git a/website/resources/ts/Components/Marketing/Estimator/BackupTierSelector.vue b/website/resources/ts/Components/Marketing/Estimator/BackupTierSelector.vue new file mode 100644 index 0000000..9115f5a --- /dev/null +++ b/website/resources/ts/Components/Marketing/Estimator/BackupTierSelector.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/website/resources/ts/Components/Marketing/Estimator/BillingCycleToggle.vue b/website/resources/ts/Components/Marketing/Estimator/BillingCycleToggle.vue new file mode 100644 index 0000000..eb44afd --- /dev/null +++ b/website/resources/ts/Components/Marketing/Estimator/BillingCycleToggle.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/website/resources/ts/Components/Marketing/Estimator/EstimatorFooter.vue b/website/resources/ts/Components/Marketing/Estimator/EstimatorFooter.vue new file mode 100644 index 0000000..f1c1d9d --- /dev/null +++ b/website/resources/ts/Components/Marketing/Estimator/EstimatorFooter.vue @@ -0,0 +1,115 @@ + + + + + diff --git a/website/resources/ts/Components/Marketing/Estimator/EstimatorSection.vue b/website/resources/ts/Components/Marketing/Estimator/EstimatorSection.vue new file mode 100644 index 0000000..4e93806 --- /dev/null +++ b/website/resources/ts/Components/Marketing/Estimator/EstimatorSection.vue @@ -0,0 +1,251 @@ + + + + + diff --git a/website/resources/ts/Components/Marketing/Estimator/IPv4Stepper.vue b/website/resources/ts/Components/Marketing/Estimator/IPv4Stepper.vue new file mode 100644 index 0000000..fc07f2d --- /dev/null +++ b/website/resources/ts/Components/Marketing/Estimator/IPv4Stepper.vue @@ -0,0 +1,98 @@ + + + + + diff --git a/website/resources/ts/Components/Marketing/Estimator/ManagedSupportSelector.vue b/website/resources/ts/Components/Marketing/Estimator/ManagedSupportSelector.vue new file mode 100644 index 0000000..cfe23ce --- /dev/null +++ b/website/resources/ts/Components/Marketing/Estimator/ManagedSupportSelector.vue @@ -0,0 +1,157 @@ + + + + + diff --git a/website/resources/ts/Components/Marketing/Estimator/MiniQuizDialog.vue b/website/resources/ts/Components/Marketing/Estimator/MiniQuizDialog.vue new file mode 100644 index 0000000..dfa5aea --- /dev/null +++ b/website/resources/ts/Components/Marketing/Estimator/MiniQuizDialog.vue @@ -0,0 +1,214 @@ + + + + + diff --git a/website/resources/ts/Components/Marketing/Estimator/RecommendedPlanCard.vue b/website/resources/ts/Components/Marketing/Estimator/RecommendedPlanCard.vue new file mode 100644 index 0000000..e5f1c49 --- /dev/null +++ b/website/resources/ts/Components/Marketing/Estimator/RecommendedPlanCard.vue @@ -0,0 +1,149 @@ + + + + + diff --git a/website/resources/ts/Components/Marketing/Estimator/WorkloadPicker.vue b/website/resources/ts/Components/Marketing/Estimator/WorkloadPicker.vue new file mode 100644 index 0000000..fea2641 --- /dev/null +++ b/website/resources/ts/Components/Marketing/Estimator/WorkloadPicker.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/website/resources/ts/Layouts/AccountLayout.vue b/website/resources/ts/Layouts/AccountLayout.vue index d91c9fa..7517b9b 100644 --- a/website/resources/ts/Layouts/AccountLayout.vue +++ b/website/resources/ts/Layouts/AccountLayout.vue @@ -8,6 +8,7 @@ import CommandPalette from '@/Components/CommandPalette.vue' import NotificationPanel from '@/Components/NotificationPanel.vue' import ToastStack from '@/Components/ToastStack.vue' import { useToastStore } from '@/stores/toast' +import { crossDomainUrl } from '@/utils/resolvers' interface AuthUser { name: string @@ -25,7 +26,7 @@ const page = usePage() const props = computed(() => page.props as unknown as PageProps) const user = computed(() => props.value.auth?.user) const isImpersonating = computed(() => props.value.impersonating) -const adminUrl = computed(() => `https://${props.value.domains?.admin}`) +const adminUrl = computed(() => crossDomainUrl(props.value.domains?.admin)) const sidebarCollapsed = ref(false) const mobileOpen = ref(false) diff --git a/website/resources/ts/Layouts/AdminLayout.vue b/website/resources/ts/Layouts/AdminLayout.vue index 95f4a8e..a041aad 100644 --- a/website/resources/ts/Layouts/AdminLayout.vue +++ b/website/resources/ts/Layouts/AdminLayout.vue @@ -8,6 +8,7 @@ import CommandPalette from '@/Components/CommandPalette.vue' import NotificationPanel from '@/Components/NotificationPanel.vue' import ToastStack from '@/Components/ToastStack.vue' import { useToastStore } from '@/stores/toast' +import { crossDomainUrl } from '@/utils/resolvers' interface AuthUser { name: string @@ -23,7 +24,7 @@ interface PageProps { const page = usePage() const props = computed(() => page.props as unknown as PageProps) const user = computed(() => props.value.auth?.user) -const accountUrl = computed(() => `https://${props.value.domains?.account}`) +const accountUrl = computed(() => crossDomainUrl(props.value.domains?.account)) const sidebarCollapsed = ref(false) const mobileOpen = ref(false) diff --git a/website/resources/ts/Layouts/AuthLayout.vue b/website/resources/ts/Layouts/AuthLayout.vue index e88a7ca..6c49d2b 100644 --- a/website/resources/ts/Layouts/AuthLayout.vue +++ b/website/resources/ts/Layouts/AuthLayout.vue @@ -1,6 +1,7 @@