diff --git a/.env.example b/.env.example index 4f4c2bb..c3085b5 100644 --- a/.env.example +++ b/.env.example @@ -47,6 +47,10 @@ BILLING_API_KEY_HOSTING= IDENTITY_API_URL=https://ladill.com/api IDENTITY_API_KEY_HOSTING= +# Ladill Domains API (owned-domain picker; domains.ladill.com) +DOMAINS_API_URL=https://domains.ladill.com/api +DOMAINS_API_KEY_HOSTING= + DOMAIN_API_URL=https://ladill.com/api/domains DOMAIN_API_KEY_HOSTING= diff --git a/app/Http/Controllers/Hosting/HostingPanelController.php b/app/Http/Controllers/Hosting/HostingPanelController.php index 15efd54..b2887ca 100644 --- a/app/Http/Controllers/Hosting/HostingPanelController.php +++ b/app/Http/Controllers/Hosting/HostingPanelController.php @@ -1116,6 +1116,18 @@ class HostingPanelController extends Controller $onboardingMode = $isOwnedDomain ? Domain::MODE_NS_AUTO : ($validated['onboarding_mode'] ?? Domain::MODE_NS_AUTO); + + if ($isOwnedDomain) { + $owned = $this->availableHostingDomainsForUser($request->user()->id, $account); + if (! $owned->contains($domainHost)) { + if ($request->wantsJson()) { + return response()->json(['message' => 'That domain is not in your Ladill account.'], 422); + } + + return back()->with('error', 'That domain is not in your Ladill account.'); + } + } + $serverIp = $account->node?->ip_address ?? '161.97.138.149'; $docRoot = ! empty($validated['document_root']) ? "/home/{$account->username}/" . ltrim($validated['document_root'], '/') @@ -1297,35 +1309,13 @@ class HostingPanelController extends Controller ->filter() ->all(); - $domainRegistryHosts = Domain::where('user_id', $userId)->pluck('host'); + $user = \App\Models\User::query()->find($userId); + $owned = $user + ? app(\App\Services\Domains\LadillDomainsClient::class)->ownedForUser((string) $user->public_id) + : []; - $registeredDomains = RcServiceOrder::where('user_id', $userId) - ->whereIn('order_type', [RcServiceOrder::TYPE_DOMAIN_REGISTRATION, RcServiceOrder::TYPE_DOMAIN_TRANSFER]) - ->where('status', RcServiceOrder::STATUS_ACTIVE) - ->whereNotNull('domain_name') - ->pluck('domain_name'); - - $customerHostingDomains = CustomerHostingOrder::where('user_id', $userId) - ->whereNotIn('status', [ - CustomerHostingOrder::STATUS_PENDING_PAYMENT, - CustomerHostingOrder::STATUS_CANCELLED, - CustomerHostingOrder::STATUS_FAILED, - ]) - ->whereNotNull('domain_name') - ->pluck('domain_name'); - - $hostingAccountDomains = HostingAccount::where('user_id', $userId) - ->whereNotNull('primary_domain') - ->pluck('primary_domain'); - - return $domainRegistryHosts - ->merge($registeredDomains) - ->merge($customerHostingDomains) - ->merge($hostingAccountDomains) - ->map(fn ($d) => strtolower(trim((string) $d))) - ->filter(fn ($d) => (bool) preg_match('/^(?=.{1,253}$)(?!-)(?:[a-z0-9-]{1,63}\.)+[a-z]{2,63}$/', $d)) + return collect($owned) ->reject(fn ($d) => in_array($d, $linkedDomains, true)) - ->unique() ->sort() ->values(); } diff --git a/app/Http/Controllers/Hosting/HostingProductController.php b/app/Http/Controllers/Hosting/HostingProductController.php index 109d641..2b54c3a 100644 --- a/app/Http/Controllers/Hosting/HostingProductController.php +++ b/app/Http/Controllers/Hosting/HostingProductController.php @@ -206,7 +206,7 @@ class HostingProductController extends Controller 'linkedSites' => $account->sites->sortBy(fn ($site) => [$site->type !== 'primary', $site->domain])->values(), 'maxDomains' => $maxDomains, 'canLinkMoreDomains' => $account->sites->count() < $maxDomains, - 'ownedDomains' => $this->getUserDomains($request->user()), + 'ownedDomains' => collect($this->getUserDomains($request->user())), 'emailUsage' => [ 'mailbox_count' => $mailboxCount, 'free_allowance' => $freeEmailAllowance, @@ -630,17 +630,9 @@ class HostingProductController extends Controller return $product->catalogSummary(); } - private function getUserDomains($user): \Illuminate\Support\Collection + private function getUserDomains($user): array { - return \App\Models\Domain::query() - ->where('user_id', $user->id) - ->whereNull('hosting_account_id') - ->whereDoesntHave('hostedSites') - ->where(function ($query) { - $query->where('source', 'purchased') - ->orWhereNotNull('email_domain_id'); - }) - ->orderBy('host') - ->get(['id', 'host', 'status', 'onboarding_state']); + return app(\App\Services\Domains\LadillDomainsClient::class) + ->ownedForUser((string) $user->public_id); } } diff --git a/app/Http/Controllers/OwnedDomainsController.php b/app/Http/Controllers/OwnedDomainsController.php new file mode 100644 index 0000000..25210dd --- /dev/null +++ b/app/Http/Controllers/OwnedDomainsController.php @@ -0,0 +1,26 @@ +query('exclude', [])) + ->map(fn ($d) => strtolower(trim((string) $d))) + ->filter() + ->all(); + + $owned = collect($domains->ownedForUser((string) ladill_account()->public_id)) + ->reject(fn ($d) => in_array($d, $exclude, true)) + ->sort() + ->values() + ->all(); + + return response()->json(['data' => $owned]); + } +} diff --git a/app/Services/Domains/LadillDomainsClient.php b/app/Services/Domains/LadillDomainsClient.php new file mode 100644 index 0000000..b572d6f --- /dev/null +++ b/app/Services/Domains/LadillDomainsClient.php @@ -0,0 +1,42 @@ + */ + public function ownedForUser(string $publicId): array + { + $key = (string) (config('domains.api_key') ?? ''); + if ($key === '') { + return []; + } + + try { + $res = Http::withToken($key) + ->acceptJson() + ->timeout(10) + ->get(rtrim((string) config('domains.api_url'), '/').'/owned-domains', [ + 'user' => $publicId, + ]); + + $res->throw(); + + return collect($res->json('data', [])) + ->map(fn ($d) => strtolower(trim((string) $d))) + ->filter() + ->values() + ->all(); + } catch (\Throwable $e) { + Log::warning('LadillDomainsClient: could not load owned domains', [ + 'user' => $publicId, + 'error' => $e->getMessage(), + ]); + + return []; + } + } +} diff --git a/app/Support/helpers.php b/app/Support/helpers.php index 8bf8445..3281e2d 100644 --- a/app/Support/helpers.php +++ b/app/Support/helpers.php @@ -23,6 +23,17 @@ if (! function_exists('ladill_domains_url')) { } } +if (! function_exists('ladill_domains_embed_url')) { + function ladill_domains_embed_url(string $path = '/embed/find'): string + { + $url = ladill_domains_url($path); + $origin = rtrim((string) config('app.url'), '/'); + $separator = str_contains($url, '?') ? '&' : '?'; + + return $url.$separator.'parent_origin='.urlencode($origin); + } +} + if (! function_exists('ladill_home_url')) { function ladill_home_url(string $path = ''): string { diff --git a/config/domains.php b/config/domains.php new file mode 100644 index 0000000..f05266e --- /dev/null +++ b/config/domains.php @@ -0,0 +1,6 @@ + env('DOMAINS_API_URL', 'https://domains.ladill.com/api'), + 'api_key' => env('DOMAINS_API_KEY_HOSTING'), +]; diff --git a/resources/js/app.js b/resources/js/app.js index 722b689..184f323 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,8 +1,12 @@ import './bootstrap'; import { registerLadillConfirmStore, registerLadillModalHelpers } from './ladill-modals'; +import { registerLadillSearchShortcut } from './ladill-search-shortcut'; +import { registerLadillDomainPurchase } from './ladill-domain-purchase'; registerLadillModalHelpers(); +registerLadillSearchShortcut(); +registerLadillDomainPurchase(); import Alpine from 'alpinejs'; diff --git a/resources/js/ladill-domain-purchase.js b/resources/js/ladill-domain-purchase.js new file mode 100644 index 0000000..93faeab --- /dev/null +++ b/resources/js/ladill-domain-purchase.js @@ -0,0 +1,99 @@ +import { closeLadillModal, openLadillModal } from './ladill-modals'; + +const PURCHASE_MODAL = 'domain-purchase'; + +function allowedParentOrigin(origin) { + try { + const host = new URL(origin).hostname; + + return host === 'ladill.com' || host.endsWith('.ladill.com'); + } catch { + return false; + } +} + +function loadPurchaseFrame() { + const frame = document.getElementById('ladill-domain-purchase-frame'); + if (!frame || frame.dataset.loaded === '1') { + return; + } + + const embedUrl = frame.dataset.embedUrl; + if (embedUrl) { + frame.src = embedUrl; + frame.dataset.loaded = '1'; + } +} + +export function registerLadillDomainPurchase() { + window.ladillDomainSourceForm = (initial = {}) => ({ + domainSource: initial.domainSource ?? 'external', + selectedOwnedDomain: initial.selectedOwnedDomain ?? '', + externalDomain: initial.externalDomain ?? '', + ownedDomains: initial.ownedDomains ?? [], + ownedUrl: initial.ownedUrl ?? '', + onboardingMode: initial.onboardingMode ?? 'ns_auto', + + openPurchaseModal() { + loadPurchaseFrame(); + openLadillModal(PURCHASE_MODAL); + }, + + async refreshOwnedDomains() { + if (!this.ownedUrl) { + return; + } + + try { + const res = await fetch(this.ownedUrl, { + headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, + }); + + if (!res.ok) { + return; + } + + const payload = await res.json(); + this.ownedDomains = Array.isArray(payload.data) ? payload.data : []; + this.domainSource = 'ladill'; + } catch { + // Non-fatal. + } + }, + }); + + window.addEventListener('message', (event) => { + if (!allowedParentOrigin(event.origin)) { + return; + } + + const data = event.data; + if (!data || data.type !== 'ladill:domains:purchase-complete') { + return; + } + + closeLadillModal(PURCHASE_MODAL); + + const domains = Array.isArray(data.domains) ? data.domains : (data.domain ? [data.domain] : []); + document.querySelectorAll('[x-data*="ladillDomainSourceForm"]').forEach((el) => { + const component = el._x_dataStack?.[0]; + if (!component?.refreshOwnedDomains) { + return; + } + + component.refreshOwnedDomains().then(() => { + const newest = domains.map((d) => String(d).toLowerCase()).find(Boolean); + if (newest) { + component.selectedOwnedDomain = newest; + component.domainSource = 'ladill'; + } + }); + }); + }); + + window.addEventListener('open-modal', (event) => { + if (String(event.detail) === PURCHASE_MODAL) { + loadPurchaseFrame(); + } + }); +} diff --git a/resources/js/ladill-search-shortcut.js b/resources/js/ladill-search-shortcut.js new file mode 100644 index 0000000..72b493f --- /dev/null +++ b/resources/js/ladill-search-shortcut.js @@ -0,0 +1,97 @@ +function isEditableTarget(element) { + if (!(element instanceof HTMLElement)) { + return false; + } + + const tag = element.tagName; + + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') { + return true; + } + + return element.isContentEditable; +} + +function isVisibleInput(input) { + if (!(input instanceof HTMLElement)) { + return false; + } + + const style = window.getComputedStyle(input); + + return style.display !== 'none' + && style.visibility !== 'hidden' + && style.opacity !== '0'; +} + +function findSearchInput() { + const marked = [...document.querySelectorAll('[data-ladill-search-input]')]; + const visibleMarked = marked.find(isVisibleInput); + + if (visibleMarked) { + return visibleMarked; + } + + if (marked.length > 0) { + return marked[0]; + } + + return document.querySelector('[x-data*="topbarSearch"] input'); +} + +function focusSearchInput() { + const input = findSearchInput(); + + if (!input) { + const fallbackUrl = document.body?.dataset?.ladillSearchUrl; + + if (fallbackUrl) { + window.location.href = fallbackUrl; + } + + return false; + } + + if (!isVisibleInput(input)) { + const fallbackUrl = document.body?.dataset?.ladillSearchUrl; + + if (fallbackUrl) { + window.location.href = fallbackUrl; + + return true; + } + } + + input.focus(); + + if (input instanceof HTMLInputElement && (input.type === 'text' || input.type === 'search')) { + input.select(); + } + + return true; +} + +export function registerLadillSearchShortcut() { + if (window.__ladillSearchShortcutRegistered) { + return; + } + + window.__ladillSearchShortcutRegistered = true; + + document.addEventListener('keydown', (event) => { + if (event.key !== '/' && event.code !== 'Slash') { + return; + } + + if (event.ctrlKey || event.metaKey || event.altKey) { + return; + } + + if (isEditableTarget(document.activeElement)) { + return; + } + + event.preventDefault(); + focusSearchInput(); + }); +} diff --git a/resources/views/components/domain-purchase-modal.blade.php b/resources/views/components/domain-purchase-modal.blade.php new file mode 100644 index 0000000..0e8f4ff --- /dev/null +++ b/resources/views/components/domain-purchase-modal.blade.php @@ -0,0 +1,20 @@ +@props(['appLabel' => 'Ladill Hosting']) + + +
+
+
+

