Show linked domains on cards and fix Email leftovers.
Deploy Ladill Hosting / deploy (push) Successful in 24s
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:
@@ -3,29 +3,22 @@
|
|||||||
namespace App\Http\Controllers\Hosting;
|
namespace App\Http\Controllers\Hosting;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\EmailSetting;
|
use App\Models\HostingSetting;
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Billing\BillingClient;
|
use App\Services\Billing\BillingClient;
|
||||||
use App\Services\Identity\IdentityClient;
|
|
||||||
use App\Services\Mailbox\MailboxClient;
|
|
||||||
use App\Services\Payment\WalletPaymentService;
|
use App\Services\Payment\WalletPaymentService;
|
||||||
use App\Support\MailboxPricing;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Account — Wallet, Billing, Settings. Money is read from the one platform
|
* Account — Wallet, Billing, Settings for Ladill Hosting.
|
||||||
* UserWallet via the Billing API (tagged 'email'); funding is platform-level, so
|
* Money is read from the one platform UserWallet via the Billing API.
|
||||||
* "Add funds" deep-links to the account portal. Settings are email-local prefs.
|
|
||||||
*/
|
*/
|
||||||
class AccountController extends Controller
|
class AccountController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private WalletPaymentService $wallet,
|
private WalletPaymentService $wallet,
|
||||||
private BillingClient $billing,
|
private BillingClient $billing,
|
||||||
private MailboxClient $mailboxes,
|
|
||||||
private IdentityClient $identity,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private function topupUrl(): string
|
private function topupUrl(): string
|
||||||
@@ -33,7 +26,6 @@ class AccountController extends Controller
|
|||||||
return 'https://'.config('app.account_domain').'/wallet';
|
return 'https://'.config('app.account_domain').'/wallet';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Wallet: balance + email spend summary + add-funds link. */
|
|
||||||
public function wallet(): View
|
public function wallet(): View
|
||||||
{
|
{
|
||||||
$account = ladill_account();
|
$account = ladill_account();
|
||||||
@@ -47,34 +39,15 @@ class AccountController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Billing: wallet snapshot + recurring paid-mailbox subscriptions. */
|
|
||||||
public function billing(): View
|
public function billing(): View
|
||||||
{
|
{
|
||||||
$account = ladill_account();
|
$account = ladill_account();
|
||||||
[$balanceMinor, $ledger] = $this->billingSnapshot($account?->public_id);
|
[$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', [
|
return view('hosting.account.billing', [
|
||||||
'balanceMinor' => $balanceMinor,
|
'balanceMinor' => $balanceMinor,
|
||||||
'spentMinor' => (int) ($ledger['spent_minor'] ?? 0),
|
'spentMinor' => (int) ($ledger['spent_minor'] ?? 0),
|
||||||
'creditedMinor' => (int) ($ledger['credited_minor'] ?? 0),
|
'creditedMinor' => (int) ($ledger['credited_minor'] ?? 0),
|
||||||
'paidMailboxes' => $paidMailboxes,
|
|
||||||
'monthlyTotalMinor' => $monthlyTotalMinor,
|
|
||||||
'topupUrl' => $this->topupUrl(),
|
'topupUrl' => $this->topupUrl(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -82,169 +55,26 @@ class AccountController extends Controller
|
|||||||
public function settings(): View
|
public function settings(): View
|
||||||
{
|
{
|
||||||
$account = ladill_account();
|
$account = ladill_account();
|
||||||
$settings = EmailSetting::firstOrNew(['user_id' => $account?->id]);
|
$settings = HostingSetting::firstOrNew(['user_id' => $account?->id]);
|
||||||
['linkStatus' => $linkStatus, 'mailboxOptions' => $mailboxOptions, 'showMailboxLinkUi' => $showMailboxLinkUi] = $this->mailboxLinkContext($account);
|
|
||||||
|
|
||||||
return view('hosting.account.settings', [
|
return view('hosting.account.settings', [
|
||||||
'account' => $account,
|
'account' => $account,
|
||||||
'settings' => $settings,
|
'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
|
public function updateSettings(Request $request): RedirectResponse
|
||||||
{
|
{
|
||||||
$account = ladill_account();
|
$account = ladill_account();
|
||||||
|
|
||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
'default_quota_mb' => ['nullable', 'integer', 'min:256', 'max:153600'],
|
|
||||||
'notify_email' => ['nullable', 'email', 'max:255'],
|
'notify_email' => ['nullable', 'email', 'max:255'],
|
||||||
'product_updates' => ['nullable', 'boolean'],
|
'product_updates' => ['nullable', 'boolean'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
EmailSetting::updateOrCreate(
|
HostingSetting::updateOrCreate(
|
||||||
['user_id' => $account->id],
|
['user_id' => $account->id],
|
||||||
[
|
[
|
||||||
'default_quota_mb' => $data['default_quota_mb'] ?? null,
|
|
||||||
'notify_email' => $data['notify_email'] ?? null,
|
'notify_email' => $data['notify_email'] ?? null,
|
||||||
'product_updates' => $request->boolean('product_updates'),
|
'product_updates' => $request->boolean('product_updates'),
|
||||||
],
|
],
|
||||||
@@ -265,7 +95,7 @@ class AccountController extends Controller
|
|||||||
try {
|
try {
|
||||||
return [
|
return [
|
||||||
$this->wallet->balanceMinor(ladill_account()),
|
$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) {
|
} catch (\Throwable $e) {
|
||||||
report($e);
|
report($e);
|
||||||
|
|||||||
@@ -3,17 +3,17 @@
|
|||||||
namespace App\Http\Controllers\Hosting;
|
namespace App\Http\Controllers\Hosting;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
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\Afia\AfiaService;
|
||||||
use App\Services\Billing\BillingClient;
|
use App\Services\Billing\BillingClient;
|
||||||
use App\Services\EmailDomain\EmailDomainClient;
|
|
||||||
use App\Services\Mailbox\MailboxClient;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
/**
|
/** Afia chat for Ladill Hosting — grounded in the user's live hosting accounts. */
|
||||||
* Ladill Email — Afia chat endpoint. Grounds the assistant in the user's live
|
|
||||||
* mailboxes/domains so answers are specific.
|
|
||||||
*/
|
|
||||||
class AfiaController extends Controller
|
class AfiaController extends Controller
|
||||||
{
|
{
|
||||||
public function chat(Request $request, AfiaService $afia): JsonResponse
|
public function chat(Request $request, AfiaService $afia): JsonResponse
|
||||||
@@ -40,35 +40,60 @@ class AfiaController extends Controller
|
|||||||
return response()->json(['reply' => $reply]);
|
return response()->json(['reply' => $reply]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return array<string,mixed> Live email context for grounding. */
|
/** @return array<string,mixed> */
|
||||||
private function context(): array
|
private function context(): array
|
||||||
{
|
{
|
||||||
$account = ladill_account();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $account) {
|
if (! $user) {
|
||||||
return ['signed_in' => 'no'];
|
return ['signed_in' => 'no'];
|
||||||
}
|
}
|
||||||
|
|
||||||
$pid = (string) $account->public_id;
|
$sharedTypes = [
|
||||||
$ctx = ['signed_in' => 'yes'];
|
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 {
|
try {
|
||||||
$mailboxes = app(MailboxClient::class)->forUser($pid);
|
$ctx['wallet_balance_ghs'] = number_format(app(BillingClient::class)->balanceMinor((string) $account->public_id) / 100, 2);
|
||||||
$ctx['mailboxes_total'] = count($mailboxes);
|
|
||||||
$ctx['mailboxes_paid'] = count(array_filter($mailboxes, fn ($m) => (bool) ($m['is_paid'] ?? false)));
|
|
||||||
} catch (\Throwable) {
|
} 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;
|
return $ctx;
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ class HostingProductController extends Controller
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
->whereHas('product', fn ($q) => $q->ofType($type))
|
->whereHas('product', fn ($q) => $q->ofType($type))
|
||||||
->with('product')
|
->with(['product', 'sites'])
|
||||||
->latest()
|
->latest()
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
|
|||||||
@@ -135,6 +135,26 @@ class HostingAccount extends Model
|
|||||||
return $this->hasMany(HostedSite::class);
|
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
|
public function databases(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(HostedDatabase::class);
|
return $this->hasMany(HostedDatabase::class);
|
||||||
|
|||||||
@@ -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'];
|
||||||
|
}
|
||||||
@@ -6,9 +6,9 @@ use Illuminate\Support\Facades\Http;
|
|||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Afia for Ladill Email — an AI assistant scoped to buying & managing mailboxes
|
* Afia — an AI assistant scoped to a Ladill product (hosting or email).
|
||||||
* and email domains. Self-contained: builds an email-specific system prompt +
|
* Self-contained: builds a product-specific system prompt + live context
|
||||||
* the user's live context and calls the configured LLM (OpenAI or Anthropic).
|
* and calls the configured LLM (OpenAI or Anthropic).
|
||||||
*/
|
*/
|
||||||
class AfiaService
|
class AfiaService
|
||||||
{
|
{
|
||||||
@@ -90,6 +90,39 @@ class AfiaService
|
|||||||
{
|
{
|
||||||
$ctx = collect($context)->map(fn ($v, $k) => "- {$k}: {$v}")->implode("\n");
|
$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
|
return <<<PROMPT
|
||||||
You are Afia, the assistant inside Ladill Email — Ladill's mailbox product (email.ladill.com).
|
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,
|
Help the user set up email domains, create and manage mailboxes, and understand billing. Be concise,
|
||||||
|
|||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
return [
|
return [
|
||||||
// Afia — Ladill Email's in-app AI assistant. Self-contained: the app calls
|
// Afia — in-app AI assistant. Product scope is set per deployed app (hosting | email).
|
||||||
// the LLM directly with an email-specific prompt + the user's live context.
|
'product' => env('AFIA_PRODUCT', 'hosting'),
|
||||||
'enabled' => (bool) env('AFIA_ENABLED', true),
|
'enabled' => (bool) env('AFIA_ENABLED', true),
|
||||||
'provider' => env('AFIA_PROVIDER', 'openai'), // openai | anthropic
|
'provider' => env('AFIA_PROVIDER', 'openai'), // openai | anthropic
|
||||||
'model' => env('AFIA_MODEL', 'gpt-4o-mini'),
|
'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
@@ -5,21 +5,16 @@ import collapse from '@alpinejs/collapse';
|
|||||||
|
|
||||||
Alpine.plugin(collapse);
|
Alpine.plugin(collapse);
|
||||||
|
|
||||||
// Afia — Ladill Email's AI assistant (slide-over chat). Opened via the topbar AI button
|
// Afia — Ladill in-app AI assistant (slide-over chat). Opened via the topbar AI button
|
||||||
// which dispatches a window 'afia-open' event.
|
// which dispatches a window 'afia-open' event. Greeting/suggestions are passed from Blade.
|
||||||
Alpine.data('afia', (config = {}) => ({
|
Alpine.data('afia', (config = {}) => ({
|
||||||
open: false,
|
open: false,
|
||||||
input: '',
|
input: '',
|
||||||
loading: false,
|
loading: false,
|
||||||
messages: [
|
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…" },
|
{ role: 'assistant', text: config.greeting || "Hi, I'm Afia 👋 How can I help?" },
|
||||||
],
|
|
||||||
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?',
|
|
||||||
],
|
],
|
||||||
|
suggestions: config.suggestions || [],
|
||||||
init() {
|
init() {
|
||||||
window.addEventListener('afia-open', () => {
|
window.addEventListener('afia-open', () => {
|
||||||
this.open = true;
|
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
|
@php $fmt = fn ($m) => 'GHS '.number_format($m / 100, 2); @endphp
|
||||||
<div class="mx-auto max-w-3xl">
|
<div class="mx-auto max-w-3xl">
|
||||||
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Billing</h1>
|
<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="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">
|
<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>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-white">
|
<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>
|
||||||
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-3.5">
|
<p class="mt-2 text-xs text-slate-400">Keep your wallet funded to avoid hosting interruption.</p>
|
||||||
<h2 class="text-sm font-semibold text-slate-900">Hosting subscriptions</h2>
|
|
||||||
<span class="text-xs text-slate-400">billed from wallet</span>
|
|
||||||
</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>
|
</div>
|
||||||
</x-app-layout>
|
@endsection
|
||||||
|
|||||||
@@ -1,71 +1,16 @@
|
|||||||
<x-app-layout title="Settings — Ladill Hosting">
|
@extends('layouts.hosting')
|
||||||
@php $emailApp = 'https://email.'.config('app.platform_domain'); @endphp
|
@section('title', 'Settings — Ladill Hosting')
|
||||||
|
@section('content')
|
||||||
<div class="mx-auto max-w-2xl">
|
<div class="mx-auto max-w-2xl">
|
||||||
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Settings</h1>
|
<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>
|
<p class="mt-0.5 text-sm text-slate-500">Hosting notifications and preferences.</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
|
|
||||||
|
|
||||||
<form method="POST" action="{{ route('account.settings.update') }}" class="mt-6 space-y-4">
|
<form method="POST" action="{{ route('account.settings.update') }}" class="mt-6 space-y-4">
|
||||||
@csrf @method('PUT')
|
@csrf @method('PUT')
|
||||||
|
|
||||||
<div class="rounded-2xl border border-slate-200 bg-white p-5">
|
<div class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||||
<h2 class="text-sm font-semibold text-slate-900">Notifications</h2>
|
<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">
|
<div class="mt-4">
|
||||||
<label class="block text-[11px] font-medium text-slate-500">Notifications email</label>
|
<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) }}"
|
<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>
|
<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>
|
</form>
|
||||||
</div>
|
</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
|
@php $fmt = fn ($m) => 'GHS '.number_format($m / 100, 2); @endphp
|
||||||
<div class="mx-auto max-w-3xl">
|
<div class="mx-auto max-w-3xl">
|
||||||
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Wallet</h1>
|
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Wallet</h1>
|
||||||
@@ -22,4 +24,4 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="mt-4 text-xs text-slate-400">Hosting renewals and upgrades are billed from this wallet.</p>
|
<p class="mt-4 text-xs text-slate-400">Hosting renewals and upgrades are billed from this wallet.</p>
|
||||||
</div>
|
</div>
|
||||||
</x-app-layout>
|
@endsection
|
||||||
|
|||||||
@@ -31,9 +31,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="font-semibold text-slate-900 group-hover:text-indigo-600 transition">
|
<h3 class="font-semibold text-slate-900 group-hover:text-indigo-600 transition">
|
||||||
{{ $account->primary_domain ?: $account->username }}
|
{{ $account->linkedDomainLabel() ?? $account->username }}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="flex items-center gap-3 mt-1">
|
<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="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
|
<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
|
@if($account->status === 'active') bg-emerald-100 text-emerald-700
|
||||||
|
|||||||
@@ -57,9 +57,10 @@
|
|||||||
@endphp
|
@endphp
|
||||||
<a href="{{ $accountUrl }}" class="flex items-center justify-between px-5 py-3.5 transition hover:bg-slate-50">
|
<a href="{{ $accountUrl }}" class="flex items-center justify-between px-5 py-3.5 transition hover:bg-slate-50">
|
||||||
<div class="min-w-0">
|
<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">
|
<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()) }}
|
· {{ $account->sites->count() }} {{ Str::plural('domain', $account->sites->count()) }}
|
||||||
@if ($account->expires_at)
|
@if ($account->expires_at)
|
||||||
· Expires {{ $account->expires_at->format('d M Y') }}
|
· Expires {{ $account->expires_at->format('d M Y') }}
|
||||||
|
|||||||
@@ -124,7 +124,7 @@
|
|||||||
<p class="text-xs text-indigo-600">Developer access</p>
|
<p class="text-xs text-indigo-600">Developer access</p>
|
||||||
@endif
|
@endif
|
||||||
</td>
|
</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">
|
<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' }}">
|
<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) }}
|
{{ ucfirst($account->status) }}
|
||||||
|
|||||||
@@ -21,7 +21,6 @@
|
|||||||
@include('partials.topbar')
|
@include('partials.topbar')
|
||||||
<main class="flex-1 p-6 pb-24 lg:p-6 lg:pb-6">
|
<main class="flex-1 p-6 pb-24 lg:p-6 lg:pb-6">
|
||||||
@include('partials.flash')
|
@include('partials.flash')
|
||||||
@include('partials.mailbox-link-banner')
|
|
||||||
@yield('content')
|
@yield('content')
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,20 @@
|
|||||||
{{-- Afia — Ladill Hosting AI assistant slide-over. Opened via $dispatch('afia-open'). --}}
|
@php
|
||||||
<div x-data="afia({ chatUrl: '{{ route('hosting.afia.chat') }}', csrf: '{{ csrf_token() }}' })">
|
$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>
|
<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
|
<section x-show="open" x-cloak
|
||||||
@@ -34,7 +49,7 @@
|
|||||||
</span>
|
</span>
|
||||||
<div class="leading-tight">
|
<div class="leading-tight">
|
||||||
<p class="text-sm font-semibold text-slate-900">Afia</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<button @click="close()" class="rounded-lg p-1.5 text-slate-400 hover:bg-slate-100 hover:text-slate-600">
|
<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>
|
</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">
|
<template x-for="s in suggestions" :key="s">
|
||||||
<button @click="useSuggestion(s)" :disabled="loading"
|
<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">
|
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">
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ use App\Http\Controllers\Hosting\HostingController;
|
|||||||
use App\Http\Controllers\Hosting\HostingPanelController;
|
use App\Http\Controllers\Hosting\HostingPanelController;
|
||||||
use App\Http\Controllers\Hosting\HostingProductController;
|
use App\Http\Controllers\Hosting\HostingProductController;
|
||||||
use App\Http\Controllers\Hosting\HostingTerminalSessionController;
|
use App\Http\Controllers\Hosting\HostingTerminalSessionController;
|
||||||
use App\Http\Controllers\Hosting\MailboxLinkBannerController;
|
|
||||||
use App\Http\Controllers\Hosting\OverviewController;
|
use App\Http\Controllers\Hosting\OverviewController;
|
||||||
use App\Http\Controllers\Hosting\TeamController;
|
use App\Http\Controllers\Hosting\TeamController;
|
||||||
use Illuminate\Support\Facades\Route;
|
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/billing', [AccountController::class, 'billing'])->name('account.billing');
|
||||||
Route::get('/account/settings', [AccountController::class, 'settings'])->name('account.settings');
|
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', [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::get('/account/team', [TeamController::class, 'index'])->name('account.team');
|
||||||
Route::post('/account/team', [TeamController::class, 'store'])->name('account.team.store');
|
Route::post('/account/team', [TeamController::class, 'store'])->name('account.team.store');
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ namespace Tests\Feature;
|
|||||||
use App\Models\EmailTeamMember;
|
use App\Models\EmailTeamMember;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Billing\BillingClient;
|
use App\Services\Billing\BillingClient;
|
||||||
use App\Services\Mailbox\MailboxClient;
|
|
||||||
use App\Services\Payment\WalletPaymentService;
|
use App\Services\Payment\WalletPaymentService;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
@@ -29,7 +28,7 @@ class AccountPagesTest extends TestCase
|
|||||||
|
|
||||||
public function test_settings_page_renders(): void
|
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
|
public function test_team_page_renders_with_owner(): void
|
||||||
@@ -66,12 +65,8 @@ class AccountPagesTest extends TestCase
|
|||||||
$b->shouldReceive('balanceMinor')->andReturn(5000);
|
$b->shouldReceive('balanceMinor')->andReturn(5000);
|
||||||
$this->app->instance(BillingClient::class, $b);
|
$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'))
|
$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
|
public function test_switch_account_rejects_inaccessible(): void
|
||||||
|
|||||||
Reference in New Issue
Block a user