Show linked domains on cards and fix Email leftovers.
Deploy Ladill Hosting / deploy (push) Successful in 24s

Display hosted_sites on account cards, scope Afia and Settings to hosting,
add hosting_settings for notifications, and remove mailbox UI from the
hosting layout and account pages.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-06 17:30:40 +00:00
co-authored by Cursor
parent c4bd4efbed
commit 5703a00862
19 changed files with 202 additions and 329 deletions
@@ -3,29 +3,22 @@
namespace App\Http\Controllers\Hosting;
use App\Http\Controllers\Controller;
use App\Models\EmailSetting;
use App\Models\User;
use App\Models\HostingSetting;
use App\Services\Billing\BillingClient;
use App\Services\Identity\IdentityClient;
use App\Services\Mailbox\MailboxClient;
use App\Services\Payment\WalletPaymentService;
use App\Support\MailboxPricing;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
/**
* Account Wallet, Billing, Settings. Money is read from the one platform
* UserWallet via the Billing API (tagged 'email'); funding is platform-level, so
* "Add funds" deep-links to the account portal. Settings are email-local prefs.
* Account Wallet, Billing, Settings for Ladill Hosting.
* Money is read from the one platform UserWallet via the Billing API.
*/
class AccountController extends Controller
{
public function __construct(
private WalletPaymentService $wallet,
private BillingClient $billing,
private MailboxClient $mailboxes,
private IdentityClient $identity,
) {}
private function topupUrl(): string
@@ -33,7 +26,6 @@ class AccountController extends Controller
return 'https://'.config('app.account_domain').'/wallet';
}
/** Wallet: balance + email spend summary + add-funds link. */
public function wallet(): View
{
$account = ladill_account();
@@ -47,34 +39,15 @@ class AccountController extends Controller
]);
}
/** Billing: wallet snapshot + recurring paid-mailbox subscriptions. */
public function billing(): View
{
$account = ladill_account();
[$balanceMinor, $ledger] = $this->billingSnapshot($account?->public_id);
$paidMailboxes = [];
$monthlyTotalMinor = 0;
try {
foreach ($this->mailboxes->forUser((string) $account?->public_id) as $m) {
if (! ($m['is_paid'] ?? false)) {
continue;
}
$m['price_minor'] = MailboxPricing::priceMinorFor((int) ($m['quota_mb'] ?? 0));
$m['quota_label'] = MailboxPricing::label((int) ($m['quota_mb'] ?? 0));
$monthlyTotalMinor += $m['price_minor'];
$paidMailboxes[] = $m;
}
} catch (\Throwable $e) {
report($e);
}
return view('hosting.account.billing', [
'balanceMinor' => $balanceMinor,
'spentMinor' => (int) ($ledger['spent_minor'] ?? 0),
'creditedMinor' => (int) ($ledger['credited_minor'] ?? 0),
'paidMailboxes' => $paidMailboxes,
'monthlyTotalMinor' => $monthlyTotalMinor,
'topupUrl' => $this->topupUrl(),
]);
}
@@ -82,169 +55,26 @@ class AccountController extends Controller
public function settings(): View
{
$account = ladill_account();
$settings = EmailSetting::firstOrNew(['user_id' => $account?->id]);
['linkStatus' => $linkStatus, 'mailboxOptions' => $mailboxOptions, 'showMailboxLinkUi' => $showMailboxLinkUi] = $this->mailboxLinkContext($account);
$settings = HostingSetting::firstOrNew(['user_id' => $account?->id]);
return view('hosting.account.settings', [
'account' => $account,
'settings' => $settings,
'linkStatus' => $linkStatus,
'mailboxOptions' => $mailboxOptions,
'showMailboxLinkUi' => $showMailboxLinkUi,
]);
}
public function linkMailbox(Request $request): RedirectResponse
{
$account = ladill_account();
$data = $request->validate([
'mailbox_address' => ['required', 'email', 'max:255'],
]);
try {
$result = $this->identity->linkMailbox(
(string) $account->public_id,
$data['mailbox_address'],
);
} catch (\Illuminate\Http\Client\RequestException $e) {
$message = (string) $e->response?->json('message', '');
if ($message !== '') {
return redirect()->route('account.settings')->with('error', $message);
}
report($e);
return redirect()->route('account.settings')->with('error', 'Could not link mailbox. Try again in a moment, or contact support if this keeps happening.');
} catch (\Throwable $e) {
report($e);
return redirect()->route('account.settings')->with('error', 'Could not link mailbox. Try again in a moment, or contact support if this keeps happening.');
}
$request->session()->forget('mailbox_link_banner_dismissed');
$connectUrl = (string) ($result['webmail_connect_url'] ?? '');
if ($connectUrl === '') {
$connectUrl = rtrim((string) config('services.ladill_webmail.url', 'https://mail.ladill.com'), '/').'/sso/connect';
}
return redirect()->away($connectUrl);
}
public function unlinkMailbox(Request $request): RedirectResponse
{
$account = ladill_account();
try {
$this->identity->unlinkMailbox((string) $account->public_id);
} catch (\Illuminate\Http\Client\RequestException $e) {
$message = (string) $e->response?->json('message', '');
if ($message !== '') {
return redirect()->route('account.settings')->with('error', $message);
}
report($e);
return redirect()->route('account.settings')->with('error', 'Could not unlink mailbox. Try again in a moment.');
} catch (\Throwable $e) {
report($e);
return redirect()->route('account.settings')->with('error', 'Could not unlink mailbox. Try again in a moment.');
}
$request->session()->forget('mailbox_link_banner_dismissed');
return redirect()->route('account.settings')->with('success', 'Mailbox unlinked from your Ladill account.');
}
/** @return array{linkStatus: array<string, mixed>, mailboxOptions: array<int, array<string, mixed>>, showMailboxLinkUi: bool} */
private function mailboxLinkContext(?User $account): array
{
$linkStatus = ['show_reminder' => false];
$mailboxOptions = [];
if (! $account?->public_id) {
return [
'linkStatus' => $linkStatus,
'mailboxOptions' => $mailboxOptions,
'showMailboxLinkUi' => false,
];
}
try {
$mailboxOptions = $this->mailboxes->forUser((string) $account->public_id);
} catch (\Throwable $e) {
report($e);
}
try {
$linkStatus = $this->identity->mailboxLinkStatus((string) $account->public_id);
} catch (\Throwable $e) {
report($e);
$linkStatus = $this->localMailboxLinkStatus($account, $mailboxOptions);
}
$showMailboxLinkUi = (bool) ($linkStatus['linked_mailbox'] ?? null)
|| ($linkStatus['show_reminder'] ?? false)
|| $this->localNeedsMailboxLink($account, $mailboxOptions, $linkStatus);
return [
'linkStatus' => $linkStatus,
'mailboxOptions' => $mailboxOptions,
'showMailboxLinkUi' => $showMailboxLinkUi,
];
}
/** @param array<int, array<string, mixed>> $mailboxOptions */
private function localNeedsMailboxLink(User $account, array $mailboxOptions, array $linkStatus): bool
{
if ($linkStatus['linked_mailbox'] ?? null) {
return false;
}
$email = strtolower(trim($account->email ?? ''));
if ($email === '') {
return false;
}
foreach ($mailboxOptions as $mailbox) {
if (strtolower((string) ($mailbox['address'] ?? '')) === $email) {
return false;
}
}
return count($mailboxOptions) > 0;
}
/** @param array<int, array<string, mixed>> $mailboxOptions */
/** @return array<string, mixed> */
private function localMailboxLinkStatus(User $account, array $mailboxOptions): array
{
$needsLink = $this->localNeedsMailboxLink($account, $mailboxOptions, []);
return [
'show_reminder' => $needsLink,
'stage' => count($mailboxOptions) > 0 ? 'needs_link' : 'needs_mailbox',
'linked_mailbox' => null,
'account_email' => $account->email,
];
}
public function updateSettings(Request $request): RedirectResponse
{
$account = ladill_account();
$data = $request->validate([
'default_quota_mb' => ['nullable', 'integer', 'min:256', 'max:153600'],
'notify_email' => ['nullable', 'email', 'max:255'],
'product_updates' => ['nullable', 'boolean'],
]);
EmailSetting::updateOrCreate(
HostingSetting::updateOrCreate(
['user_id' => $account->id],
[
'default_quota_mb' => $data['default_quota_mb'] ?? null,
'notify_email' => $data['notify_email'] ?? null,
'product_updates' => $request->boolean('product_updates'),
],
@@ -265,7 +95,7 @@ class AccountController extends Controller
try {
return [
$this->wallet->balanceMinor(ladill_account()),
$this->billing->serviceLedger($publicId, (string) config('billing.service', 'email')),
$this->billing->serviceLedger($publicId, (string) config('billing.service', 'hosting')),
];
} catch (\Throwable $e) {
report($e);
+50 -25
View File
@@ -3,17 +3,17 @@
namespace App\Http\Controllers\Hosting;
use App\Http\Controllers\Controller;
use App\Models\CustomerHostingOrder;
use App\Models\HostedSite;
use App\Models\HostingAccount;
use App\Models\HostingAccountMember;
use App\Models\HostingProduct;
use App\Services\Afia\AfiaService;
use App\Services\Billing\BillingClient;
use App\Services\EmailDomain\EmailDomainClient;
use App\Services\Mailbox\MailboxClient;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Ladill Email Afia chat endpoint. Grounds the assistant in the user's live
* mailboxes/domains so answers are specific.
*/
/** Afia chat for Ladill Hosting — grounded in the user's live hosting accounts. */
class AfiaController extends Controller
{
public function chat(Request $request, AfiaService $afia): JsonResponse
@@ -40,35 +40,60 @@ class AfiaController extends Controller
return response()->json(['reply' => $reply]);
}
/** @return array<string,mixed> Live email context for grounding. */
/** @return array<string,mixed> */
private function context(): array
{
$account = ladill_account();
$user = auth()->user();
if (! $account) {
if (! $user) {
return ['signed_in' => 'no'];
}
$pid = (string) $account->public_id;
$ctx = ['signed_in' => 'yes'];
$sharedTypes = [
HostingProduct::TYPE_SINGLE_DOMAIN,
HostingProduct::TYPE_MULTI_DOMAIN,
HostingProduct::TYPE_WORDPRESS,
];
$sharedAccountIds = HostingAccountMember::query()
->where('user_id', $user->id)
->pluck('hosting_account_id');
$accounts = HostingAccount::query()
->where(function ($query) use ($user, $sharedAccountIds) {
$query->where('user_id', $user->id);
if ($sharedAccountIds->isNotEmpty()) {
$query->orWhereIn('id', $sharedAccountIds);
}
})
->whereHas('product', fn ($q) => $q->whereIn('type', $sharedTypes))
->with('sites')
->get();
$ctx = [
'signed_in' => 'yes',
'hosting_accounts_total' => $accounts->count(),
'hosting_accounts_active' => $accounts->where('status', HostingAccount::STATUS_ACTIVE)->count(),
'linked_domains' => HostedSite::query()
->whereIn('hosting_account_id', $accounts->pluck('id'))
->count(),
'pending_orders' => CustomerHostingOrder::query()
->forUser($user->id)
->whereIn('status', [
CustomerHostingOrder::STATUS_PENDING_PAYMENT,
CustomerHostingOrder::STATUS_PENDING_APPROVAL,
CustomerHostingOrder::STATUS_APPROVED,
CustomerHostingOrder::STATUS_PROVISIONING,
])
->count(),
];
$account = ladill_account();
if ($account?->public_id) {
try {
$mailboxes = app(MailboxClient::class)->forUser($pid);
$ctx['mailboxes_total'] = count($mailboxes);
$ctx['mailboxes_paid'] = count(array_filter($mailboxes, fn ($m) => (bool) ($m['is_paid'] ?? false)));
$ctx['wallet_balance_ghs'] = number_format(app(BillingClient::class)->balanceMinor((string) $account->public_id) / 100, 2);
} catch (\Throwable) {
}
try {
$domains = app(EmailDomainClient::class)->forUser($pid);
$ctx['domains_total'] = count($domains);
$ctx['domains_verified'] = count(array_filter($domains, fn ($d) => (bool) ($d['active'] ?? $d['verified'] ?? false)));
} catch (\Throwable) {
}
try {
$ctx['wallet_balance_ghs'] = number_format(app(BillingClient::class)->balanceMinor($pid) / 100, 2);
} catch (\Throwable) {
}
return $ctx;
@@ -76,7 +76,7 @@ class HostingProductController extends Controller
}
})
->whereHas('product', fn ($q) => $q->ofType($type))
->with('product')
->with(['product', 'sites'])
->latest()
->get();
+20
View File
@@ -135,6 +135,26 @@ class HostingAccount extends Model
return $this->hasMany(HostedSite::class);
}
/** Primary label for UI lists — prefers linked hosted_sites over primary_domain. */
public function linkedDomainLabel(): ?string
{
if ($this->relationLoaded('sites') && $this->sites->isNotEmpty()) {
$domains = $this->sites->pluck('domain')->filter()->unique()->values();
if ($domains->count() === 1) {
return (string) $domains->first();
}
$preview = $domains->take(2)->implode(', ');
return $domains->count() > 2
? "{$preview} +".($domains->count() - 2)
: $preview;
}
return filled($this->primary_domain) ? (string) $this->primary_domain : null;
}
public function databases(): HasMany
{
return $this->hasMany(HostedDatabase::class);
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/** Per-account hosting preferences (notifications, product updates). */
class HostingSetting extends Model
{
protected $fillable = ['user_id', 'notify_email', 'product_updates'];
protected $casts = ['product_updates' => 'boolean'];
}
+36 -3
View File
@@ -6,9 +6,9 @@ use Illuminate\Support\Facades\Http;
use RuntimeException;
/**
* Afia for Ladill Email an AI assistant scoped to buying & managing mailboxes
* and email domains. Self-contained: builds an email-specific system prompt +
* the user's live context and calls the configured LLM (OpenAI or Anthropic).
* Afia an AI assistant scoped to a Ladill product (hosting or email).
* Self-contained: builds a product-specific system prompt + live context
* and calls the configured LLM (OpenAI or Anthropic).
*/
class AfiaService
{
@@ -90,6 +90,39 @@ class AfiaService
{
$ctx = collect($context)->map(fn ($v, $k) => "- {$k}: {$v}")->implode("\n");
return match ((string) config('afia.product', 'hosting')) {
'email' => $this->emailSystemPrompt($ctx),
default => $this->hostingSystemPrompt($ctx),
};
}
private function hostingSystemPrompt(string $ctx): string
{
return <<<PROMPT
You are Afia, the assistant inside Ladill Hosting Ladill's shared web hosting product (hosting.ladill.com).
Help the user buy hosting, manage accounts, link domains, use the control panel, and understand billing. Be concise,
friendly, and actionable: give short step-by-step answers and point to the relevant section by name.
What Ladill Hosting does and where things live:
- Dashboard: overview of all hosting accounts, pending orders, and linked domains.
- Product pages: Single Domain, Multi Domain, and WordPress hosting order new plans here.
- Account overview: per-account summary (plan, expiry, linked domains, email usage) with a Control Panel button.
- Control Panel: file manager, domains, databases, SSL, PHP settings, cron, terminal, and one-click apps.
- Domains: link a Ladill domain or an external domain to a hosting account; DNS may need updating for external domains.
- Billing: hosting renewals and upgrades are paid from the Ladill wallet (account portal Wallet).
- Team: invite teammates to co-manage hosting (sidebar Team).
Rules: Only answer questions about Ladill Hosting plans, accounts, domains on hosting, the control panel,
SSL, file manager, renewals, and wallet billing. If asked about Ladill Email, domains registration, or VPS/servers,
briefly redirect to the right Ladill app. Never invent passwords, DNS values, or prices tell the user where to find them.
Current user context:
{$ctx}
PROMPT;
}
private function emailSystemPrompt(string $ctx): string
{
return <<<PROMPT
You are Afia, the assistant inside Ladill Email Ladill's mailbox product (email.ladill.com).
Help the user set up email domains, create and manage mailboxes, and understand billing. Be concise,
+2 -2
View File
@@ -1,8 +1,8 @@
<?php
return [
// Afia — Ladill Email's in-app AI assistant. Self-contained: the app calls
// the LLM directly with an email-specific prompt + the user's live context.
// Afia — in-app AI assistant. Product scope is set per deployed app (hosting | email).
'product' => env('AFIA_PRODUCT', 'hosting'),
'enabled' => (bool) env('AFIA_ENABLED', true),
'provider' => env('AFIA_PROVIDER', 'openai'), // openai | anthropic
'model' => env('AFIA_MODEL', 'gpt-4o-mini'),
@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('hosting_settings', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->unique()->constrained()->cascadeOnDelete();
$table->string('notify_email')->nullable();
$table->boolean('product_updates')->default(true);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('hosting_settings');
}
};
+4 -9
View File
@@ -5,21 +5,16 @@ import collapse from '@alpinejs/collapse';
Alpine.plugin(collapse);
// Afia — Ladill Email's AI assistant (slide-over chat). Opened via the topbar AI button
// which dispatches a window 'afia-open' event.
// Afia — Ladill in-app AI assistant (slide-over chat). Opened via the topbar AI button
// which dispatches a window 'afia-open' event. Greeting/suggestions are passed from Blade.
Alpine.data('afia', (config = {}) => ({
open: false,
input: '',
loading: false,
messages: [
{ role: 'assistant', text: "Hi, I'm Afia 👋 Ask me anything about email — setting up a domain, verifying DNS, creating mailboxes, IMAP/SMTP settings or billing…" },
],
suggestions: [
'How do I set up email for my domain?',
'What DNS records do I need to verify?',
'How do I create a mailbox?',
'What are my IMAP and SMTP settings?',
{ role: 'assistant', text: config.greeting || "Hi, I'm Afia 👋 How can I help?" },
],
suggestions: config.suggestions || [],
init() {
window.addEventListener('afia-open', () => {
this.open = true;
@@ -1,8 +1,10 @@
<x-app-layout title="Billing — Ladill Hosting">
@extends('layouts.hosting')
@section('title', 'Billing — Ladill Hosting')
@section('content')
@php $fmt = fn ($m) => 'GHS '.number_format($m / 100, 2); @endphp
<div class="mx-auto max-w-3xl">
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Billing</h1>
<p class="mt-0.5 text-sm text-slate-500">Your Ladill wallet funds hosting and other Ladill apps.</p>
<p class="mt-0.5 text-sm text-slate-500">Your Ladill wallet funds hosting renewals and upgrades.</p>
<div class="mt-6 grid gap-4 sm:grid-cols-3">
<div class="rounded-2xl bg-gradient-to-br from-indigo-700 via-indigo-600 to-blue-500 p-5 text-white shadow-sm sm:col-span-1">
@@ -20,31 +22,7 @@
</div>
</div>
<div class="mt-6 rounded-2xl border border-slate-200 bg-white">
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-3.5">
<h2 class="text-sm font-semibold text-slate-900">Hosting subscriptions</h2>
<span class="text-xs text-slate-400">billed from wallet</span>
<p class="mt-6 text-sm text-slate-600">Hosting plan renewals and upgrades are billed from your Ladill wallet. Manage individual accounts from the dashboard or account overview pages.</p>
<p class="mt-2 text-xs text-slate-400">Keep your wallet funded to avoid hosting interruption.</p>
</div>
@if(empty($paidMailboxes))
<p class="px-5 py-8 text-center text-sm text-slate-400">No active hosting subscriptions yet.</p>
@else
<ul class="divide-y divide-slate-50">
@foreach($paidMailboxes as $m)
<li class="flex items-center justify-between px-5 py-3.5">
<div>
<p class="text-sm font-medium text-slate-900">{{ $m['address'] ?? '—' }}</p>
<p class="text-xs text-slate-400">{{ $m['quota_label'] }} · {{ ucfirst($m['status'] ?? 'active') }}</p>
</div>
<span class="text-sm font-medium text-slate-700">{{ $fmt($m['price_minor']) }}<span class="text-xs text-slate-400">/mo</span></span>
</li>
@endforeach
</ul>
<div class="flex items-center justify-between border-t border-slate-100 px-5 py-3">
<span class="text-xs font-medium uppercase tracking-wide text-slate-400">Monthly total</span>
<span class="text-sm font-semibold text-slate-900">{{ $fmt($monthlyTotalMinor) }}</span>
</div>
@endif
</div>
<p class="mt-4 text-xs text-slate-400">Keep your wallet funded to avoid hosting interruption.</p>
</div>
</x-app-layout>
@endsection
@@ -1,71 +1,16 @@
<x-app-layout title="Settings — Ladill Hosting">
@php $emailApp = 'https://email.'.config('app.platform_domain'); @endphp
@extends('layouts.hosting')
@section('title', 'Settings — Ladill Hosting')
@section('content')
<div class="mx-auto max-w-2xl">
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Settings</h1>
<p class="mt-0.5 text-sm text-slate-500">Hosting preferences and account mailbox linking.</p>
@if($showMailboxLinkUi ?? (($linkStatus['linked_mailbox'] ?? null) || ($linkStatus['show_reminder'] ?? false)))
<div class="mt-6 rounded-2xl border border-indigo-200 bg-white p-5 shadow-sm">
<div class="flex items-start gap-3">
<span class="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-indigo-50">
@include('partials.ladill-pro-icon')
</span>
<div class="min-w-0 flex-1">
<h2 class="text-sm font-semibold text-slate-900">Link to mailbox</h2>
<p class="mt-1 text-sm leading-relaxed text-slate-500">
Your Ladill account uses <strong class="font-medium text-slate-700">{{ $linkStatus['account_email'] ?? $account->email }}</strong>.
Link a mailbox so <strong class="font-medium text-slate-700">Ladill Mail</strong> opens with your Ladill sign-in.
</p>
@if($linkStatus['linked_mailbox'] ?? null)
<div class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2.5 text-sm text-emerald-800">
Linked mailbox: <span class="font-semibold">{{ $linkStatus['linked_mailbox'] }}</span>
</div>
<form method="POST" action="{{ route('account.settings.mailbox-unlink') }}" class="mt-3"
onsubmit="return confirm('Unlink this mailbox from your Ladill account? Ladill Mail will no longer open automatically with Ladill sign-in.')">
@csrf @method('DELETE')
<button type="submit" class="rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-slate-700 transition hover:border-slate-300 hover:bg-slate-50">
Unlink mailbox
</button>
</form>
@elseif(($linkStatus['stage'] ?? '') === 'needs_domain')
<p class="mt-3 text-sm text-amber-800">Add and verify an email domain first.</p>
<a href="{{ $emailApp }}/domains" class="mt-3 inline-flex items-center gap-1 text-sm font-semibold text-indigo-700 hover:text-indigo-800">
Go to Ladill Email
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3"/></svg>
</a>
@elseif(($linkStatus['stage'] ?? '') === 'needs_mailbox')
<p class="mt-3 text-sm text-amber-800">Create a mailbox on your verified domain first.</p>
<a href="{{ $emailApp }}/mailboxes/create" class="mt-3 inline-flex items-center gap-1 text-sm font-semibold text-indigo-700 hover:text-indigo-800">
Create mailbox
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3"/></svg>
</a>
@elseif(count($mailboxOptions) > 0)
<form method="POST" action="{{ route('account.settings.mailbox-link') }}" class="mt-4 flex flex-col gap-3 sm:flex-row sm:items-end">
@csrf @method('PUT')
<div class="min-w-0 flex-1">
<label for="mailbox_address" class="block text-[11px] font-medium text-slate-500">Choose mailbox</label>
<select id="mailbox_address" name="mailbox_address" required class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
<option value="" disabled @selected(! old('mailbox_address'))>Select a mailbox</option>
@foreach($mailboxOptions as $mailbox)
<option value="{{ $mailbox['address'] }}" @selected(old('mailbox_address') === $mailbox['address'])>{{ $mailbox['address'] }}</option>
@endforeach
</select>
@error('mailbox_address')<p class="mt-1 text-xs text-rose-600">{{ $message }}</p>@enderror
</div>
<button class="rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-indigo-700">Link mailbox</button>
</form>
@endif
</div>
</div>
</div>
@endif
<p class="mt-0.5 text-sm text-slate-500">Hosting notifications and preferences.</p>
<form method="POST" action="{{ route('account.settings.update') }}" class="mt-6 space-y-4">
@csrf @method('PUT')
<div class="rounded-2xl border border-slate-200 bg-white p-5">
<h2 class="text-sm font-semibold text-slate-900">Notifications</h2>
<p class="mt-1 text-xs text-slate-500">Where we send hosting renewal reminders, suspension notices, and resource warnings.</p>
<div class="mt-4">
<label class="block text-[11px] font-medium text-slate-500">Notifications email</label>
<input type="email" name="notify_email" value="{{ old('notify_email', $settings->notify_email ?? $account->email) }}"
@@ -86,4 +31,4 @@
<button class="rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-indigo-700">Save settings</button>
</form>
</div>
</x-app-layout>
@endsection
@@ -1,4 +1,6 @@
<x-app-layout title="Wallet — Ladill Hosting">
@extends('layouts.hosting')
@section('title', 'Wallet — Ladill Hosting')
@section('content')
@php $fmt = fn ($m) => 'GHS '.number_format($m / 100, 2); @endphp
<div class="mx-auto max-w-3xl">
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Wallet</h1>
@@ -22,4 +24,4 @@
</div>
<p class="mt-4 text-xs text-slate-400">Hosting renewals and upgrades are billed from this wallet.</p>
</div>
</x-app-layout>
@endsection
@@ -31,9 +31,11 @@
</div>
<div>
<h3 class="font-semibold text-slate-900 group-hover:text-indigo-600 transition">
{{ $account->primary_domain ?: $account->username }}
{{ $account->linkedDomainLabel() ?? $account->username }}
</h3>
<div class="flex items-center gap-3 mt-1">
<span class="text-sm text-slate-500">{{ $account->username }}</span>
<span class="text-sm text-slate-400">·</span>
<span class="text-sm text-slate-500">{{ $account->product?->name ?? 'Hosting' }}</span>
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
@if($account->status === 'active') bg-emerald-100 text-emerald-700
+3 -2
View File
@@ -57,9 +57,10 @@
@endphp
<a href="{{ $accountUrl }}" class="flex items-center justify-between px-5 py-3.5 transition hover:bg-slate-50">
<div class="min-w-0">
<p class="truncate text-sm font-medium text-slate-900">{{ $account->primary_domain ?: $account->username }}</p>
<p class="truncate text-sm font-medium text-slate-900">{{ $account->linkedDomainLabel() ?? $account->username }}</p>
<p class="mt-0.5 text-xs text-slate-400">
{{ $account->product?->name ?? 'Hosting' }}
{{ $account->username }}
· {{ $account->product?->name ?? 'Hosting' }}
· {{ $account->sites->count() }} {{ Str::plural('domain', $account->sites->count()) }}
@if ($account->expires_at)
· Expires {{ $account->expires_at->format('d M Y') }}
+1 -1
View File
@@ -124,7 +124,7 @@
<p class="text-xs text-indigo-600">Developer access</p>
@endif
</td>
<td class="px-5 py-3 text-gray-600">{{ filled($account->primary_domain) ? $account->primary_domain : '—' }}</td>
<td class="px-5 py-3 text-gray-600">{{ $account->linkedDomainLabel() ?? '—' }}</td>
<td class="px-5 py-3">
<span class="inline-flex items-center rounded px-1.5 py-0.5 text-xs font-medium {{ $accountStatusColors[$account->status] ?? 'bg-gray-100 text-gray-500' }}">
{{ ucfirst($account->status) }}
@@ -21,7 +21,6 @@
@include('partials.topbar')
<main class="flex-1 p-6 pb-24 lg:p-6 lg:pb-6">
@include('partials.flash')
@include('partials.mailbox-link-banner')
@yield('content')
</main>
</div>
+19 -4
View File
@@ -1,5 +1,20 @@
{{-- Afia Ladill Hosting AI assistant slide-over. Opened via $dispatch('afia-open'). --}}
<div x-data="afia({ chatUrl: '{{ route('hosting.afia.chat') }}', csrf: '{{ csrf_token() }}' })">
@php
$afiaProduct = $afiaProduct ?? (request()->routeIs('email.*') ? 'email' : 'hosting');
$afiaGreeting = $afiaProduct === 'email'
? "Hi, I'm Afia 👋 Ask me anything about email — setting up a domain, verifying DNS, creating mailboxes, IMAP/SMTP settings or billing…"
: "Hi, I'm Afia 👋 Ask me anything about hosting — choosing a plan, linking a domain, using the control panel, SSL, or renewals…";
$afiaSuggestions = $afiaProduct === 'email'
? ['How do I set up email for my domain?', 'What DNS records do I need to verify?', 'How do I create a mailbox?', 'What are my IMAP and SMTP settings?']
: ['How do I link a domain to my hosting?', 'How do I open the control panel?', 'How do I renew my hosting plan?', 'How do I install WordPress?'];
$afiaSubtitle = $afiaProduct === 'email' ? 'Email assistant' : 'Hosting assistant';
@endphp
{{-- Afia Ladill AI assistant slide-over. Opened via $dispatch('afia-open'). --}}
<div x-data="afia({
chatUrl: '{{ $afiaProduct === 'email' ? route('email.afia.chat') : route('hosting.afia.chat') }}',
csrf: '{{ csrf_token() }}',
greeting: @js($afiaGreeting),
suggestions: @js($afiaSuggestions),
})">
<div x-show="open" x-cloak @click="close()" class="fixed inset-0 z-50 bg-slate-900/20"></div>
<section x-show="open" x-cloak
@@ -34,7 +49,7 @@
</span>
<div class="leading-tight">
<p class="text-sm font-semibold text-slate-900">Afia</p>
<p class="text-[11px] text-slate-400">Email assistant</p>
<p class="text-[11px] text-slate-400">{{ $afiaSubtitle }}</p>
</div>
</div>
<button @click="close()" class="rounded-lg p-1.5 text-slate-400 hover:bg-slate-100 hover:text-slate-600">
@@ -62,7 +77,7 @@
</div>
</div>
<div x-show="messages.length <= 1" class="mt-4 space-y-2">
<div x-show="messages.length <= 1 && suggestions.length" class="mt-4 space-y-2">
<template x-for="s in suggestions" :key="s">
<button @click="useSuggestion(s)" :disabled="loading"
class="flex w-full items-center gap-2 rounded-xl border border-slate-200 bg-white px-3.5 py-2.5 text-left text-[13px] font-medium text-slate-700 transition hover:border-indigo-200 hover:bg-indigo-50 disabled:opacity-50">
-4
View File
@@ -8,7 +8,6 @@ use App\Http\Controllers\Hosting\HostingController;
use App\Http\Controllers\Hosting\HostingPanelController;
use App\Http\Controllers\Hosting\HostingProductController;
use App\Http\Controllers\Hosting\HostingTerminalSessionController;
use App\Http\Controllers\Hosting\MailboxLinkBannerController;
use App\Http\Controllers\Hosting\OverviewController;
use App\Http\Controllers\Hosting\TeamController;
use Illuminate\Support\Facades\Route;
@@ -118,9 +117,6 @@ Route::middleware(['auth'])->group(function () use ($serversApp) {
Route::get('/account/billing', [AccountController::class, 'billing'])->name('account.billing');
Route::get('/account/settings', [AccountController::class, 'settings'])->name('account.settings');
Route::put('/account/settings', [AccountController::class, 'updateSettings'])->name('account.settings.update');
Route::put('/account/settings/mailbox-link', [AccountController::class, 'linkMailbox'])->name('account.settings.mailbox-link');
Route::delete('/account/settings/mailbox-link', [AccountController::class, 'unlinkMailbox'])->name('account.settings.mailbox-unlink');
Route::post('/mailbox-link-banner/dismiss', [MailboxLinkBannerController::class, 'dismiss'])->name('account.mailbox-link-banner.dismiss');
Route::get('/account/team', [TeamController::class, 'index'])->name('account.team');
Route::post('/account/team', [TeamController::class, 'store'])->name('account.team.store');
+2 -7
View File
@@ -5,7 +5,6 @@ namespace Tests\Feature;
use App\Models\EmailTeamMember;
use App\Models\User;
use App\Services\Billing\BillingClient;
use App\Services\Mailbox\MailboxClient;
use App\Services\Payment\WalletPaymentService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
@@ -29,7 +28,7 @@ class AccountPagesTest extends TestCase
public function test_settings_page_renders(): void
{
$this->actingAs($this->user())->get(route('account.settings'))->assertOk()->assertSee('Mailbox defaults');
$this->actingAs($this->user())->get(route('account.settings'))->assertOk()->assertSee('Hosting notifications');
}
public function test_team_page_renders_with_owner(): void
@@ -66,12 +65,8 @@ class AccountPagesTest extends TestCase
$b->shouldReceive('balanceMinor')->andReturn(5000);
$this->app->instance(BillingClient::class, $b);
$m = Mockery::mock(MailboxClient::class);
$m->shouldReceive('forUser')->andReturn([['address' => 'a@x.com', 'is_paid' => true, 'status' => 'active', 'quota_mb' => 10240]]);
$this->app->instance(MailboxClient::class, $m);
$this->actingAs($this->user())->get(route('account.billing'))
->assertOk()->assertSee('Mailbox subscriptions')->assertSee('a@x.com')->assertSee('10 GB');
->assertOk()->assertSee('Spent on hosting');
}
public function test_switch_account_rejects_inaccessible(): void