Register a domain

+

Search and buy without leaving {{ $appLabel }}.

+
+ +
+ +
+
diff --git a/resources/views/components/domain-source-fields.blade.php b/resources/views/components/domain-source-fields.blade.php new file mode 100644 index 0000000..74c11ed --- /dev/null +++ b/resources/views/components/domain-source-fields.blade.php @@ -0,0 +1,140 @@ +@props([ + 'ownedDomains' => [], + 'fieldName' => 'domain', + 'ownedUrl' => '', + 'variant' => 'slate', + 'showConnectionMethod' => false, + 'showDocumentRoot' => false, +]) + +@php + $ownedDomainHosts = collect($ownedDomains)->map(fn ($d) => (string) $d)->all(); + $oldValue = trim((string) old($fieldName, '')); + $initialDomainSource = empty($ownedDomainHosts) + ? 'external' + : ((string) old('is_owned_domain', '1') === '0' ? 'external' : 'ladill'); + $selectedOwnedDomain = in_array($oldValue, $ownedDomainHosts, true) ? $oldValue : ''; + $externalFallbackDomain = ! in_array($oldValue, $ownedDomainHosts, true) ? $oldValue : ''; + $isGray = $variant === 'gray'; + $inputClass = $isGray + ? 'block w-full rounded-lg border-gray-200 bg-white text-sm focus:border-gray-400 focus:ring-0' + : 'block w-full rounded-xl border-slate-200 bg-slate-50 text-sm shadow-sm focus:border-indigo-500 focus:bg-white focus:ring-indigo-500'; + $toggleWrapClass = $isGray ? 'bg-gray-100' : 'bg-slate-100'; + $purchaseLinkClass = $isGray + ? 'font-medium text-gray-700 underline underline-offset-2 hover:text-gray-900' + : 'font-medium text-indigo-600 underline underline-offset-2 hover:text-indigo-800'; +@endphp + +
+
+ +
+ + +
+
+ +
+ + +
+ +
+ +

