mirror of
https://github.com/coollabsio/coolify.git
synced 2026-08-24 10:05:47 -05:00
feat(boarding): add Hostinger provider and refresh create UI
Expose Hostinger in onboarding next to DigitalOcean, switch server creation to the shared provider token picker UI, and cover both with feature tests.
This commit is contained in:
@@ -204,6 +204,23 @@
|
||||
</x-slot:content>
|
||||
<livewire:server.new.by-digital-ocean :limit_reached="false" :from_onboarding="true" />
|
||||
</x-modal-input>
|
||||
<x-modal-input title="Connect a Hostinger Server" isFullWidth>
|
||||
<x-slot:content>
|
||||
<div
|
||||
class="group relative cursor-pointer flex h-full min-h-36 flex-col rounded-[10px] border border-neutral-200 bg-white p-4 text-left shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]">
|
||||
<div class="flex h-full flex-col gap-4 text-left">
|
||||
<x-hostinger-icon class="size-10 shrink-0 text-[#673de6]" />
|
||||
<div class="min-h-0 flex-1">
|
||||
<h3 class="mb-1 text-[14px] font-semibold">Hostinger</h3>
|
||||
<p class="text-sm dark:text-neutral-400">
|
||||
Deploy servers directly from your Hostinger account.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-slot:content>
|
||||
<livewire:server.new.by-hostinger :limit_reached="false" :from_onboarding="true" />
|
||||
</x-modal-input>
|
||||
@endif
|
||||
@endcan
|
||||
</div>
|
||||
|
||||
@@ -2,134 +2,117 @@
|
||||
@if ($limit_reached)
|
||||
<x-limit-reached name="servers" />
|
||||
@elseif ($current_step === 1)
|
||||
<div class="flex flex-col w-full gap-4">
|
||||
<div class="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
Manage your Hostinger API tokens in <a href="https://hpanel.hostinger.com/profile/api" target="_blank"
|
||||
class="underline dark:text-white">hPanel</a>.
|
||||
<div class="flex flex-col gap-6">
|
||||
<x-server.provider-token-picker provider="hostinger" providerLabel="Hostinger"
|
||||
:tokens="$available_tokens" />
|
||||
<p class="text-[11px] text-neutral-500 dark:text-fg-faint">
|
||||
New to Hostinger?
|
||||
<a href="https://www.hostinger.com/vps/coolify-hosting?ref=coolify.io" target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="font-medium text-coollabs hover:underline dark:text-warning">Create an account</a>
|
||||
through Coolify's affiliate link.
|
||||
</p>
|
||||
</div>
|
||||
@if ($available_tokens->isNotEmpty())
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
@foreach ($available_tokens as $token)
|
||||
<a class="coolbox group text-left" wire:key="hostinger-token-{{ $token->id }}"
|
||||
href="{{ route('server.create.token', ['type' => 'hostinger', 'token_uuid' => $token->uuid]) }}"
|
||||
{{ wireNavigate() }}>
|
||||
<div class="flex flex-col justify-center mx-6">
|
||||
<div class="box-title">{{ $token->name ?? 'Hostinger Token' }}</div>
|
||||
<div class="box-description">Use this token to purchase and create a Hostinger VPS.</div>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<div class="w-full max-w-2xl">
|
||||
<x-modal-input title="Add Hostinger Token">
|
||||
<x-slot:content>
|
||||
<div class="coolbox group cursor-pointer">
|
||||
<div class="flex items-center gap-4 mx-6">
|
||||
<div class="flex size-10 shrink-0 items-center justify-center rounded-full bg-coollabs/10 text-coollabs dark:bg-warning/20 dark:text-warning">
|
||||
<svg class="size-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex flex-col justify-center">
|
||||
<div class="box-title">Add a new token</div>
|
||||
<div class="box-description">Add a Hostinger API token to create VPS instances.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-slot:content>
|
||||
<livewire:security.cloud-provider-token-form :modal_mode="true" provider="hostinger"
|
||||
wire:key="new-server-empty-token-hostinger" />
|
||||
</x-modal-input>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
@elseif ($current_step === 2)
|
||||
<div wire:init="loadHostingerData">
|
||||
@if ($loading_data)
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<x-application.settings-section title="Loading Hostinger"
|
||||
description="Fetching data centers, plans, billing periods, and operating systems.">
|
||||
<div class="flex min-h-40 items-center justify-center">
|
||||
<x-loading text="Loading Hostinger data..." />
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
@elseif ($provider_data_error)
|
||||
<div class="flex flex-col gap-4 rounded-lg border border-error bg-error/10 p-4">
|
||||
<div>
|
||||
<h3>Unable to load Hostinger details</h3>
|
||||
<p class="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
Coolify could not fetch Hostinger data with the selected token.
|
||||
</p>
|
||||
</div>
|
||||
<pre class="whitespace-pre-wrap break-words text-sm text-error">{{ $provider_data_error }}</pre>
|
||||
<div>
|
||||
<a class="button" href="{{ route('server.create.type', ['type' => 'hostinger']) }}" {{ wireNavigate() }}>
|
||||
Select another token
|
||||
</a>
|
||||
</div>
|
||||
<x-application.settings-section title="Unable to load Hostinger"
|
||||
description="The selected token could not access the provider API.">
|
||||
<x-callout type="error" title="Provider request failed">
|
||||
<pre class="mt-2 whitespace-pre-wrap break-words text-[11px]">{{ $provider_data_error }}</pre>
|
||||
</x-callout>
|
||||
<div class="mt-4">
|
||||
<a class="button" href="{{ route('server.create.type', ['type' => 'hostinger']) }}"
|
||||
{{ wireNavigate() }}>Select another token</a>
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
@else
|
||||
<form class="flex flex-col w-full gap-2" wire:submit="submit">
|
||||
<x-forms.input id="server_name" label="Server Name" helper="A valid hostname for your VPS." />
|
||||
@php
|
||||
$dataCenterOptions = collect($data_centers)->map(fn ($dataCenter) => [
|
||||
'value' => $dataCenter['id'],
|
||||
'label' => ($dataCenter['city'] ?? $dataCenter['name'])
|
||||
. (!empty($dataCenter['location']) ? ' · ' . strtoupper($dataCenter['location']) : ''),
|
||||
])->values()->all();
|
||||
$priceOptions = collect($this->priceOptions)->map(fn ($price) => [
|
||||
'value' => $price['id'],
|
||||
'label' => $this->priceLabel($price),
|
||||
])->values()->all();
|
||||
$templateOptions = collect($templates)->map(fn ($template) => [
|
||||
'value' => $template['id'],
|
||||
'label' => $template['name'] ?? $template['description'],
|
||||
])->values()->all();
|
||||
$privateKeyOptions = $private_keys->map(fn ($key) => [
|
||||
'value' => $key->id,
|
||||
'label' => $key->name,
|
||||
])->values()->all();
|
||||
@endphp
|
||||
|
||||
<x-forms.select label="Data Center" id="selected_data_center_id" required>
|
||||
<option value="">Select a data center...</option>
|
||||
@foreach ($data_centers as $dataCenter)
|
||||
<option value="{{ $dataCenter['id'] }}">
|
||||
{{ $dataCenter['city'] ?? $dataCenter['name'] }}@if (!empty($dataCenter['location']))
|
||||
({{ strtoupper($dataCenter['location']) }})
|
||||
@endif
|
||||
</option>
|
||||
@endforeach
|
||||
</x-forms.select>
|
||||
<form wire:submit="submit" class="flex flex-col gap-6">
|
||||
<x-application.settings-section title="Hostinger server"
|
||||
description="Choose the data center, plan, operating system, and Coolify SSH key.">
|
||||
<x-slot:actions>
|
||||
<button type="submit" class="button button-highlighted"
|
||||
wire:loading.attr="disabled" wire:target="submit"
|
||||
@disabled(!$private_key_id)>
|
||||
Buy and create
|
||||
<x-loading-on-button wire:loading wire:target="submit" />
|
||||
</button>
|
||||
</x-slot:actions>
|
||||
|
||||
<x-forms.select label="Plan & Billing Period" id="selected_price_id" wire:model.live="selected_price_id" required>
|
||||
<option value="">Select a plan...</option>
|
||||
@foreach ($this->priceOptions as $price)
|
||||
<option value="{{ $price['id'] }}">{{ $this->priceLabel($price) }}</option>
|
||||
@endforeach
|
||||
</x-forms.select>
|
||||
|
||||
<x-forms.select label="Operating System" id="selected_template_id" required>
|
||||
<option value="">Select an operating system...</option>
|
||||
@foreach ($templates as $template)
|
||||
<option value="{{ $template['id'] }}">{{ $template['name'] ?? $template['description'] }}</option>
|
||||
@endforeach
|
||||
</x-forms.select>
|
||||
|
||||
<div>
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<div class="lg:col-span-2">
|
||||
<x-forms.input id="server_name" label="Server name"
|
||||
helper="A friendly name shown in Coolify and used as the VPS hostname." />
|
||||
</div>
|
||||
<x-forms.listbox id="selected_data_center_id" label="Data center" required live
|
||||
placeholder="Select a data center" :options="$dataCenterOptions" />
|
||||
<x-forms.listbox id="selected_price_id" label="Plan and billing period" required live
|
||||
:disabled="!$selected_data_center_id" placeholder="Select a plan"
|
||||
:options="$priceOptions" />
|
||||
<x-forms.listbox id="selected_template_id" label="Operating system" required
|
||||
:disabled="!$selected_price_id" placeholder="Select an operating system"
|
||||
:options="$templateOptions" />
|
||||
@if ($private_keys->isEmpty())
|
||||
<div class="flex flex-col gap-2 rounded border border-warning-500 bg-warning-50 p-4 dark:border-warning-600 dark:bg-warning-900/10">
|
||||
<p class="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
Create a private key before purchasing the VPS.
|
||||
</p>
|
||||
<x-modal-input buttonTitle="Create New Private Key" title="New Private Key" isHighlightedButton>
|
||||
<div>
|
||||
<label class="mb-1.5 flex w-fit items-center gap-1.5">Private key
|
||||
<x-highlighted text="*" />
|
||||
</label>
|
||||
<div
|
||||
class="flex min-h-8 items-center justify-between gap-3 rounded-lg border border-warning/30 bg-warning/5 px-3 py-2">
|
||||
<span class="text-[11px] text-neutral-600 dark:text-fg-dim">A private key is required.</span>
|
||||
<x-modal-input title="New Private Key">
|
||||
<x-slot:content>
|
||||
<button type="button" class="button">Create key</button>
|
||||
</x-slot:content>
|
||||
<livewire:security.private-key.create :modal_mode="true" from="server" />
|
||||
</x-modal-input>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<x-forms.select label="Private Key" id="private_key_id" required>
|
||||
<option value="">Select a private key...</option>
|
||||
@foreach ($private_keys as $key)
|
||||
<option value="{{ $key->id }}">{{ $key->name }}</option>
|
||||
@endforeach
|
||||
</x-forms.select>
|
||||
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
This public key will be installed during Hostinger VPS setup.
|
||||
</p>
|
||||
<x-forms.listbox id="private_key_id" label="Private key" required
|
||||
placeholder="Select a private key" :options="$privateKeyOptions"
|
||||
helper="This key is added to the Hostinger VPS automatically." />
|
||||
@endif
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
|
||||
<x-forms.checkbox id="enable_backups" label="Enable weekly Hostinger backups" />
|
||||
|
||||
<div class="my-2 rounded border border-warning/40 bg-warning/10 p-3 text-sm">
|
||||
This action purchases a paid Hostinger VPS using your account's default payment method.
|
||||
Review the plan and billing period before continuing.
|
||||
<x-application.settings-section title="Advanced options"
|
||||
description="Provider backups and purchase details.">
|
||||
<div class="flex flex-col gap-4">
|
||||
<x-forms.checkbox id="enable_backups" label="Enable weekly Hostinger backups" fullWidth />
|
||||
<x-callout type="warning" title="This purchase is billed by Hostinger">
|
||||
The VPS is purchased with your Hostinger account's default payment method. Review the
|
||||
selected plan and billing period before continuing.
|
||||
</x-callout>
|
||||
</div>
|
||||
|
||||
<x-forms.button type="submit" class="w-full" :disabled="$private_keys->isEmpty()" :showLoadingIndicator="false"
|
||||
wire:loading.attr="disabled" wire:target="submit">
|
||||
Buy & Create Server
|
||||
<x-loading-on-button wire:loading wire:target="submit" />
|
||||
</x-forms.button>
|
||||
</x-application.settings-section>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,16 @@ test('DigitalOcean is available as an onboarding server provider', function () {
|
||||
->toContain('<livewire:server.new.by-digital-ocean :limit_reached="false" :from_onboarding="true" />');
|
||||
});
|
||||
|
||||
test('Hostinger is available as an onboarding server provider', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('<x-modal-input title="Connect a Hostinger Server" isFullWidth>')
|
||||
->toContain('<x-hostinger-icon class="size-10 shrink-0 text-[#673de6]" />')
|
||||
->toContain('Deploy servers directly from your Hostinger account.')
|
||||
->toContain('<livewire:server.new.by-hostinger :limit_reached="false" :from_onboarding="true" />');
|
||||
});
|
||||
|
||||
test('server type details are shown on the relevant cards instead of a technical details panel', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php'));
|
||||
|
||||
@@ -43,8 +53,8 @@ test('server type cards use the standard card hover treatment', function () {
|
||||
$hoverClasses = 'shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:shadow-md';
|
||||
preg_match_all('/min-h-36[^\"]*'.preg_quote($hoverClasses, '/').'/', $view, $matches);
|
||||
|
||||
expect($matches[0])->toHaveCount(5)
|
||||
->and(substr_count($view, 'group relative cursor-pointer'))->toBe(5)
|
||||
expect($matches[0])->toHaveCount(6)
|
||||
->and(substr_count($view, 'group relative cursor-pointer'))->toBe(6)
|
||||
->and($view)->not->toContain('hover:border-coollabs/35 hover:bg-coollabs/[0.03]')
|
||||
->and($view)->not->toContain('dark:hover:border-warning/25 dark:hover:bg-warning/[0.04]');
|
||||
});
|
||||
|
||||
@@ -53,6 +53,19 @@ it('offers Hostinger as a server provider', function () {
|
||||
->assertSet('tokenProvider', 'hostinger');
|
||||
});
|
||||
|
||||
it('uses the current cloud provider UI and Hostinger affiliate link', function () {
|
||||
Livewire::test(ByHostinger::class)
|
||||
->assertSee('Hostinger account')
|
||||
->assertSee('https://www.hostinger.com/vps/coolify-hosting?ref=coolify.io', false)
|
||||
->assertSee("through Coolify's affiliate link.", false);
|
||||
|
||||
Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid])
|
||||
->set('loading_data', false)
|
||||
->assertSee('Hostinger server')
|
||||
->assertSee('Advanced options')
|
||||
->assertSee('Buy and create');
|
||||
});
|
||||
|
||||
it('purchases a Hostinger VPS and creates the linked Coolify server', function () {
|
||||
Http::fake([
|
||||
'https://developers.hostinger.com/api/vps/v1/data-centers' => Http::response([
|
||||
|
||||
Reference in New Issue
Block a user