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'])
+
+ Search and buy without leaving {{ $appLabel }}.Register a domain
+
+ Prefer another? + +
+Enter your domain without www or http (e.g., example.com)
+{{ $message }}
+ @enderror +{{ $message }}
+ @enderror +Leave empty to use default: public_html/domain.com
+Use one of these methods to connect an external domain:
-DNS changes can take up to 24–48 hours.
- Prefer another? - Get a new domain -
-{{ $message }}
- @enderror +