Enter your domain without www or http (e.g., example.com)

+
+ + @error($fieldName) +

{{ $message }}

+ @enderror +
+ + + +@if($showConnectionMethod) +
+ +
+ + + +
+ @error('onboarding_mode') +

{{ $message }}

+ @enderror +
+@endif + +@if($showDocumentRoot) +
+ + +

Leave empty to use default: public_html/domain.com

+
+@endif diff --git a/resources/views/hosting/account.blade.php b/resources/views/hosting/account.blade.php index 9cd2d28..e1a2f4d 100644 --- a/resources/views/hosting/account.blade.php +++ b/resources/views/hosting/account.blade.php @@ -36,9 +36,9 @@ @endphp @php - $ownedDomainHosts = $ownedDomains->pluck('host')->map(fn ($d) => (string) $d)->all(); + $ownedDomainHosts = collect($ownedDomains)->map(fn ($d) => (string) $d)->all(); $oldDomain = trim((string) old('domain', '')); - $initialDomainSource = $ownedDomains->isEmpty() + $initialDomainSource = empty($ownedDomainHosts) ? 'external' : ((string) old('is_owned_domain', '1') === '0' ? 'external' : 'ladill'); $selectedOwned = in_array($oldDomain, $ownedDomainHosts, true) ? $oldDomain : ''; @@ -54,10 +54,6 @@ showSheet: false, checkoutUrl: '', renewLoading: false, - domainSource: @js($initialDomainSource), - selectedOwnedDomain: @js($selectedOwned), - externalDomain: @js($externalFallback), - onboardingMode: '{{ old('onboarding_mode', 'ns_auto') }}', async submitRenewal() { this.renewLoading = true; try { @@ -472,9 +468,16 @@ x-transition:leave="transition ease-in duration-150" x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95" class="relative min-h-full sm:min-h-0 w-full sm:max-w-lg sm:rounded-2xl sm:border sm:border-slate-200 bg-white shadow-xl" @click.stop> -
+ @csrf -

Connect a Domain

@@ -483,83 +486,15 @@
- {{-- Domain Source Toggle --}} -
-
- -
- - -
-
- - {{-- Ladill (Owned) Domains --}} -
- @if($ownedDomains->isNotEmpty()) - -

- Don't have a domain? - Purchase one first -

- @else -
- No domains found in your account. - Register a domain -
- @endif -
- - {{-- External Domain --}} -
- -

Enter your domain without www or http (e.g., example.com)

-
- - @if ($formErrors->has('domain')) -

{{ $formErrors->first('domain') }}

- @endif -
- - {{-- Connection Method (External only) --}} -
- -
- - -
-
+
- +
diff --git a/resources/views/hosting/panel/domains.blade.php b/resources/views/hosting/panel/domains.blade.php index ee0be0a..dd85bd3 100644 --- a/resources/views/hosting/panel/domains.blade.php +++ b/resources/views/hosting/panel/domains.blade.php @@ -16,9 +16,9 @@ @endif @php - $ownedDomainHosts = $ownedDomains->map(fn ($d) => (string) $d)->all(); + $ownedDomainHosts = collect($ownedDomains)->map(fn ($d) => (string) $d)->all(); $oldDomain = trim((string) old('domain', '')); - $initialDomainSource = $ownedDomains->isEmpty() + $initialDomainSource = empty($ownedDomainHosts) ? 'external' : ((string) old('is_owned_domain', '1') === '0' ? 'external' : 'ladill'); $selectedOwnedDomain = in_array($oldDomain, $ownedDomainHosts, true) ? $oldDomain : ''; @@ -28,113 +28,21 @@

Add Domain

+ x-data="ladillDomainSourceForm({ + domainSource: @js($initialDomainSource), + selectedOwnedDomain: @js($selectedOwnedDomain), + externalDomain: @js($externalFallbackDomain), + ownedDomains: @js($ownedDomainHosts), + ownedUrl: @js(route('hosting.domains.owned', ['exclude' => $account->sites->pluck('domain')->filter()->all()])), + onboardingMode: @js(old('onboarding_mode', 'ns_auto')), + })"> @csrf - - - {{-- Domain Source Toggle --}} -
-
- -
- - -
-
- - {{-- Ladill (Owned) Domains --}} -
- @if($ownedDomains->isNotEmpty()) - -

- Don't have a domain? - Purchase one first -

- @else -
- No domains found in your account. - Register a domain -
- @endif -
- - {{-- External Domain --}} -
- -

Enter your domain without www or http (e.g., example.com)

-
- - @error('domain') -

{{ $message }}

- @enderror -
- - {{-- Onboarding Mode (External only) --}} -
- -
- - - -
- @error('onboarding_mode') -

{{ $message }}

- @enderror -
- - {{-- Document Root (optional, both flows) --}} -
- - -

Leave empty to use default: public_html/domain.com

-
+ @@ -209,21 +116,14 @@ @endif
- @php - $serverIp = $account->node?->ip_address ?? '161.97.138.149'; - @endphp + @php $serverIp = $account->node?->ip_address ?? '161.97.138.149'; @endphp - {{-- DNS Quick Reference --}}

DNS Quick Reference

Use one of these methods to connect an external domain:

-
-
-

Nameservers

- Recommended -
+

Nameservers

ns1.ladill.com @@ -235,27 +135,12 @@
-

A Records

-
-
- Type - Name - Value -
-
- A - @ - {{ $serverIp }} -
-
- A - www - {{ $serverIp }} -
+
+
A @ {{ $serverIp }}
+
A www {{ $serverIp }}
-

DNS changes can take up to 24–48 hours.

diff --git a/resources/views/hosting/partials/order-form-fields.blade.php b/resources/views/hosting/partials/order-form-fields.blade.php index 31117bb..f0f2393 100644 --- a/resources/views/hosting/partials/order-form-fields.blade.php +++ b/resources/views/hosting/partials/order-form-fields.blade.php @@ -35,50 +35,23 @@ @if ($nativeForm['requires_domain']) @php - $newDomainUrl = ladill_domains_url('/find'); + $ownedDomainHosts = $userDomains; + $selectedOwned = in_array($oldDomainName, $ownedDomainHosts, true) ? $oldDomainName : ''; + $externalFallback = $initialDomainSource === 'external' ? $oldDomainName : ''; @endphp -
-
- -
- - -
-
- -
- @if ($userDomains->isNotEmpty()) -
- -

- Prefer another? - Get a new domain -

-
- @else - - @endif -
-
- -
- @error('domain_name') -

{{ $message }}

- @enderror +
+
@endif diff --git a/resources/views/hosting/type.blade.php b/resources/views/hosting/type.blade.php index 0dfc220..45e11d1 100644 --- a/resources/views/hosting/type.blade.php +++ b/resources/views/hosting/type.blade.php @@ -6,7 +6,7 @@ $orderFormErrorFields = ['domain_name', 'plan_id', 'months']; $hasOrderFormErrors = collect($orderFormErrorFields)->contains(fn ($field) => $errors->has($field)); $shouldOpenOrderModal = (bool) session('open_product_order_modal', false) || $hasOrderFormErrors; - $knownDomainHosts = $userDomains->pluck('host')->all(); + $knownDomainHosts = $userDomains; $oldDomainName = trim((string) old('domain_name', '')); $initialDomainSource = $oldDomainName !== '' && ! in_array($oldDomainName, $knownDomainHosts, true) ? 'external' : 'ladill'; $initialExternalDomain = $initialDomainSource === 'external' ? $oldDomainName : ''; @@ -52,7 +52,7 @@ || $rcServiceOrders->isNotEmpty(); @endphp -
+
{{-- Page Header --}}
diff --git a/resources/views/layouts/hosting.blade.php b/resources/views/layouts/hosting.blade.php index 253e8b3..68d585c 100644 --- a/resources/views/layouts/hosting.blade.php +++ b/resources/views/layouts/hosting.blade.php @@ -10,7 +10,7 @@ @vite(['resources/css/app.css', 'resources/js/app.js']) - +