Add opt-in custom domains with automatic SSL
Deploy Ladill Merchant / deploy (push) Successful in 29s

Customers can connect their own domain to a merchant page (storefront/event):
add a domain, point an A record (apex + www) to the app server, click Verify —
DNS is checked, then Ladill Domains' central SSL service issues + installs the
Let's Encrypt cert and calls back to flip it live. The custom domain then serves
the mapped page (host resolution on /). Feature-gated: only active when a Domains
SSL API key is set, so this deploy is inert until wired.

- custom_domains table + CustomDomain model
- CustomDomainService (DNS verify, request cert), DomainsSslClient, DnsResolver
- settings UI panel, signed SSL callback receiver, host resolution on /
- feature tests (DNS verify/fail, signed callback, ownership)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
isaacclad
2026-06-26 16:59:19 +00:00
co-authored by Claude Opus 4.8
parent a4974098ee
commit b2aede5963
13 changed files with 579 additions and 3 deletions
@@ -94,6 +94,52 @@
</script>
@endonce
@if(!empty($customDomainsEnabled))
<div class="rounded-2xl border border-slate-200 bg-white p-5">
<h3 class="text-sm font-semibold text-slate-900">Custom domain</h3>
<p class="mt-1 text-xs text-slate-500">Serve this storefront on your own domain with automatic SSL. Optional your <span class="break-all">{{ $publicUrl }}</span> link always works.</p>
@forelse($customDomains as $cd)
<div class="mt-3 rounded-xl border border-slate-200 p-3">
<div class="flex items-center justify-between gap-2">
<span class="break-all text-sm font-medium text-slate-900">{{ $cd->host }}</span>
@php $live = $cd->status === 'active' && $cd->ssl_status === 'active'; @endphp
<span class="shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium {{ $live ? 'bg-emerald-50 text-emerald-700' : ($cd->status === 'failed' ? 'bg-red-50 text-red-700' : 'bg-amber-50 text-amber-700') }}">
{{ $live ? 'Live (SSL)' : ($cd->status === 'failed' ? 'Failed' : 'Pending') }}
</span>
</div>
@unless($live)
<p class="mt-2 text-xs text-slate-500">Point an <strong>A record</strong> for <strong>{{ $cd->host }}</strong>@if($cd->include_www) and <strong>www.{{ $cd->host }}</strong>@endif to <strong>{{ $customDomainServerIp }}</strong>, then verify.</p>
@if($cd->last_error)<p class="mt-1 text-xs text-red-600">{{ $cd->last_error }}</p>@endif
@endunless
<div class="mt-2 flex items-center gap-3">
@unless($live)
<form method="post" action="{{ route('merchant.custom-domain.verify', $cd) }}">
@csrf
<button class="text-xs font-semibold text-indigo-600 hover:text-indigo-500">Verify &amp; enable SSL</button>
</form>
@endunless
<form method="post" action="{{ route('merchant.custom-domain.destroy', $cd) }}" onsubmit="return confirm('Remove this custom domain?');">
@csrf @method('DELETE')
<button class="text-xs font-medium text-slate-500 hover:text-red-600">Remove</button>
</form>
</div>
</div>
@empty
<form method="post" action="{{ route('merchant.custom-domain.store', $qrCode) }}" class="mt-3 flex items-end gap-2">
@csrf
<div class="flex-1">
<label class="block text-xs text-slate-500">Your domain</label>
<input type="text" name="host" placeholder="brand.com" required
class="mt-1 w-full rounded-xl border-slate-200 text-sm focus:border-indigo-500 focus:ring-indigo-500">
</div>
<button class="rounded-xl bg-slate-900 px-3.5 py-2 text-sm font-semibold text-white hover:bg-slate-800">Add</button>
</form>
@error('host')<p class="mt-1 text-xs text-red-600">{{ $message }}</p>@enderror
@endforelse
</div>
@endif
<form method="post" action="{{ route('merchant.storefronts.destroy', $qrCode) }}"
onsubmit="return confirm('Delete this storefront? This cannot be undone.');">
@csrf @method('DELETE')