De-fork POS chrome/assets and import CRM products only
Deploy Ladill POS / deploy (push) Successful in 32s

Finish removing the upstream platform fork: drop the Hosting/SSL/Domain
notification classes + their mail views, the email/mailbox/domains/hosting
view trees, the Mini/Afia/search/paystack partials and unused components, and
the now-orphan support helpers (DomainConfig, MailboxPricing,
ResellerClubLegacy), SmsService, and fork configs (afia, hosting, domain,
email, mailbox, qr, mail_brands, notifications, ...). Reparent the
notifications page off the hosting layout onto the POS app layout (it had
referenced undefined hosting./mini. routes). Computed the removable set from a
reachability scan of the POS view graph; kept views reference no removed
routes/services.

Branding: favicon now uses the updated favicon.ico (favicon.svg removed);
signed-out fallback logo points at the POS logo.

CRM import: constrain the catalogue pull to type=product so CRM services are
never imported as POS products (server-side filter + defensive client skip).

Test suite green (8 passed, 29 assertions); all routes resolve and every
remaining Blade compiles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
isaacclad
2026-06-24 00:40:01 +00:00
co-authored by Claude Opus 4.8
parent 46b5091b1e
commit b7c7a32ee6
87 changed files with 34 additions and 5410 deletions
@@ -1,45 +0,0 @@
<?php
namespace App\Notifications;
use App\Models\Domain;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;
use Illuminate\Notifications\Messages\MailMessage;
class DomainVerifiedNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
private readonly Domain $domain
) {
}
public function via($notifiable): array
{
return ['mail', 'database'];
}
public function toMail($notifiable): MailMessage
{
return (new MailMessage())
->subject("Domain Verified: {$this->domain->host} is now active!")
->view('mail.notifications.domain-verified', [
'domain' => $this->domain,
'manageUrl' => route('user.domains.show', $this->domain),
'emailUrl' => route('user.mailboxes.index'),
]);
}
public function toArray($notifiable): array
{
return [
'title' => 'Domain Verified',
'message' => "Your domain {$this->domain->host} has been verified and is now active.",
'icon' => 'success',
'url' => route('user.domains.show', $this->domain),
];
}
}
@@ -1,53 +0,0 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;
use Illuminate\Notifications\Messages\MailMessage;
class HostingActivatedNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
private readonly string $planName,
private readonly string $activationDate,
private readonly ?string $domainName = null,
private readonly ?string $manageUrl = null
) {
}
public function via($notifiable): array
{
return ['mail', 'database'];
}
public function toMail($notifiable): MailMessage
{
return (new MailMessage())
->subject('Your hosting is now active!')
->view('mail.notifications.hosting-activated', [
'planName' => $this->planName,
'activationDate' => $this->activationDate,
'domainName' => $this->domainName,
'manageUrl' => $this->manageUrl ?? route('hosting.index'),
]);
}
public function toArray($notifiable): array
{
$message = "Your {$this->planName} hosting plan is now active";
if ($this->domainName) {
$message .= " for {$this->domainName}";
}
return [
'title' => 'Hosting Activated',
'message' => $message . '.',
'icon' => 'hosting',
'url' => $this->manageUrl ?? route('hosting.index'),
];
}
}
@@ -1,54 +0,0 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class HostingDeveloperAddedNotification extends Notification implements ShouldQueue
{
use Queueable;
/**
* @param array<int, string> $accountLabels
*/
public function __construct(
private string $ownerName,
private array $accountLabels,
private ?string $setupUrl = null
) {}
public function via($notifiable): array
{
return ['mail', 'database'];
}
public function toMail($notifiable): MailMessage
{
return (new MailMessage())
->subject('You were added to a Ladill hosting team')
->view('mail.notifications.hosting-developer-added', [
'developer' => $notifiable,
'ownerName' => $this->ownerName,
'accountLabels' => $this->accountLabels,
'setupUrl' => $this->setupUrl,
'loginUrl' => route('login'),
'dashboardUrl' => route('dashboard'),
]);
}
public function toArray($notifiable): array
{
$accountCount = count($this->accountLabels);
$scope = $accountCount === 1 ? $this->accountLabels[0] : $accountCount.' hosting accounts';
return [
'title' => 'Hosting team access granted',
'message' => "You were added by {$this->ownerName} to {$scope}.",
'icon' => 'hosting',
'url' => route('hosting.single-domain'),
];
}
}
@@ -1,68 +0,0 @@
<?php
namespace App\Notifications;
use App\Models\HostingAccount;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class HostingExpiringNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
private readonly HostingAccount $account,
private readonly int $daysRemaining,
) {
}
public function via($notifiable): array
{
return ['mail', 'database'];
}
public function toMail($notifiable): MailMessage
{
return (new MailMessage())
->subject($this->subjectLine())
->view('mail.notifications.hosting-expiring', [
'account' => $this->account,
'daysRemaining' => $this->daysRemaining,
'renewUrl' => route('hosting.accounts.show', $this->account),
]);
}
public function toArray($notifiable): array
{
$label = $this->account->primary_domain ?: $this->account->username;
return [
'title' => $this->headline(),
'message' => "Hosting for {$label} expires in {$this->daysRemaining} days.",
'icon' => 'hosting',
'url' => route('hosting.accounts.show', $this->account),
];
}
private function subjectLine(): string
{
$label = $this->account->primary_domain ?: $this->account->username;
return match (true) {
$this->daysRemaining <= 1 => "Hosting expires tomorrow: {$label}",
$this->daysRemaining <= 7 => "Urgent: hosting for {$label} expires in {$this->daysRemaining} days",
default => "Hosting renewal reminder: {$label}",
};
}
private function headline(): string
{
return match (true) {
$this->daysRemaining <= 1 => 'Hosting expires soon',
$this->daysRemaining <= 7 => 'Urgent hosting renewal',
default => 'Hosting renewal reminder',
};
}
}
@@ -1,47 +0,0 @@
<?php
namespace App\Notifications;
use App\Models\HostingAccount;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class HostingResourceWarningNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
private readonly HostingAccount $account,
private readonly string $subjectLine,
private readonly string $messageBody
) {
}
public function via($notifiable): array
{
return ['mail', 'database'];
}
public function toMail($notifiable): MailMessage
{
return (new MailMessage())
->subject($this->subjectLine)
->greeting('Hosting resource update')
->line($this->messageBody)
->line('Domain: ' . ($this->account->primary_domain ?: $this->account->username))
->line('Resource state: ' . ucfirst((string) ($this->account->resource_status ?: 'active')))
->action('Manage Hosting', route('hosting.accounts.show', $this->account));
}
public function toArray($notifiable): array
{
return [
'title' => $this->subjectLine,
'message' => $this->messageBody,
'icon' => 'hosting',
'url' => route('hosting.accounts.show', $this->account),
];
}
}
@@ -1,48 +0,0 @@
<?php
namespace App\Notifications;
use App\Models\HostingAccount;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class HostingSuspendedNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
private readonly HostingAccount $account,
private readonly string $reason,
) {
}
public function via($notifiable): array
{
return ['mail', 'database'];
}
public function toMail($notifiable): MailMessage
{
return (new MailMessage())
->subject('Hosting suspended: '.($this->account->primary_domain ?: $this->account->username))
->view('mail.notifications.hosting-suspended', [
'account' => $this->account,
'reason' => $this->reason,
'dashboardUrl' => route('hosting.accounts.show', $this->account),
]);
}
public function toArray($notifiable): array
{
$label = $this->account->primary_domain ?: $this->account->username;
return [
'title' => 'Hosting suspended',
'message' => "Hosting for {$label} has been suspended. {$this->reason}",
'icon' => 'hosting',
'url' => route('hosting.accounts.show', $this->account),
];
}
}
@@ -1,47 +0,0 @@
<?php
namespace App\Notifications;
use App\Models\Domain;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;
use Illuminate\Notifications\Messages\MailMessage;
class SslExpiringNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
private readonly Domain $domain,
private readonly int $daysUntilExpiry
) {
}
public function via($notifiable): array
{
return ['mail', 'database'];
}
public function toMail($notifiable): MailMessage
{
return (new MailMessage())
->subject("SSL Certificate Expiring Soon: {$this->domain->host}")
->view('mail.notifications.ssl-expiring', [
'domain' => $this->domain,
'daysUntilExpiry' => $this->daysUntilExpiry,
'expiresAt' => $this->domain->ssl_expires_at,
'manageUrl' => route('user.domains.show', $this->domain),
]);
}
public function toArray($notifiable): array
{
return [
'title' => 'SSL Certificate Expiring',
'message' => "Your SSL certificate for {$this->domain->host} expires in {$this->daysUntilExpiry} days.",
'icon' => 'warning',
'url' => route('user.domains.show', $this->domain),
];
}
}
@@ -1,45 +0,0 @@
<?php
namespace App\Notifications;
use App\Models\Domain;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;
use Illuminate\Notifications\Messages\MailMessage;
class SslProvisionedNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
private readonly Domain $domain
) {
}
public function via($notifiable): array
{
return ['mail', 'database'];
}
public function toMail($notifiable): MailMessage
{
return (new MailMessage())
->subject("SSL Certificate Active: {$this->domain->host}")
->view('mail.notifications.ssl-provisioned', [
'domain' => $this->domain,
'expiresAt' => $this->domain->ssl_expires_at,
'websiteUrl' => "https://{$this->domain->host}",
]);
}
public function toArray($notifiable): array
{
return [
'title' => 'SSL Certificate Active',
'message' => "Your SSL certificate for {$this->domain->host} is now active. Your site is secure!",
'icon' => 'ssl',
'url' => route('user.domains.show', $this->domain),
];
}
}
-43
View File
@@ -1,43 +0,0 @@
<?php
namespace App\Services\Billing;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class SmsService
{
public function send(string $to, string $message): void
{
$apiKey = config('services.termii.api_key', '');
$senderId = config('services.termii.sender_id', config('app.name', 'LaDill'));
if ($apiKey === '') {
return;
}
$phone = preg_replace('/\D+/', '', $to);
if (strlen($phone) < 9) {
return;
}
if (strlen($phone) === 9) {
$phone = '233'.$phone;
} elseif (str_starts_with($phone, '0')) {
$phone = '233'.substr($phone, 1);
}
try {
Http::timeout(10)->post('https://v3.api.termii.com/api/sms/send', [
'api_key' => $apiKey,
'to' => $phone,
'from' => $senderId,
'sms' => $message,
'type' => 'plain',
'channel' => 'dnd',
]);
} catch (\Throwable $e) {
Log::warning('SMS send failed', ['to' => $phone, 'error' => $e->getMessage()]);
}
}
}
@@ -13,7 +13,8 @@ class CrmProductImportService
*/
public function import(string $ownerRef): array
{
$response = CrmClient::for($ownerRef)->products(['active' => 1, 'per_page' => 500]);
// CRM catalogues both products and services; POS only stocks products.
$response = CrmClient::for($ownerRef)->products(['type' => 'product', 'active' => 1, 'per_page' => 500]);
$rows = (array) ($response['data'] ?? []);
if ($rows === []) {
@@ -24,6 +25,11 @@ class CrmProductImportService
$updated = 0;
foreach ($rows as $row) {
// Defensive: skip services in case an older CRM API ignores the type filter.
if (($row['type'] ?? 'product') !== 'product') {
continue;
}
$name = trim((string) ($row['name'] ?? ''));
if ($name === '') {
continue;
-369
View File
@@ -1,369 +0,0 @@
<?php
namespace App\Support;
use App\Services\Domain\DomainPricingService;
class DomainConfig
{
private const PRIORITY_TLDS = [
'com', 'org', 'net', 'ai', 'io', 'co', 'app', 'dev',
'store', 'shop', 'online', 'site', 'tech', 'cloud', 'pro', 'me',
'xyz', 'club', 'live', 'com.ng', 'com.gh', 'co.za', 'co.uk', 'info', 'biz',
];
private const DEFAULT_PAGE_SIZE = 20;
/**
* Get featured TLDs for initial display (lightweight).
*
* @return list<string>
*/
public static function featuredTlds(): array
{
$allTlds = self::allTlds();
$featured = array_values(array_intersect(self::PRIORITY_TLDS, $allTlds));
if ($featured !== []) {
return $featured;
}
return array_slice($allTlds, 0, self::DEFAULT_PAGE_SIZE);
}
/**
* Get all available TLDs sorted by priority.
*
* @return list<string>
*/
public static function allTlds(): array
{
$current = self::normalizeTlds(config('domain.search_tlds', []));
$legacy = self::normalizeTlds(config('mailinfra.domain_search_tlds', []));
$tlds = $legacy !== [] ? $legacy : $current;
$livePricedTlds = self::livePricedTlds();
if ($livePricedTlds === []) {
return [];
}
$livePricedLookup = array_flip($livePricedTlds);
$tlds = array_values(array_filter(
$tlds,
static fn (string $tld): bool => isset($livePricedLookup[$tld])
));
return self::sortWithPriority($tlds);
}
/**
* Get paginated TLDs (excluding featured ones).
*
* @return array{tlds: list<string>, hasMore: bool, total: int}
*/
public static function paginatedTlds(int $page = 1, int $perPage = self::DEFAULT_PAGE_SIZE): array
{
$allTlds = self::allTlds();
$featured = self::featuredTlds();
$remaining = array_values(array_filter($allTlds, fn ($tld) => !in_array($tld, $featured, true)));
$total = count($remaining);
$offset = ($page - 1) * $perPage;
$tlds = array_slice($remaining, $offset, $perPage);
return [
'tlds' => $tlds,
'hasMore' => ($offset + $perPage) < $total,
'total' => $total,
];
}
/**
* @deprecated Use featuredTlds() or allTlds() instead
* @return list<string>
*/
public static function searchTlds(): array
{
return self::featuredTlds();
}
/**
* Build the TLD list and query term for a search page request.
*
* @return array{query: string, tlds: list<string>, hasMore: bool, exact_domain: string|null}
*/
public static function searchPageConfig(string $query, int $page = 1, int $perPage = self::DEFAULT_PAGE_SIZE): array
{
$query = strtolower(trim($query));
$allTlds = self::allTlds();
if ($allTlds === []) {
return [
'query' => $query,
'tlds' => [],
'hasMore' => false,
'exact_domain' => null,
];
}
$exactDomain = self::normalizeExactDomain($query);
if ($exactDomain === null) {
if ($page === 1) {
$featured = self::featuredTlds();
$paginatedData = self::paginatedTlds(1, $perPage);
return [
'query' => $query,
'tlds' => $featured,
'hasMore' => $paginatedData['hasMore'] || count($paginatedData['tlds']) > 0,
'exact_domain' => null,
];
}
$paginatedData = self::paginatedTlds($page - 1, $perPage);
return [
'query' => $query,
'tlds' => $paginatedData['tlds'],
'hasMore' => $paginatedData['hasMore'],
'exact_domain' => null,
];
}
$exactTld = self::extractTld($exactDomain);
$keyword = explode('.', $exactDomain, 2)[0] ?? $exactDomain;
$firstPageTlds = array_values(array_unique(array_filter([
$exactTld,
...self::featuredTlds(),
])));
$remainingTlds = array_values(array_filter(
$allTlds,
static fn (string $tld): bool => ! in_array($tld, $firstPageTlds, true)
));
if ($page === 1) {
return [
'query' => $keyword,
'tlds' => $firstPageTlds,
'hasMore' => $remainingTlds !== [],
'exact_domain' => $exactDomain,
];
}
$offset = max(0, ($page - 2) * $perPage);
return [
'query' => $keyword,
'tlds' => array_slice($remainingTlds, $offset, $perPage),
'hasMore' => ($offset + $perPage) < count($remainingTlds),
'exact_domain' => $exactDomain,
];
}
/**
* Sort domain search results by TLD priority.
*
* @param array<int, array{domain: string, ...}> $results
* @return array<int, array{domain: string, ...}>
*/
public static function sortResultsByTldPriority(array $results, ?string $query = null): array
{
$priorityMap = array_flip(self::PRIORITY_TLDS);
$exactDomain = self::normalizeExactDomain((string) $query);
usort($results, static function (array $a, array $b) use ($priorityMap, $exactDomain): int {
$aDomain = strtolower((string) ($a['domain'] ?? ''));
$bDomain = strtolower((string) ($b['domain'] ?? ''));
$aExact = $exactDomain !== null && $aDomain === $exactDomain ? 0 : 1;
$bExact = $exactDomain !== null && $bDomain === $exactDomain ? 0 : 1;
if ($aExact !== $bExact) {
return $aExact <=> $bExact;
}
$aPremium = self::isPremiumResult($a) ? 0 : 1;
$bPremium = self::isPremiumResult($b) ? 0 : 1;
if ($aPremium !== $bPremium) {
return $aPremium <=> $bPremium;
}
$aAvailable = ! empty($a['available']) ? 0 : 1;
$bAvailable = ! empty($b['available']) ? 0 : 1;
if ($aAvailable !== $bAvailable) {
return $aAvailable <=> $bAvailable;
}
$aTld = self::extractTld($a['domain'] ?? '');
$bTld = self::extractTld($b['domain'] ?? '');
$aPriority = $priorityMap[$aTld] ?? PHP_INT_MAX;
$bPriority = $priorityMap[$bTld] ?? PHP_INT_MAX;
if ($aPriority !== $bPriority) {
return $aPriority <=> $bPriority;
}
return $aDomain <=> $bDomain;
});
return $results;
}
/**
* Extract TLD from a domain name.
*/
private static function extractTld(string $domain): string
{
$parts = explode('.', strtolower($domain), 2);
return $parts[1] ?? '';
}
private static function normalizeExactDomain(string $query): ?string
{
$query = strtolower(trim($query));
if ($query === '' || str_starts_with($query, '.') || str_ends_with($query, '.') || ! str_contains($query, '.')) {
return null;
}
return preg_match('/^(?=.{1,253}$)(?!-)(?:[a-z0-9-]{1,63}\.)+[a-z]{2,63}$/', $query) === 1
? $query
: null;
}
/**
* @param array<string, mixed> $result
*/
private static function isPremiumResult(array $result): bool
{
$premium = $result['premium'] ?? false;
if (is_bool($premium)) {
return $premium;
}
return in_array(strtolower(trim((string) $premium)), ['1', 'yes', 'true', 'premium'], true);
}
/**
* @param list<string> $tlds
* @return list<string>
*/
private static function sortWithPriority(array $tlds): array
{
$priorityMap = array_flip(self::PRIORITY_TLDS);
$pricingMap = self::pricingMapForTlds($tlds);
usort($tlds, static function (string $a, string $b) use ($priorityMap, $pricingMap): int {
$aPriority = $priorityMap[$a] ?? PHP_INT_MAX;
$bPriority = $priorityMap[$b] ?? PHP_INT_MAX;
if ($aPriority !== $bPriority) {
return $aPriority <=> $bPriority;
}
$aPrice = (int) ($pricingMap[$a]['register'] ?? 0);
$bPrice = (int) ($pricingMap[$b]['register'] ?? 0);
if ($aPrice !== $bPrice) {
return $bPrice <=> $aPrice;
}
return $a <=> $b;
});
return $tlds;
}
/**
* @return array<string, string>
*/
public static function resellerClubTldProductKeys(): array
{
return array_replace(
self::normalizeProductKeyMap(config('domain.resellerclub.tld_product_keys', [])),
self::normalizeProductKeyMap(config('mailinfra.resellerclub_tld_product_keys', []))
);
}
public static function resellerClubProductKeyCacheTtlSeconds(): int
{
$ttl = (int) config('domain.resellerclub.product_key_cache_ttl_seconds', 86400);
return $ttl > 0 ? $ttl : 86400;
}
/**
* @param mixed $value
* @return list<string>
*/
private static function normalizeTlds(mixed $value): array
{
if (! is_array($value)) {
return [];
}
return array_values(array_unique(array_filter(array_map(
static fn ($tld): string => ltrim(strtolower(trim((string) $tld)), '.'),
$value
))));
}
/**
* @param mixed $value
* @return array<string, string>
*/
private static function normalizeProductKeyMap(mixed $value): array
{
if (! is_array($value)) {
return [];
}
$map = [];
foreach ($value as $tld => $productKey) {
$normalizedTld = ltrim(strtolower(trim((string) $tld)), '.');
$normalizedProductKey = trim((string) $productKey);
if ($normalizedTld === '' || $normalizedProductKey === '') {
continue;
}
$map[$normalizedTld] = $normalizedProductKey;
}
return $map;
}
/**
* @return list<string>
*/
private static function livePricedTlds(): array
{
try {
return app(DomainPricingService::class)->getLivePricedTlds();
} catch (\Throwable) {
return [];
}
}
/**
* @param list<string> $tlds
* @return array<string, array{register: int, renew: int, transfer: int, currency: string}>
*/
private static function pricingMapForTlds(array $tlds): array
{
try {
return app(DomainPricingService::class)->getPricingForTlds($tlds);
} catch (\Throwable) {
return [];
}
}
}
-69
View File
@@ -1,69 +0,0 @@
<?php
namespace App\Support;
/** Storage-tier pricing for mailboxes (config/email.php quota_tiers). */
class MailboxPricing
{
/** @return array<int,array{mb:int,price_minor:int,label:string}> */
public static function tiers(): array
{
return array_map(fn ($t) => [
'mb' => (int) $t['mb'],
'price_minor' => (int) $t['price_minor'],
'label' => self::label((int) $t['mb']),
], (array) config('email.quota_tiers', []));
}
public static function isValidQuota(int $mb): bool
{
foreach (self::tiers() as $t) {
if ($t['mb'] === $mb) {
return true;
}
}
return false;
}
public static function priceMinorFor(int $mb): int
{
foreach (self::tiers() as $t) {
if ($t['mb'] === $mb) {
return $t['price_minor'];
}
}
return 0;
}
/** A tier is free when its monthly price is zero (the 1 GB plan). */
public static function isFree(int $mb): bool
{
return self::priceMinorFor($mb) === 0;
}
/** The smallest free tier's quota (the default for new mailboxes). */
public static function freeQuotaMb(): int
{
foreach (self::tiers() as $t) {
if ($t['price_minor'] === 0) {
return $t['mb'];
}
}
return self::tiers()[0]['mb'] ?? 1024;
}
public static function defaultQuotaMb(): int
{
$default = (int) config('email.default_quota_mb', 1024);
return self::isValidQuota($default) ? $default : self::freeQuotaMb();
}
public static function label(int $mb): string
{
return $mb % 1024 === 0 ? ($mb / 1024).' GB' : $mb.' MB';
}
}
-122
View File
@@ -1,122 +0,0 @@
<?php
namespace App\Support;
use App\Models\DomainOrder;
use App\Models\RcServiceOrder;
use Illuminate\Support\Collection;
use RuntimeException;
/**
* ResellerClub is legacy-only for new sales. Renewals and management remain available
* for customers with existing ResellerClub services.
*/
final class ResellerClubLegacy
{
public static function integrationEnabled(): bool
{
return (bool) config('hosting.legacy.rc_enabled', true);
}
public static function newOrdersEnabled(): bool
{
return self::integrationEnabled()
&& (bool) config('hosting.legacy.rc_new_orders_enabled', false);
}
public static function renewalsEnabled(): bool
{
return self::integrationEnabled()
&& (bool) config('hosting.legacy.rc_renewals_enabled', true);
}
public static function isRenewalRcServiceOrder(RcServiceOrder $order): bool
{
if ($order->order_type === RcServiceOrder::TYPE_RENEWAL) {
return true;
}
return (bool) data_get($order->meta, 'is_renewal', false);
}
public static function isRenewalDomainOrder(DomainOrder $order): bool
{
return $order->order_type === DomainOrder::TYPE_RENEWAL;
}
public static function canAutomateFulfillment(RcServiceOrder $order): bool
{
if (! self::integrationEnabled()) {
return false;
}
if (self::newOrdersEnabled()) {
return true;
}
return self::renewalsEnabled() && self::isRenewalRcServiceOrder($order);
}
/**
* @param Collection<int, RcServiceOrder>|iterable<int, RcServiceOrder> $items
*/
public static function assertCartCheckoutAllowed(iterable $items): void
{
$items = $items instanceof Collection ? $items : collect($items);
if ($items->isEmpty()) {
throw new RuntimeException('Your cart is empty.');
}
if (self::newOrdersEnabled()) {
return;
}
if (! self::renewalsEnabled()) {
throw new RuntimeException(self::renewalsDisabledMessage());
}
if ($items->contains(fn (RcServiceOrder $item) => ! self::isRenewalRcServiceOrder($item))) {
throw new RuntimeException(
'ResellerClub checkout is limited to renewals for existing services. '
.'New product purchases use our current domain and hosting products instead.'
);
}
}
public static function assertNewSaleAllowed(): void
{
if (self::newOrdersEnabled()) {
return;
}
throw new RuntimeException(
'ResellerClub is no longer available for new product purchases. '
.'Use our domain and hosting products instead, or renew an existing ResellerClub service from your dashboard.'
);
}
public static function assertRenewalsAllowed(): void
{
if (! self::renewalsEnabled()) {
throw new RuntimeException(self::renewalsDisabledMessage());
}
}
/** @deprecated Use assertNewSaleAllowed() */
public static function assertNewOrdersAllowed(): void
{
self::assertNewSaleAllowed();
}
public static function renewalsDisabledMessage(): string
{
return 'ResellerClub renewals are not available at this time. Please contact support if you need assistance.';
}
public static function fulfillmentDisabledMessage(): string
{
return 'Automated ResellerClub fulfillment for new purchases is no longer available. '
.'Open a support ticket if you need help with a legacy order.';
}
}
-10
View File
@@ -1,10 +0,0 @@
<?php
return [
// Afia — in-app AI assistant scoped to Ladill Mini.
'product' => env('AFIA_PRODUCT', 'mini'),
'enabled' => (bool) env('AFIA_ENABLED', true),
'provider' => env('AFIA_PROVIDER', 'openai'), // openai | anthropic
'model' => env('AFIA_MODEL', 'gpt-4o-mini'),
'api_key' => env('AFIA_API_KEY'),
];
-25
View File
@@ -1,25 +0,0 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Ladill Mini Android App Links (Digital Asset Links)
|--------------------------------------------------------------------------
|
| Served at /.well-known/assetlinks.json for Android App Link verification.
| Use the App signing certificate SHA-256 from Play Console Setup
| App signing (not the upload key unless you opted out of Play signing).
|
| Multiple fingerprints may be comma-separated in ANDROID_APP_SHA256_FINGERPRINTS.
|
*/
'package_name' => env('ANDROID_APP_PACKAGE', 'com.ladill.mini'),
'sha256_cert_fingerprints' => array_values(array_filter(array_map(
static fn (string $fingerprint): string => strtoupper(trim($fingerprint)),
explode(',', (string) env('ANDROID_APP_SHA256_FINGERPRINTS', '')),
))),
];
-6
View File
@@ -1,6 +0,0 @@
<?php
return [
'api_url' => env('DOMAIN_API_URL', 'https://ladill.com/api/domains'),
'api_key' => env('DOMAIN_API_KEY_HOSTING'),
];
-21
View File
@@ -1,21 +0,0 @@
<?php
return [
/*
| Email product plans. Mailboxes are priced purely by the storage tier chosen
| at creation (and changeable later via Upgrade). The 1 GB tier is FREE,
| forever, for every mailbox; larger tiers are billed monthly from the one
| Ladill wallet (§4-A). Money is in minor units (pesewas).
*/
'currency' => env('EMAIL_CURRENCY', 'GHS'),
'default_quota_mb' => (int) env('EMAIL_DEFAULT_QUOTA_MB', 1024), // new mailboxes start on the free 1 GB tier
// Storage tiers: quota (MB) → monthly price (minor units). 1 GB is free.
'quota_tiers' => [
['mb' => 1024, 'price_minor' => 0], // 1 GB — Free forever
['mb' => 5120, 'price_minor' => 1000], // 5 GB — GHS 10
['mb' => 10240, 'price_minor' => 2000], // 10 GB — GHS 20
['mb' => 25600, 'price_minor' => 3000], // 25 GB — GHS 30
['mb' => 51200, 'price_minor' => 6000], // 50 GB — GHS 60
],
];
-7
View File
@@ -1,7 +0,0 @@
<?php
return [
// Consumer config for the shared Email-Domain verification API (§4-D).
'api_url' => env('EMAILDOMAIN_API_URL', 'https://ladill.com/api/email-domains'),
'api_key' => env('EMAILDOMAIN_API_KEY_EMAIL'),
];
-703
View File
@@ -1,703 +0,0 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Contabo API Configuration
|--------------------------------------------------------------------------
|
| Credentials for the Contabo Compute Management API.
| Used for provisioning VPS and VDS instances.
|
*/
'contabo' => [
'client_id' => env('CONTABO_CLIENT_ID'),
'client_secret' => env('CONTABO_CLIENT_SECRET'),
'api_user' => env('CONTABO_API_USER'),
'api_password' => env('CONTABO_API_PASSWORD'),
'product_catalog_endpoint' => env('CONTABO_PRODUCT_CATALOG_ENDPOINT'),
],
/*
|--------------------------------------------------------------------------
| Pricing Configuration
|--------------------------------------------------------------------------
|
| Dynamic pricing settings for VPS/Dedicated servers.
| Base prices from Contabo are in USD, converted to GHS using exchange rate.
|
*/
'pricing' => [
'base_currency' => 'USD',
'display_currency' => 'GHS',
// Fallback rate used when live API fails
'fallback_usd_to_ghs_rate' => env('FALLBACK_USD_TO_GHS_RATE', 15.50),
// Profit margins (percentage on top of converted Contabo USD price)
'margins' => [
'vps' => env('VPS_PROFIT_MARGIN', 45),
'dedicated' => env('DEDICATED_PROFIT_MARGIN', 30),
],
'contabo_price_cache_ttl' => env('CONTABO_PRICE_CACHE_TTL', 3600),
'term_discounts' => [
'quarterly' => 5,
'semiannual' => 10,
'yearly' => 20,
],
'setup_fee_rules' => [
// VPS 10 only — monthly and semiannual cycles carry a setup fee equal to 1× the monthly price
[
'product_ids' => ['V91'],
'billing_cycles' => ['monthly', 'semiannual'],
'monthly_price_multiplier' => 1,
'label' => 'One-time setup fee',
],
// Dedicated servers — fixed EUR fee that decreases with longer commitment; waived on yearly
[
'product_ids' => ['amd-ryzen-12-cores', 'amd-genoa-24-cores'],
'billing_cycles' => ['monthly', 'quarterly', 'semiannual'],
'fixed_eur_by_cycle' => [
'monthly' => 39.99,
'quarterly' => 29.99,
'semiannual' => 19.99,
],
'label' => 'One-time setup fee',
],
],
// Fallback Contabo base prices in USD. Live API/feed prices are preferred.
// Keep these values as a fail-safe for API downtime.
'contabo_base_prices' => [
'V91' => ['monthly' => 4.99, 'name' => 'Cloud VPS 10 NVMe', 'cpu' => 3, 'ram_gb' => 8, 'disk_gb' => 75],
'V94' => ['monthly' => 7.00, 'name' => 'Cloud VPS 20 NVMe', 'cpu' => 6, 'ram_gb' => 12, 'disk_gb' => 100],
'V97' => ['monthly' => 14.00, 'name' => 'Cloud VPS 30 NVMe', 'cpu' => 8, 'ram_gb' => 24, 'disk_gb' => 200],
'V100' => ['monthly' => 25.00, 'name' => 'Cloud VPS 40 NVMe', 'cpu' => 12, 'ram_gb' => 48, 'disk_gb' => 250],
'amd-ryzen-12-cores' => ['monthly' => 104.64, 'name' => 'AMD Ryzen 12 Cores', 'cpu' => 12, 'ram_gb' => 64, 'disk_gb' => 1000],
'amd-genoa-24-cores' => ['monthly' => 184.21, 'name' => 'AMD Genoa 24 Cores', 'cpu' => 24, 'ram_gb' => 128, 'disk_gb' => 2000],
],
],
/*
|--------------------------------------------------------------------------
| Server Order Options
|--------------------------------------------------------------------------
|
| Surcharges shown here are Contabo-side monthly USD add-on costs before
| the Ladill exchange-rate conversion and product margin are applied.
| Update these values whenever Contabo changes its commercial pricing.
|
*/
'server_order' => [
'regions' => [
'EU' => ['label' => 'Europe (Germany)', 'monthly_usd' => 0.00, 'automated' => true],
'US-central' => ['label' => 'US Central', 'monthly_usd' => 0.00, 'automated' => true],
'US-east' => ['label' => 'US East', 'monthly_usd' => 0.00, 'automated' => true],
'US-west' => ['label' => 'US West', 'monthly_usd' => 0.00, 'automated' => true],
'UK' => ['label' => 'United Kingdom', 'monthly_usd' => 0.00, 'automated' => true],
'SIN' => ['label' => 'Singapore', 'monthly_usd' => 0.00, 'automated' => true],
'AUS' => ['label' => 'Australia', 'monthly_usd' => 0.00, 'automated' => true],
'JPN' => ['label' => 'Japan', 'monthly_usd' => 0.00, 'automated' => true],
],
'image_pricing_rules' => [
[
'key' => 'windows',
'label' => 'Windows Server',
'monthly_usd' => 9.30,
'match' => ['windows'],
'os_family' => 'windows',
'default_user' => 'administrator',
'requires_custom_image_addon' => false,
'automated' => true,
],
[
'key' => 'ubuntu',
'label' => 'Ubuntu',
'monthly_usd' => 0.00,
'match' => ['ubuntu'],
'os_family' => 'linux',
'default_user' => 'root',
'requires_custom_image_addon' => false,
'automated' => true,
],
[
'key' => 'rhel',
'label' => 'RHEL Variants',
'monthly_usd' => 0.00,
'match' => ['alma', 'rocky', 'rhel', 'centos'],
'os_family' => 'linux',
'default_user' => 'root',
'requires_custom_image_addon' => false,
'automated' => true,
],
[
'key' => 'custom',
'label' => 'Custom Images',
'monthly_usd' => 0.00,
'custom_image' => true,
'os_family' => 'linux',
'default_user' => 'root',
'requires_custom_image_addon' => true,
'automated' => true,
],
[
'key' => 'linux',
'label' => 'Linux',
'monthly_usd' => 0.00,
'match' => ['debian', 'fedora', 'linux', 'bsd'],
'os_family' => 'linux',
'default_user' => 'root',
'requires_custom_image_addon' => false,
'automated' => true,
],
],
'fallback_images' => [
[
'value' => 'afecbb85-e2fc-46f0-9684-b46b1faf00bb',
'label' => 'Ubuntu 22.04',
'description' => 'Fallback Ubuntu image',
'os_family' => 'linux',
'monthly_usd' => 0.00,
'default_user' => 'root',
'requires_custom_image_addon' => false,
'automated' => true,
],
],
'managed_stack_supported_images' => [
[
'value' => 'afecbb85-e2fc-46f0-9684-b46b1faf00bb',
'label' => 'Ubuntu 22.04 LTS',
'match' => ['ubuntu 22.04'],
'os_family' => 'linux',
],
[
'label' => 'Ubuntu 24.04 LTS',
'match' => ['ubuntu 24.04'],
'os_family' => 'linux',
],
[
'label' => 'Debian 12',
'match' => ['debian 12', 'bookworm'],
'os_family' => 'linux',
],
[
'label' => 'AlmaLinux',
'match' => ['almalinux', 'alma linux', 'alma'],
'os_family' => 'linux',
],
[
'label' => 'Rocky Linux',
'match' => ['rocky linux', 'rocky'],
'os_family' => 'linux',
],
[
'label' => 'Fedora',
'match' => ['fedora'],
'os_family' => 'linux',
],
[
'label' => 'openSUSE Leap',
'match' => ['opensuse leap', 'open suse leap', 'opensuse', 'leap'],
'os_family' => 'linux',
],
[
'label' => 'CentOS',
'match' => ['centos'],
'os_family' => 'linux',
],
],
'licenses' => [
'none' => [
'label' => 'Remote Login Only',
'description' => 'No hosting panel or commercial control-panel license.',
'monthly_usd' => 0.00,
'license' => null,
'panel' => null,
'automated' => true,
],
'ladill_panel' => [
'label' => 'Ladill Server Manager',
'description' => 'Manage power, status, and server details from the Ladill server manager.',
'monthly_usd' => 0.00,
'license' => null,
'panel' => 'ladill',
'compatible_os_families' => ['linux'],
'requires_image' => true,
'requires_managed_stack_image' => true,
'automated' => true,
],
'plesk_host' => [
'label' => 'Plesk + Linux',
'description' => 'Plesk host edition on Linux.',
'monthly_usd' => 15.00,
'license' => 'PleskHost',
'compatible_os_families' => ['linux'],
'requires_image' => true,
'automated' => true,
],
'plesk_windows' => [
'label' => 'Plesk + Windows',
'description' => 'Plesk on Windows Server.',
'monthly_usd' => 22.70,
'license' => 'PleskHost',
'compatible_os_families' => ['windows'],
'requires_image' => true,
'automated' => true,
],
'cpanel_5' => [
'label' => 'cPanel 5',
'description' => 'cPanel license for up to 5 accounts.',
'monthly_usd' => 35.99,
'license' => 'cPanel5',
'compatible_os_families' => ['linux'],
'requires_image' => true,
'automated' => true,
],
'cpanel_30' => [
'label' => 'cPanel 30',
'description' => 'cPanel license for up to 30 accounts.',
'monthly_usd' => 53.99,
'license' => 'cPanel30',
'compatible_os_families' => ['linux'],
'requires_image' => true,
'automated' => true,
],
'cpanel_50' => [
'label' => 'cPanel 50',
'description' => 'cPanel license for up to 50 accounts.',
'monthly_usd' => 53.99 + (0.49 * 20),
'license' => 'cPanel50',
'compatible_os_families' => ['linux'],
'requires_image' => true,
'automated' => true,
],
'cpanel_100' => [
'label' => 'cPanel 100',
'description' => 'cPanel license for up to 100 accounts.',
'monthly_usd' => 69.99,
'license' => 'cPanel100',
'compatible_os_families' => ['linux'],
'requires_image' => true,
'automated' => true,
],
'cpanel_150' => [
'label' => 'cPanel 150',
'description' => 'cPanel license for up to 150 accounts.',
'monthly_usd' => 69.99 + (0.49 * 50),
'license' => 'cPanel150',
'compatible_os_families' => ['linux'],
'requires_image' => true,
'automated' => true,
],
'cpanel_200' => [
'label' => 'cPanel 200',
'description' => 'cPanel license for up to 200 accounts.',
'monthly_usd' => 69.99 + (0.49 * 100),
'license' => 'cPanel200',
'compatible_os_families' => ['linux'],
'requires_image' => true,
'automated' => true,
],
'cpanel_250' => [
'label' => 'cPanel 250',
'description' => 'cPanel license for up to 250 accounts.',
'monthly_usd' => 69.99 + (0.49 * 150),
'license' => 'cPanel250',
'compatible_os_families' => ['linux'],
'requires_image' => true,
'automated' => true,
],
'cpanel_300' => [
'label' => 'cPanel 300',
'description' => 'cPanel license for up to 300 accounts.',
'monthly_usd' => 69.99 + (0.49 * 200),
'license' => 'cPanel300',
'compatible_os_families' => ['linux'],
'requires_image' => true,
'automated' => true,
],
'cpanel_350' => [
'label' => 'cPanel 350',
'description' => 'cPanel license for up to 350 accounts.',
'monthly_usd' => 69.99 + (0.49 * 250),
'license' => 'cPanel350',
'compatible_os_families' => ['linux'],
'requires_image' => true,
'automated' => true,
],
'cpanel_400' => [
'label' => 'cPanel 400',
'description' => 'cPanel license for up to 400 accounts.',
'monthly_usd' => 69.99 + (0.49 * 300),
'license' => 'cPanel400',
'compatible_os_families' => ['linux'],
'requires_image' => true,
'automated' => true,
],
'cpanel_450' => [
'label' => 'cPanel 450',
'description' => 'cPanel license for up to 450 accounts.',
'monthly_usd' => 69.99 + (0.49 * 350),
'license' => 'cPanel450',
'compatible_os_families' => ['linux'],
'requires_image' => true,
'automated' => true,
],
'cpanel_500' => [
'label' => 'cPanel 500',
'description' => 'cPanel license for up to 500 accounts.',
'monthly_usd' => 69.99 + (0.49 * 400),
'license' => 'cPanel500',
'compatible_os_families' => ['linux'],
'requires_image' => true,
'automated' => true,
],
'cpanel_550' => [
'label' => 'cPanel 550',
'description' => 'cPanel license for up to 550 accounts.',
'monthly_usd' => 69.99 + (0.49 * 450),
'license' => 'cPanel550',
'compatible_os_families' => ['linux'],
'requires_image' => true,
'automated' => true,
],
'cpanel_600' => [
'label' => 'cPanel 600',
'description' => 'cPanel license for up to 600 accounts.',
'monthly_usd' => 69.99 + (0.49 * 500),
'license' => 'cPanel600',
'compatible_os_families' => ['linux'],
'requires_image' => true,
'automated' => true,
],
'cpanel_650' => [
'label' => 'cPanel 650',
'description' => 'cPanel license for up to 650 accounts.',
'monthly_usd' => 69.99 + (0.49 * 550),
'license' => 'cPanel650',
'compatible_os_families' => ['linux'],
'requires_image' => true,
'automated' => true,
],
'cpanel_700' => [
'label' => 'cPanel 700',
'description' => 'cPanel license for up to 700 accounts.',
'monthly_usd' => 69.99 + (0.49 * 600),
'license' => 'cPanel700',
'compatible_os_families' => ['linux'],
'requires_image' => true,
'automated' => true,
],
'cpanel_750' => [
'label' => 'cPanel 750',
'description' => 'cPanel license for up to 750 accounts.',
'monthly_usd' => 69.99 + (0.49 * 650),
'license' => 'cPanel750',
'compatible_os_families' => ['linux'],
'requires_image' => true,
'automated' => true,
],
'cpanel_800' => [
'label' => 'cPanel 800',
'description' => 'cPanel license for up to 800 accounts.',
'monthly_usd' => 69.99 + (0.49 * 700),
'license' => 'cPanel800',
'compatible_os_families' => ['linux'],
'requires_image' => true,
'automated' => true,
],
'cpanel_850' => [
'label' => 'cPanel 850',
'description' => 'cPanel license for up to 850 accounts.',
'monthly_usd' => 69.99 + (0.49 * 750),
'license' => 'cPanel850',
'compatible_os_families' => ['linux'],
'requires_image' => true,
'automated' => true,
],
'cpanel_900' => [
'label' => 'cPanel 900',
'description' => 'cPanel license for up to 900 accounts.',
'monthly_usd' => 69.99 + (0.49 * 800),
'license' => 'cPanel900',
'compatible_os_families' => ['linux'],
'requires_image' => true,
'automated' => true,
],
'cpanel_950' => [
'label' => 'cPanel 950',
'description' => 'cPanel license for up to 950 accounts.',
'monthly_usd' => 69.99 + (0.49 * 850),
'license' => 'cPanel950',
'compatible_os_families' => ['linux'],
'requires_image' => true,
'automated' => true,
],
'cpanel_1000' => [
'label' => 'cPanel 1000',
'description' => 'cPanel license for up to 1000 accounts.',
'monthly_usd' => 69.99 + (0.49 * 900),
'license' => 'cPanel1000',
'compatible_os_families' => ['linux'],
'requires_image' => true,
'automated' => true,
],
],
'applications' => [
'none' => [
'label' => 'No Preinstalled App',
'description' => 'Provision the server without an extra preinstalled application.',
'monthly_usd' => 0.00,
'automated' => true,
],
'webmin' => [
'label' => 'Webmin',
'description' => 'Free Webmin server panel installed automatically on Linux.',
'monthly_usd' => 0.00,
'cloud_init_preset' => 'webmin',
'compatible_os_families' => ['linux'],
'requires_image' => true,
'automated' => true,
],
'webmin_lamp' => [
'label' => 'Webmin + LAMP',
'description' => 'Free Webmin plus Apache, MariaDB, and PHP on Linux.',
'monthly_usd' => 0.00,
'cloud_init_preset' => 'webmin_lamp',
'compatible_os_families' => ['linux'],
'requires_image' => true,
'automated' => true,
],
'ipfs_node' => [
'label' => 'IPFS Node',
'description' => 'Contabo application profile for IPFS nodes.',
'monthly_usd' => 0.00,
'application_id' => env('CONTABO_APP_ID_IPFS_NODE'),
'compatible_os_families' => ['linux'],
'requires_image' => true,
'automated' => true,
],
'flux_node' => [
'label' => 'Flux Node',
'description' => 'Contabo application profile for Flux nodes.',
'monthly_usd' => 0.00,
'application_id' => env('CONTABO_APP_ID_FLUX_NODE'),
'compatible_os_families' => ['linux'],
'requires_image' => true,
'automated' => true,
],
'horizon_node' => [
'label' => 'Horizen Node',
'description' => 'Contabo application profile for Horizen nodes.',
'monthly_usd' => 0.00,
'application_id' => env('CONTABO_APP_ID_HORIZON_NODE'),
'compatible_os_families' => ['linux'],
'requires_image' => true,
'automated' => true,
],
'ethereum_node' => [
'label' => 'Ethereum Node',
'description' => 'Contabo application profile for Ethereum 2.0 nodes.',
'monthly_usd' => 0.00,
'application_id' => null,
'compatible_os_families' => ['linux'],
'requires_image' => true,
'automated' => true,
],
'bitcoin_node' => [
'label' => 'Bitcoin Full Node',
'description' => 'Contabo application profile for Bitcoin full nodes.',
'monthly_usd' => 0.00,
'application_id' => null,
'compatible_os_families' => ['linux'],
'requires_image' => true,
'automated' => true,
],
],
'additional_ip' => [
'none' => [
'label' => '1 IP Address',
'description' => 'Default primary IP only.',
'monthly_usd' => 0.00,
'add_on' => null,
'automated' => true,
],
'one_extra' => [
'label' => '1 Additional IP',
'description' => 'Adds one extra IPv4 address.',
'monthly_usd' => 4.50,
'add_on' => 'additionalIps',
'automated' => true,
],
],
'private_networking' => [
'disabled' => [
'label' => 'No Private Networking',
'description' => 'Do not enable the private networking add-on.',
'monthly_usd' => 0.00,
'add_on' => null,
'automated' => true,
],
'enabled' => [
'label' => 'Private Networking Enabled',
'description' => 'Purchases the private networking add-on.',
'monthly_usd' => 2.99,
'add_on' => 'privateNetworking',
'automated' => true,
],
],
'storage_types' => [
'included' => [
'label' => 'Included Storage',
'description' => 'Use the plan default storage.',
'monthly_usd' => 0.00,
'add_on' => null,
'automated' => true,
],
'ssd_300' => [
'label' => '300 GB SSD',
'description' => 'Higher SSD storage tier.',
'monthly_usd' => 1.95,
'add_on' => 'extraStorage',
'automated' => false,
],
'nvme_150' => [
'label' => '150 GB NVMe',
'description' => 'Higher NVMe storage tier.',
'monthly_usd' => 2.30,
'add_on' => 'extraStorage',
'automated' => false,
],
],
'object_storage' => [
'none' => [
'label' => 'No Object Storage',
'description' => 'Do not order object storage with this server.',
'monthly_usd' => 0.00,
'automated' => false,
],
'eu_250' => [
'label' => '250 GB Object Storage (EU)',
'description' => 'S3-compatible object storage in the European Union.',
'monthly_usd' => 2.99,
'automated' => false,
],
'eu_500' => [
'label' => '500 GB Object Storage (EU)',
'description' => 'S3-compatible object storage in the European Union.',
'monthly_usd' => 5.98,
'automated' => false,
],
'eu_750' => [
'label' => '750 GB Object Storage (EU)',
'description' => 'S3-compatible object storage in the European Union.',
'monthly_usd' => 8.97,
'automated' => false,
],
'eu_1024' => [
'label' => '1 TB Object Storage (EU)',
'description' => 'S3-compatible object storage in the European Union.',
'monthly_usd' => 11.96,
'automated' => false,
],
],
'linux_default_users' => [
'root' => ['label' => 'root'],
'admin' => ['label' => 'admin'],
],
'windows_default_users' => [
'admin' => ['label' => 'admin'],
'administrator' => ['label' => 'administrator'],
],
],
'server_agent' => [
'release_version' => env('SERVER_AGENT_RELEASE_VERSION', '0.2.0'),
'signed_release_ttl_minutes' => (int) env('SERVER_AGENT_SIGNED_RELEASE_TTL_MINUTES', 10080),
'heartbeat_interval_seconds' => 15,
],
/*
|--------------------------------------------------------------------------
| Shared Hosting Configuration
|--------------------------------------------------------------------------
|
| Settings for the shared hosting nodes.
|
*/
'shared' => [
'default_php_version' => '8.2',
'available_php_versions' => ['8.0', '8.1', '8.2', '8.3'],
'default_document_root' => 'public_html',
'max_upload_size_mb' => 64,
'max_execution_time' => 300,
'memory_limit_mb' => 256,
'phpmyadmin_url' => env('HOSTING_PHPMYADMIN_URL', ''),
'phpmyadmin_sso_secret' => env('HOSTING_PHPMYADMIN_SSO_SECRET', ''),
],
/*
|--------------------------------------------------------------------------
| VPS Configuration
|--------------------------------------------------------------------------
|
| Default settings for VPS instances.
|
*/
'vps' => [
'default_region' => 'EU',
'default_image' => 'afecbb85-e2fc-46f0-9684-b46b1faf00bb', // Ubuntu 22.04
'available_regions' => ['EU', 'US-central', 'US-east', 'US-west', 'SIN', 'UK', 'AUS', 'JPN'],
],
/*
|--------------------------------------------------------------------------
| App Installer Configuration
|--------------------------------------------------------------------------
|
| Settings for the one-click app installer.
|
*/
'apps' => [
'enabled' => ['wordpress', 'joomla', 'drupal', 'opencart'],
'magento_enabled' => false, // Requires higher resource plans
'wordpress' => [
'default_version' => '6.4',
'wp_cli_path' => '/usr/local/bin/wp',
],
],
/*
|--------------------------------------------------------------------------
| Provisioning Settings
|--------------------------------------------------------------------------
|
| General provisioning configuration.
|
*/
'provisioning' => [
'max_retries' => 3,
'retry_delay_minutes' => 5,
'timeout_minutes' => 30,
],
/*
|--------------------------------------------------------------------------
| Legacy ResellerClub Integration
|--------------------------------------------------------------------------
|
| Keep RC integration active for existing customers.
|
*/
'legacy' => [
// Keep RC data/API available for customers with existing ResellerClub services.
'rc_enabled' => (bool) env('LADILL_RC_LEGACY_ENABLED', true),
// New sales and automated fulfillment use Ladill (Dynadot domains, native hosting).
'rc_new_orders_enabled' => (bool) env('LADILL_RC_NEW_ORDERS_ENABLED', false),
// Renew existing ResellerClub services (hosting, domains, VPS, etc.).
'rc_renewals_enabled' => (bool) env('LADILL_RC_RENEWALS_ENABLED', true),
],
];
-123
View File
@@ -1,123 +0,0 @@
<?php
$platformHost = parse_url((string) env('APP_URL', 'https://ladill.com'), PHP_URL_HOST) ?: 'ladill.com';
return [
'account_url' => env(
'LADILL_ACCOUNT_URL',
'https://'.env('ACCOUNT_DOMAIN', 'account.'.$platformHost)
),
'brands' => [
'ladill' => [
'name' => 'Ladill',
'logo' => 'ladill-logo-white.png',
'logo_class' => 'email-logo',
'app_url' => env('APP_URL', 'https://ladill.com'),
'footer_account' => 'an account with Ladill',
'dashboard_path' => '/dashboard',
'support_path' => '/support',
'home_label' => 'ladill.com',
],
'hosting' => [
'name' => 'Ladill Hosting',
'logo' => 'ladillhosting-logo-email.png',
'logo_class' => 'email-logo',
'app_url' => env('LADILL_HOSTING_APP_URL', 'https://hosting.ladill.com'),
'footer_account' => 'a Ladill Hosting account',
'dashboard_path' => '/',
'support_path' => '/support',
'home_label' => 'hosting.ladill.com',
],
'domains' => [
'name' => 'Ladill Domains',
'logo' => 'ladilldomains-logo-email.png',
'logo_class' => 'email-logo',
'app_url' => env('LADILL_DOMAINS_APP_URL', 'https://domains.ladill.com'),
'footer_account' => 'a Ladill Domains account',
'dashboard_path' => '/',
'support_path' => '/support',
'home_label' => 'domains.ladill.com',
],
'bird' => [
'name' => 'Ladill Bird',
'logo' => 'ladillbird-logo-email.png',
'logo_class' => 'email-logo',
'app_url' => env('LADILL_BIRD_APP_URL', 'https://bird.ladill.com'),
'footer_account' => 'a Ladill Bird account',
'dashboard_path' => '/',
'support_path' => '/support',
'home_label' => 'bird.ladill.com',
],
'mail' => [
'name' => 'Ladill Mail',
'logo' => 'ladillmail-logo-email.png',
'logo_class' => 'email-logo',
'app_url' => env('LADILL_WEBMAIL_URL', 'https://mail.ladill.com'),
'footer_account' => 'a Ladill Mail account',
'dashboard_path' => '/',
'support_path' => '/support',
'home_label' => 'mail.ladill.com',
],
'email' => [
'name' => 'Ladill Email',
'logo' => 'ladillemail-logo-email.png',
'logo_class' => 'email-logo',
'app_url' => env('LADILL_EMAIL_APP_URL', 'https://email.ladill.com'),
'footer_account' => 'a Ladill Email account',
'dashboard_path' => '/',
'support_path' => '/support',
'home_label' => 'email.ladill.com',
],
'qrplus' => [
'name' => 'Ladill QR Plus',
'logo' => 'ladillqrplus-logo-email.png',
'logo_class' => 'email-logo',
'app_url' => env('LADILL_QR_APP_URL', 'https://qrplus.ladill.com'),
'footer_account' => 'a Ladill QR Plus account',
'dashboard_path' => '/',
'support_path' => '/support',
'home_label' => 'qrplus.ladill.com',
],
'events' => [
'name' => 'Ladill Events',
'logo' => 'ladillevents-logo-email.png',
'logo_class' => 'email-logo',
'app_url' => env('LADILL_EVENTS_APP_URL', 'https://events.ladill.com'),
'footer_account' => 'a Ladill Events account',
'dashboard_path' => '/',
'support_path' => '/support',
'home_label' => 'events.ladill.com',
],
'transfer' => [
'name' => 'Ladill Transfer',
'logo' => 'ladilltransfer-logo-email.png',
'logo_class' => 'email-logo email-logo-transfer',
'app_url' => env('LADILL_TRANSFER_APP_URL', 'https://transfer.ladill.com'),
'footer_account' => 'a Ladill Transfer account',
'dashboard_path' => '/',
'support_path' => '/support',
'home_label' => 'transfer.ladill.com',
],
'mini' => [
'name' => 'Ladill Mini',
'logo' => 'ladillmini-logo-email.png',
'logo_class' => 'email-logo',
'app_url' => env('LADILL_MINI_APP_URL', 'https://mini.ladill.com'),
'footer_account' => 'a Ladill Mini account',
'dashboard_path' => '/',
'support_path' => '/support',
'home_label' => 'mini.ladill.com',
],
'servers' => [
'name' => 'Ladill Servers',
'logo' => 'ladillservers-logo-email.png',
'logo_class' => 'email-logo',
'app_url' => env('LADILL_SERVERS_APP_URL', 'https://servers.ladill.com'),
'footer_account' => 'a Ladill Servers account',
'dashboard_path' => '/',
'support_path' => '/support',
'home_label' => 'servers.ladill.com',
],
],
];
-9
View File
@@ -1,9 +0,0 @@
<?php
return [
// Consumer config for the platform Mailbox provisioning API (§4). Mail infra
// (Postfix/Dovecot via mail DB + pools) lives on the platform; this app
// provisions through the API. The user is identified by public_id.
'api_url' => env('MAILBOX_API_URL', 'https://ladill.com/api/mailboxes'),
'api_key' => env('MAILBOX_API_KEY_EMAIL'),
];
-17
View File
@@ -1,17 +0,0 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| FCM push for Ladill Mini Android
|--------------------------------------------------------------------------
|
| Sellers receive instant payment alerts when a device token is registered
| and the account was active in the app within this window.
|
*/
'fcm_active_user_within_days' => (int) env('NOTIFICATIONS_FCM_ACTIVE_USER_DAYS', 60),
];
-9
View File
@@ -1,9 +0,0 @@
<?php
return [
'price_per_qr_ghs' => (float) env('QR_PRICE_PER_QR_GHS', 5.0),
'min_topup_ghs' => (float) env('QR_MIN_TOPUP_GHS', 5.0),
'max_pdf_bytes' => (int) env('QR_MAX_PDF_BYTES', 104857600), // 100 MB
'short_code_length' => (int) env('QR_SHORT_CODE_LENGTH', 8),
'scan_unique_window_hours' => (int) env('QR_SCAN_UNIQUE_WINDOW_HOURS', 24),
];
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 264 KiB

-31
View File
@@ -1,31 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 360 360">
<defs>
<style>
.cls-1 {
fill: #8f8fe7;
}
.cls-1, .cls-2, .cls-3, .cls-4 {
stroke-width: 0px;
}
.cls-2 {
fill: #f48725;
}
.cls-3 {
fill: #868f77;
}
.cls-4 {
fill: #1797c8;
}
</style>
</defs>
<circle class="cls-4" cx="99.4" cy="109" r="98"/>
<rect class="cls-2" x="1.4" y="11" width="83.2" height="338"/>
<path class="cls-3" d="M84.6,205.7V12.2C37.5,19.4,1.4,59.9,1.4,109s36.1,89.6,83.2,96.7Z"/>
<path class="cls-1" d="M123.4,245c-.1,2-.3,4-.3,6,0,54.1,43.9,98,98,98s98-43.9,98-98-.2-4-.3-6H123.4Z"/>
<path class="cls-1" d="M162.9,238.5c-.1-2-.3-4-.3-6,0-54.1,43.9-98,98-98s98,43.9,98,98-.2,4-.3,6h-195.3Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 888 B

+1 -1
View File
@@ -1,6 +1,6 @@
@php
$signedOut = (array) config('signed_out');
$logo = (string) ($signedOut['logo'] ?? 'images/logo/ladillgive-logo.svg');
$logo = (string) ($signedOut['logo'] ?? 'images/logo/ladillpos-logo.svg');
$logoPath = public_path($logo);
@endphp
<!DOCTYPE html>
@@ -1,17 +0,0 @@
@props([
'href' => null,
'type' => 'button',
])
@php
$tag = $href ? 'a' : 'button';
@endphp
<{{ $tag }}
@if ($href) href="{{ $href }}" @endif
@if ($tag === 'button') type="{{ $type }}" @endif
{{ $attributes->class(['btn-primary']) }}
>
<svg class="h-4 w-4 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg>
{{ $slot }}
</{{ $tag }}>
@@ -1,16 +0,0 @@
@props([
'href' => null,
'type' => 'button',
])
@php
$tag = $href ? 'a' : 'button';
@endphp
<{{ $tag }}
@if ($href) href="{{ $href }}" @endif
@if ($tag === 'button') type="{{ $type }}" @endif
{{ $attributes->class(['btn-primary']) }}
>
{{ $slot }}
</{{ $tag }}>
@@ -1,69 +0,0 @@
@props([
'name',
'title',
'message' => null,
'action',
'method' => 'POST',
'confirmLabel' => 'Confirm',
'cancelLabel' => 'Cancel',
'variant' => 'danger',
])
@php
$confirmBtnClass = $variant === 'danger'
? 'bg-red-600 hover:bg-red-700'
: 'bg-violet-600 hover:bg-violet-700';
$iconWrapClass = $variant === 'danger'
? 'bg-red-100 text-red-600'
: 'bg-violet-100 text-violet-600';
@endphp
@if(isset($trigger))
<span @click.stop="$dispatch('open-modal', {{ Js::from($name) }})" class="inline-flex">
{{ $trigger }}
</span>
@endif
<x-modal :name="$name" maxWidth="md">
<div class="px-5 pb-6 pt-2 sm:px-6 sm:pb-6 sm:pt-4">
<div class="flex flex-col items-center text-center sm:items-start sm:text-left">
<div @class(['flex h-12 w-12 items-center justify-center rounded-full', $iconWrapClass])>
@if($variant === 'danger')
<svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"/>
</svg>
@else
<svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 5.25h.008v.008H12v-.008Z"/>
</svg>
@endif
</div>
<h2 class="mt-4 text-lg font-semibold text-slate-900">{{ $title }}</h2>
@if($message)
<p class="mt-2 text-sm leading-relaxed text-slate-500">{{ $message }}</p>
@endif
@isset($details)
<div class="mt-3 w-full">{{ $details }}</div>
@endisset
</div>
<form method="post" action="{{ $action }}" class="mt-6 flex flex-col-reverse gap-2.5 sm:flex-row sm:justify-end">
@csrf
@if(strtoupper($method) !== 'POST')
@method($method)
@endif
@isset($fields)
{{ $fields }}
@endisset
<button type="button"
@click="$dispatch('close-modal', {{ Js::from($name) }})"
class="rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 transition hover:bg-slate-50">
{{ $cancelLabel }}
</button>
<button type="submit"
@class(['rounded-xl px-4 py-2.5 text-sm font-semibold text-white transition', $confirmBtnClass])>
{{ $confirmLabel }}
</button>
</form>
</div>
</x-modal>
@@ -1,73 +0,0 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
@include('partials.favicon')
<title>{{ $title ?? 'Hosting Panel' }} - Ladill</title>
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
@php
$canViewAccountDetails = auth()->check() && auth()->user()->can('viewAccount', $account);
$exitUrl = $canViewAccountDetails
? route('hosting.accounts.show', $account)
: route('hosting.single-domain');
@endphp
<body class="bg-slate-100 font-sans antialiased" x-data="{ sidebarOpen: false }">
<div class="min-h-screen flex">
{{-- Mobile sidebar overlay --}}
<div x-show="sidebarOpen" x-cloak class="fixed inset-0 z-30 bg-black/50 lg:hidden" @click="sidebarOpen = false"></div>
{{-- Sidebar --}}
<aside class="fixed inset-y-0 left-0 z-40 w-64 transform transition-transform lg:translate-x-0 bg-white border-r border-slate-200"
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'">
@include('hosting.panel.partials.sidebar')
</aside>
{{-- Main content --}}
<div class="flex-1 flex flex-col min-w-0 lg:pl-64">
{{-- Top bar --}}
<header class="sticky top-0 z-20 flex h-14 items-center gap-4 border-b border-slate-200 bg-white/95 backdrop-blur px-4 lg:px-6">
<button type="button" @click="sidebarOpen = true" class="lg:hidden -ml-2 p-2 text-slate-500 hover:text-slate-700">
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"/></svg>
</button>
<div class="flex-1">
<h1 class="text-sm font-semibold text-slate-800 truncate">{{ $header ?? 'Hosting Panel' }}</h1>
@unless ($canViewAccountDetails)
<p class="text-xs text-slate-500">Developer access</p>
@endunless
</div>
<a href="{{ $exitUrl }}" class="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-600 hover:bg-slate-50 transition">
<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="M6 18L18 6M6 6l12 12"/></svg>
Exit Panel
</a>
</header>
{{-- Flash messages --}}
@include('partials.flash')
{{-- Expired Account Banner --}}
@if ($account->isExpired() && $account->isInGracePeriod())
<div class="mx-4 mt-4 lg:mx-6 lg:mt-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3">
<div class="flex items-center gap-2.5">
<svg class="h-4.5 w-4.5 shrink-0 text-red-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z"/></svg>
<p class="text-sm text-red-800"><span class="font-semibold">Account expired.</span> Your site is offline. Files are preserved until {{ $account->gracePeriodEndsAt()->format('M d, Y') }}.
<a href="{{ route('hosting.accounts.show', $account) }}" class="underline font-medium hover:text-red-900">Renew now</a>
</p>
</div>
</div>
@endif
{{-- Page content --}}
<main class="flex-1 p-4 lg:p-6">
{{ $slot }}
</main>
</div>
</div>
@stack('scripts')
</body>
</html>
@@ -1,6 +0,0 @@
@php
$class = $class ?? 'h-5 w-5';
@endphp
<svg class="{{ $class }}" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="m21 0h-7c-1.657 0-3 1.343-3 3v3h13v-3c0-1.657-1.343-3-3-3zm-6.5 4.5c-.828 0-1.5-.672-1.5-1.5s.672-1.5 1.5-1.5 1.5.672 1.5 1.5-.672 1.5-1.5 1.5zm6.5-.5h-2c-.552 0-1-.448-1-1s.448-1 1-1h2c.552 0 1 .448 1 1s-.448 1-1 1zm-10 6c0 1.657 1.343 3 3 3h7c1.657 0 3-1.343 3-3v-3h-13zm8-1h2c.552 0 1 .448 1 1s-.448 1-1 1h-2c-.552 0-1-.448-1-1s.448-1 1-1zm-4.5-.5c.828 0 1.5.672 1.5 1.5s-.672 1.5-1.5 1.5-1.5-.672-1.5-1.5.672-1.5 1.5-1.5zm-5.5-6.5c0 .552-.447 1-1 1h-1c-1.103 0-2 .897-2 2v2.029l1.307-1.25c.399-.383 1.031-.37 1.414.028s.37 1.031-.027 1.414l-2.301 2.212c-.38.378-.883.569-1.388.569-.51 0-1.021-.193-1.41-.582l-2.288-2.199c-.397-.383-.41-1.016-.027-1.414s1.015-.411 1.414-.028l1.307 1.256v-2.035c-.001-2.206 1.793-4 3.999-4h1c.553 0 1 .448 1 1zm12 14v1c0 2.206-1.794 4-4 4h-2.029l1.25 1.307c.383.398.37 1.031-.027 1.414-.398.382-1.031.371-1.414-.028l-2.212-2.301c-.761-.761-.761-2.023.013-2.798l2.199-2.287c.382-.398 1.015-.412 1.414-.028.397.383.41 1.016.027 1.414l-1.257 1.307h2.036c1.103 0 2-.897 2-2v-1c0-.552.447-1 1-1s1 .448 1 1zm-12-1.5v2.421c-.221.268-1.456 1.079-4.5 1.079s-4.279-.811-4.5-1.079v-2.421c0-.883 1.85-1.5 4.5-1.5s4.5.617 4.5 1.5zm-4.5 5.5c1.93 0 3.421-.311 4.5-.777v2.61c0 1.377-1.641 2.167-4.5 2.167s-4.5-.79-4.5-2.167v-2.61c1.079.466 2.57.777 4.5.777z"/>
</svg>
@@ -1,5 +0,0 @@
@props(['class' => 'h-5 w-5'])
<svg {{ $attributes->merge(['class' => $class]) }} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="m4,11h7v-7h-7v7Zm2-5h3v3h-3v-3Zm14-2h-7v7h7v-7Zm-2,5h-3v-3h3v3Zm-14,11h7v-7h-7v7Zm2-5h3v3h-3v-3Zm-3,7h4v2H3c-1.654,0-3-1.346-3-3v-4h2v4c0,.551.449,1,1,1Zm19-5h2v4c0,1.654-1.346,3-3,3h-4v-2h4c.551,0,1-.449,1-1v-4Zm2-14v4h-2V3c0-.551-.449-1-1-1h-4V0h4c1.654,0,3,1.346,3,3ZM2,7H0V3C0,1.346,1.346,0,3,0h4v2H3c-.551,0-1,.449-1,1v4Zm11,10h3v3h-3v-3Zm4-1v-3h3v3h-3Zm-4-3h3v3h-3v-3Z"/>
</svg>
@@ -1,7 +0,0 @@
@php
$class = $class ?? 'h-5 w-5';
$strokeWidth = $strokeWidth ?? '1.5';
@endphp
<svg class="{{ $class }}" fill="none" stroke="currentColor" stroke-width="{{ $strokeWidth }}" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 0 1-3-3m3 3a3 3 0 1 0 0 6h13.5a3 3 0 1 0 0-6m-16.5-3a3 3 0 0 1 3-3h13.5a3 3 0 0 1 3 3m-19.5 0a4.5 4.5 0 0 1 .9-2.7L5.737 5.1a3.375 3.375 0 0 1 2.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 0 1 .9 2.7"/>
</svg>
@@ -1,6 +0,0 @@
@php
$class = $class ?? 'h-5 w-5';
@endphp
<svg class="{{ $class }}" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="m24 12c0 6.617-5.383 12-12 12-3.476 0-6.744-1.542-9-4.104v2.104c0 .552-.447 1-1 1s-1-.448-1-1v-4c0-1.103.897-2 2-2h4c.553 0 1 .448 1 1s-.447 1-1 1h-2.991c1.877 2.49 4.837 4 7.991 4 5.514 0 10-4.486 10-10 0-.552.447-1 1-1s1 .448 1 1zm-22 0c0-5.514 4.486-10 10-10 3.154 0 6.115 1.51 7.991 4h-2.991c-.553 0-1 .448-1 1s.447 1 1 1h4c1.103 0 2-.897 2-2v-4c0-.552-.447-1-1-1s-1 .448-1 1v2.104c-2.256-2.562-5.524-4.104-9-4.104-6.617 0-12 5.383-12 12 0 .552.447 1 1 1s1-.448 1-1zm13.5-3.5c1.93 0 3.5 1.57 3.5 3.5s-1.57 3.5-3.5 3.5c-1.386 0-2.685-1.085-3.5-1.949-.815.864-2.114 1.949-3.5 1.949-1.93 0-3.5-1.57-3.5-3.5s1.57-3.5 3.5-3.5c1.386 0 2.685 1.085 3.5 1.949.815-.864 2.114-1.949 3.5-1.949zm-4.795 3.5c-.67-.736-1.591-1.5-2.205-1.5-.827 0-1.5.673-1.5 1.5s.673 1.5 1.5 1.5c.614 0 1.535-.764 2.205-1.5zm4.795-1.5c-.614 0-1.535.764-2.205 1.5.67.736 1.591 1.5 2.205 1.5.827 0 1.5-.673 1.5-1.5s-.673-1.5-1.5-1.5z"/>
</svg>
@@ -1,9 +0,0 @@
@props(['messages'])
@if ($messages)
<ul {{ $attributes->merge(['class' => 'text-sm text-red-600 space-y-1']) }}>
@foreach ((array) $messages as $message)
<li>{{ $message }}</li>
@endforeach
</ul>
@endif
@@ -1,32 +0,0 @@
@props([
'title',
'subtitle' => null,
'backUrl' => null,
'badge' => null,
'hideAfia' => false,
])
<div class="sticky top-0 z-20 border-b border-slate-200 bg-white/95 backdrop-blur lg:hidden">
<div class="flex items-center gap-3 px-4 py-3">
@if ($backUrl)
<a href="{{ $backUrl }}"
class="inline-flex h-10 w-10 items-center justify-center rounded-full border border-slate-200 text-slate-600 transition hover:bg-slate-50">
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18"/></svg>
</a>
@endif
<div class="min-w-0 flex-1">
@if ($subtitle)
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">{{ $subtitle }}</p>
@endif
<h1 class="truncate text-base font-semibold text-slate-900">{{ $title }}</h1>
</div>
@if ($badge)
<span class="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-600">{{ $badge }}</span>
@endif
@unless ($hideAfia)
@include('partials.afia-button', ['compact' => true])
@endunless
{{ $actions ?? '' }}
</div>
{{ $slot }}
</div>
@@ -1,88 +0,0 @@
@props([
'name',
'show' => false,
'maxWidth' => '2xl'
])
@php
$maxWidth = [
'sm' => 'sm:max-w-sm',
'md' => 'sm:max-w-md',
'lg' => 'sm:max-w-lg',
'xl' => 'sm:max-w-xl',
'2xl' => 'sm:max-w-2xl',
][$maxWidth];
@endphp
<div
x-data="{
show: @js($show),
focusables() {
let selector = 'a, button, input:not([type=\'hidden\']), textarea, select, details, [tabindex]:not([tabindex=\'-1\'])'
return [...$el.querySelectorAll(selector)]
.filter(el => ! el.hasAttribute('disabled'))
},
firstFocusable() { return this.focusables()[0] },
lastFocusable() { return this.focusables().slice(-1)[0] },
nextFocusable() { return this.focusables()[this.nextFocusableIndex()] || this.firstFocusable() },
prevFocusable() { return this.focusables()[this.prevFocusableIndex()] || this.lastFocusable() },
nextFocusableIndex() { return (this.focusables().indexOf(document.activeElement) + 1) % (this.focusables().length + 1) },
prevFocusableIndex() { return Math.max(0, this.focusables().indexOf(document.activeElement)) -1 },
}"
x-init="$watch('show', value => {
if (value) {
document.body.classList.add('overflow-y-hidden');
{{ $attributes->has('focusable') ? 'setTimeout(() => firstFocusable().focus(), 100)' : '' }}
} else {
setTimeout(() => { if (!show) document.body.classList.remove('overflow-y-hidden'); }, 200);
}
})"
x-on:open-modal.window="String($event.detail) === @js($name) ? show = true : null"
data-modal-name="{{ $name }}"
x-on:close-modal.window="String($event.detail) === @js($name) ? show = false : null"
x-on:close.stop="show = false"
x-on:keydown.escape.window="show = false"
x-on:keydown.tab.prevent="$event.shiftKey || nextFocusable().focus()"
x-on:keydown.shift.tab.prevent="prevFocusable().focus()"
x-show="show"
x-cloak
x-transition:enter="transition-opacity ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition-opacity ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 z-50 flex items-end sm:items-center sm:justify-center"
style="background: rgba(0,0,0,0.45); backdrop-filter: blur(3px);"
>
{{-- Backdrop click to close --}}
<div class="absolute inset-0" x-on:click="show = false" data-close-modal="{{ $name }}"></div>
{{-- Panel: bottom-sheet on mobile, centered card on sm+ --}}
<div
x-show="show"
class="relative z-10 w-full max-h-[90vh] overflow-y-auto rounded-t-3xl bg-white shadow-2xl sm:mb-6 sm:max-h-[85vh] sm:rounded-2xl {{ $maxWidth }} sm:border sm:border-gray-200"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="translate-y-full sm:translate-y-0 sm:opacity-0 sm:scale-95"
x-transition:enter-end="translate-y-0 sm:opacity-100 sm:scale-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="translate-y-0 sm:opacity-100 sm:scale-100"
x-transition:leave-end="translate-y-full sm:translate-y-0 sm:opacity-0 sm:scale-95"
x-on:click.stop
>
{{-- Drag handle (mobile only) --}}
<div class="flex justify-center pb-1 pt-3 sm:hidden">
<div class="h-1 w-10 rounded-full bg-slate-200"></div>
</div>
{{-- Close button (desktop only) --}}
<button @click="show = false" data-close-modal="{{ $name }}" type="button"
class="absolute right-3 top-3 z-10 hidden h-8 w-8 items-center justify-center rounded-full text-gray-400 transition hover:bg-gray-100 hover:text-gray-600 sm:flex">
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/>
</svg>
</button>
{{ $slot }}
</div>
</div>
@@ -1,33 +0,0 @@
@props(['title' => 'Ladill Hosting'])
<!DOCTYPE html>
<html lang="en" class="h-full bg-slate-100">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ $title ?? 'Ladill Hosting' }}</title>
@include('partials.favicon')
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="h-full font-sans antialiased bg-slate-100" x-data="{ sidebarOpen: false }">
<div class="min-h-screen max-lg:min-h-dvh">
<div x-show="sidebarOpen" x-cloak class="fixed inset-0 z-30 bg-black/50 lg:hidden" @click="sidebarOpen = false"></div>
<aside x-cloak class="fixed inset-y-0 left-0 z-40 w-64 transform transition-transform lg:translate-x-0"
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'">
@include('partials.sidebar')
</aside>
<div class="flex min-h-screen flex-col min-w-0 lg:pl-64">
@include('partials.topbar')
<main class="flex-1 p-6 pb-24 lg:p-6 lg:pb-6">
@include('partials.flash')
{{ $slot }}
</main>
</div>
</div>
@auth
@include('partials.afia')
@endauth
</body>
</html>
@@ -1,124 +0,0 @@
@props([
'id',
'title' => 'Add credits',
'description' => '',
'topupAction',
'minTopup' => 5,
'suggestedAmount' => 10,
'ladillWalletBalance' => 0,
'serviceBalance' => null,
'serviceBalanceLabel' => 'Current balance',
'openOnLoad' => false,
'returnUrl' => null,
])
@php
// Single-wallet (siloing step 2): there is one Ladill wallet, so "pay from
// wallet into service credits" is a meaningless round-trip — top up the one
// wallet directly (Paystack). Transfer option removed.
$canPayFromWallet = false;
@endphp
<x-modal :name="$id" :show="$openOnLoad" maxWidth="md">
<div class="flex min-h-full flex-col sm:min-h-0">
<div class="border-b border-slate-100 px-5 py-4 pr-12 sm:px-6 sm:pr-14">
<h2 class="text-lg font-semibold text-slate-900">{{ $title }}</h2>
@if($description)
<p class="mt-1 text-sm text-slate-500">{{ $description }}</p>
@endif
</div>
<form action="{{ $topupAction }}" method="POST" class="flex flex-1 flex-col p-5 sm:p-6"
x-data="{
method: 'paystack',
showSheet: false,
checkoutUrl: '',
loading: false,
errorMsg: '',
async submitTopup(event) {
if (this.method !== 'paystack' || window.innerWidth >= 768) return;
event.preventDefault();
this.loading = true;
this.errorMsg = '';
try {
const res = await fetch(event.target.action, {
method: 'POST',
headers: { 'Accept': 'application/json' },
body: new FormData(event.target),
});
const data = await res.json();
if (!res.ok || data.error || !data.checkout_url) {
this.errorMsg = data.error || 'Unable to start payment. Please try again.';
this.loading = false;
return;
}
this.checkoutUrl = data.checkout_url;
this.showSheet = true;
this.loading = false;
} catch (error) {
this.errorMsg = 'Network error. Please try again.';
this.loading = false;
}
},
}"
@submit="submitTopup($event)">
@csrf
@if($returnUrl)
<input type="hidden" name="return_url" value="{{ $returnUrl }}">
@endif
@if($serviceBalance !== null)
<div class="mb-4 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-center">
<p class="text-xl font-bold text-slate-900">GHS {{ number_format((float) $serviceBalance, 2) }}</p>
<p class="text-xs text-slate-500">{{ $serviceBalanceLabel }}</p>
</div>
@endif
<div x-show="errorMsg" x-cloak class="mb-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700" x-text="errorMsg"></div>
<div>
<label class="text-sm font-medium text-slate-700">Amount (GHS)</label>
<input type="number" name="amount" min="{{ $minTopup }}" max="5000" step="0.01"
value="{{ $suggestedAmount }}"
class="mt-1.5 block w-full rounded-xl border border-slate-200 px-3 py-2.5 text-sm focus:border-indigo-400 focus:ring-1 focus:ring-indigo-400"
required>
<p class="mt-1.5 text-xs text-slate-500">Minimum GHS {{ number_format($minTopup, 2) }}</p>
</div>
<input type="hidden" name="payment_method" value="paystack" @if($canPayFromWallet) x-model="method" @endif>
@if($canPayFromWallet)
<div class="mt-4">
<p class="mb-2 text-xs font-medium text-slate-600">Payment method</p>
<div class="flex rounded-xl border border-slate-200 bg-white p-0.5 text-sm">
<button type="button" @click="method = 'paystack'"
:class="method === 'paystack' ? 'bg-indigo-600 text-white' : 'text-slate-600'"
class="flex-1 rounded-lg py-2 font-medium transition">Paystack</button>
<button type="button" @click="method = 'wallet'"
:class="method === 'wallet' ? 'bg-indigo-600 text-white' : 'text-slate-600'"
class="flex-1 rounded-lg py-2 font-medium transition">Wallet (GHS {{ number_format($ladillWalletBalance, 2) }})</button>
</div>
</div>
@else
<p class="mt-4 text-xs text-slate-500">Pay with Paystack.</p>
@endif
<div class="mt-6 flex flex-col gap-2 sm:mt-auto sm:flex-row sm:justify-end">
<button type="button" @click="$dispatch('close-modal', '{{ $id }}')"
data-close-modal="{{ $id }}"
class="hidden rounded-xl border border-slate-200 px-4 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50 sm:inline-flex">
Cancel
</button>
<button type="submit" :disabled="loading"
class="btn-primary w-full sm:w-auto">
<span x-text="loading ? 'Processing…' : 'Add credits'">Add credits</span>
</button>
</div>
@include('partials.paystack-sheet')
</form>
</div>
</x-modal>
@@ -1,52 +0,0 @@
@extends('layouts.email')
@section('title', 'Billing — Ladill Email')
@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 mailboxes across every Ladill app.</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">
<p class="text-xs font-medium uppercase tracking-wide text-indigo-100">Wallet balance</p>
<p class="mt-1 text-2xl font-semibold">{{ $fmt($balanceMinor) }}</p>
<a href="{{ $topupUrl }}" class="mt-3 inline-block rounded-lg bg-white/15 px-3.5 py-1.5 text-xs font-semibold text-white backdrop-blur transition hover:bg-white/25">Add funds</a>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-5">
<p class="text-xs font-medium uppercase tracking-wide text-slate-400">Spent on email</p>
<p class="mt-1.5 text-2xl font-semibold text-slate-900">{{ $fmt($spentMinor) }}</p>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-5">
<p class="text-xs font-medium uppercase tracking-wide text-slate-400">Refunded / credited</p>
<p class="mt-1.5 text-2xl font-semibold text-slate-900">{{ $fmt($creditedMinor) }}</p>
</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">Mailbox subscriptions</h2>
<span class="text-xs text-slate-400">priced by storage plan</span>
</div>
@if(empty($paidMailboxes))
<p class="px-5 py-8 text-center text-sm text-slate-400">No paid mailboxes yet your free allowance covers you.</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">Mailboxes beyond your free allowance renew monthly from your wallet. Keep it funded to avoid interruption.</p>
</div>
@endsection
@@ -1,78 +0,0 @@
@extends('layouts.email')
@section('title', 'Developers — Ladill Email')
@section('content')
<div class="mx-auto max-w-3xl">
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Developers</h1>
<p class="mt-0.5 text-sm text-slate-500">API tokens to manage your mailboxes programmatically.</p>
@if($newToken)
<div class="mt-6 rounded-2xl border border-emerald-200 bg-emerald-50 p-5">
<p class="text-sm font-semibold text-emerald-900">Your new token copy it now</p>
<p class="mt-1 text-xs text-emerald-700">This is the only time it will be shown.</p>
<div class="mt-3 flex items-center gap-2" x-data="{ copied: false }">
<code class="flex-1 truncate rounded-lg bg-white px-3 py-2 font-mono text-xs text-slate-800 ring-1 ring-emerald-200">{{ $newToken }}</code>
<button @click="navigator.clipboard.writeText('{{ $newToken }}'); copied = true; setTimeout(() => copied = false, 1500)"
class="rounded-lg bg-emerald-600 px-3 py-2 text-xs font-semibold text-white hover:bg-emerald-700">
<span x-show="!copied">Copy</span><span x-show="copied" x-cloak>Copied </span>
</button>
</div>
</div>
@endif
{{-- Create --}}
<div class="mt-6 rounded-2xl border border-slate-200 bg-white p-5">
<h2 class="text-sm font-semibold text-slate-900">Create a token</h2>
<form method="POST" action="{{ route('account.developers.store') }}" class="mt-4 flex flex-col gap-3 sm:flex-row sm:items-end">
@csrf
<div class="flex-1">
<label class="block text-[11px] font-medium text-slate-500">Token name</label>
<input type="text" name="name" required placeholder="e.g. CI server"
class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
@error('name')<p class="mt-1 text-xs text-rose-600">{{ $message }}</p>@enderror
</div>
<button class="btn-primary">Generate token</button>
</form>
</div>
{{-- Tokens --}}
<div class="mt-6 rounded-2xl border border-slate-200 bg-white">
<div class="border-b border-slate-100 px-5 py-3.5"><h2 class="text-sm font-semibold text-slate-900">Your tokens</h2></div>
@forelse($tokens as $token)
<div class="flex items-center justify-between px-5 py-3.5 {{ ! $loop->last ? 'border-b border-slate-50' : '' }}">
<div>
<p class="text-sm font-medium text-slate-900">{{ $token->name }}</p>
<p class="text-xs text-slate-400">
Created {{ $token->created_at->diffForHumans() }} ·
{{ $token->last_used_at ? 'last used '.$token->last_used_at->diffForHumans() : 'never used' }}
</p>
</div>
<x-confirm-dialog
:name="'revoke-token-'.$token->id"
title="Revoke API token?"
:message="'Revoke '.$token->name.'? Apps using this token will stop working.'"
:action="route('account.developers.destroy', $token->id)"
method="DELETE"
confirm-label="Revoke"
>
<x-slot:trigger>
<button type="button" class="text-xs font-medium text-rose-600 hover:underline">Revoke</button>
</x-slot:trigger>
</x-confirm-dialog>
</div>
@empty
<p class="px-5 py-8 text-center text-sm text-slate-400">No tokens yet.</p>
@endforelse
</div>
{{-- Docs --}}
<div class="mt-6 rounded-2xl border border-slate-200 bg-slate-900 p-5 text-slate-200">
<h2 class="text-sm font-semibold text-white">Quick start</h2>
<p class="mt-1 text-xs text-slate-400">Authenticate with a Bearer token. Base URL:</p>
<code class="mt-2 block rounded-lg bg-black/40 px-3 py-2 font-mono text-[11px] text-emerald-300">{{ $apiBase }}</code>
<pre class="mt-3 overflow-x-auto rounded-lg bg-black/40 px-3 py-3 font-mono text-[11px] leading-relaxed text-slate-300"><code>curl {{ $apiBase }}/mailboxes \
-H "Authorization: Bearer &lt;your-token&gt;" \
-H "Accept: application/json"</code></pre>
<p class="mt-3 text-[11px] text-slate-400">Endpoints: <span class="font-mono text-slate-300">GET /me</span>, <span class="font-mono text-slate-300">GET /mailboxes</span>. More coming soon.</p>
</div>
</div>
@endsection
@@ -1,110 +0,0 @@
@extends('layouts.email')
@section('title', 'Settings — Ladill Email')
@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">Defaults, 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>
<x-confirm-dialog
name="unlink-mailbox"
title="Unlink mailbox?"
message="Unlink this mailbox from your Ladill account? Ladill Mail will no longer open automatically with Ladill sign-in."
:action="route('account.settings.mailbox-unlink')"
method="DELETE"
confirm-label="Unlink"
variant="primary"
class="mt-3"
>
<x-slot:trigger>
<button type="button" 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>
</x-slot:trigger>
</x-confirm-dialog>
@elseif(($linkStatus['stage'] ?? '') === 'needs_domain')
<p class="mt-3 text-sm text-amber-800">Add and verify an email domain first.</p>
<a href="{{ route('email.domains.index') }}" class="mt-3 inline-flex items-center gap-1 text-sm font-semibold text-indigo-700 hover:text-indigo-800">
Go to domains
<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="{{ route('email.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="btn-primary">Link mailbox</button>
</form>
@endif
</div>
</div>
</div>
@endif
<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">Mailbox defaults</h2>
<div class="mt-4 grid gap-3 sm:grid-cols-2">
<div>
<label class="block text-[11px] font-medium text-slate-500">Default mailbox quota</label>
<select name="default_quota_mb" class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none">
@php $cur = (int) ($settings->default_quota_mb ?? config('email.default_quota_mb', 10240)); @endphp
@foreach([1024 => '1 GB', 5120 => '5 GB', 10240 => '10 GB', 25600 => '25 GB', 51200 => '50 GB'] as $mb => $label)
<option value="{{ $mb }}" @selected($cur === $mb)>{{ $label }}</option>
@endforeach
</select>
</div>
<div>
<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) }}"
class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
</div>
</div>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-5">
<label class="flex items-center justify-between">
<span>
<span class="block text-sm font-medium text-slate-800">Product updates</span>
<span class="block text-xs text-slate-400">Occasional emails about new Ladill Email features.</span>
</span>
<input type="checkbox" name="product_updates" value="1" @checked($settings->product_updates ?? true) class="h-5 w-5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
</label>
</div>
<button class="btn-primary">Save settings</button>
</form>
</div>
@endsection
@@ -1,90 +0,0 @@
@extends('layouts.email')
@section('title', 'Team — Ladill Email')
@section('content')
<div class="mx-auto max-w-3xl">
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Team</h1>
<p class="mt-0.5 text-sm text-slate-500">Invite people to help manage this accounts mailboxes & domains.</p>
@if($canManage)
<div class="mt-6 rounded-2xl border border-slate-200 bg-white p-5">
<h2 class="text-sm font-semibold text-slate-900">Invite a teammate</h2>
<form method="POST" action="{{ route('account.team.store') }}" class="mt-4 flex flex-col gap-3 sm:flex-row sm:items-end">
@csrf
<div class="flex-1">
<label class="block text-[11px] font-medium text-slate-500">Email</label>
<input type="email" name="email" required placeholder="teammate@example.com"
class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
@error('email')<p class="mt-1 text-xs text-rose-600">{{ $message }}</p>@enderror
</div>
<div>
<label class="block text-[11px] font-medium text-slate-500">Role</label>
<select name="role" class="mt-1 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none">
<option value="member">Member</option>
<option value="admin">Admin</option>
</select>
</div>
<button class="btn-primary">Send invite</button>
</form>
<p class="mt-2 text-[11px] text-slate-400">Admins can manage mailboxes and the team. Members can manage mailboxes. Invitees join by signing in with that email.</p>
</div>
@endif
<div class="mt-6 rounded-2xl border border-slate-200 bg-white">
<div class="border-b border-slate-100 px-5 py-3.5"><h2 class="text-sm font-semibold text-slate-900">Members</h2></div>
<ul class="divide-y divide-slate-50">
<li class="flex items-center justify-between px-5 py-3.5">
<div class="flex items-center gap-3">
<span class="inline-flex h-9 w-9 items-center justify-center rounded-full bg-slate-900 text-xs font-semibold text-white">{{ strtoupper(substr($account->name ?? $account->email, 0, 1)) }}</span>
<div>
<p class="text-sm font-medium text-slate-900">{{ $account->name ?? $account->email }} <span class="text-xs font-normal text-slate-400">(you)</span></p>
<p class="text-xs text-slate-400">{{ $account->email }}</p>
</div>
</div>
<span class="rounded-full bg-indigo-50 px-2.5 py-1 text-[11px] font-medium text-indigo-700">Owner</span>
</li>
@forelse($members as $member)
<li class="flex items-center justify-between px-5 py-3.5">
<div class="flex items-center gap-3">
<span class="inline-flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-xs font-semibold text-slate-600">{{ strtoupper(substr($member->email, 0, 1)) }}</span>
<div>
<p class="text-sm font-medium text-slate-900">{{ $member->member->name ?? $member->email }}</p>
<p class="text-xs text-slate-400">{{ $member->email }}</p>
</div>
</div>
<div class="flex items-center gap-3">
@if($member->status === 'invited')
<span class="rounded-full bg-amber-50 px-2.5 py-1 text-[11px] font-medium text-amber-700">Invited</span>
@endif
@if($canManage)
<form method="POST" action="{{ route('account.team.role', $member) }}">
@csrf @method('PATCH')
<select name="role" onchange="this.form.submit()" class="rounded-lg border border-slate-200 bg-white px-2 py-1 text-xs focus:outline-none">
<option value="member" @selected($member->role === 'member')>Member</option>
<option value="admin" @selected($member->role === 'admin')>Admin</option>
</select>
</form>
<x-confirm-dialog
:name="'remove-member-'.$member->id"
title="Remove team member?"
:message="'Remove '.$member->email.' from this team?'"
:action="route('account.team.destroy', $member)"
method="DELETE"
confirm-label="Remove"
>
<x-slot:trigger>
<button type="button" class="text-xs font-medium text-rose-600 hover:underline">Remove</button>
</x-slot:trigger>
</x-confirm-dialog>
@else
<span class="rounded-full bg-slate-100 px-2.5 py-1 text-[11px] font-medium capitalize text-slate-600">{{ $member->role }}</span>
@endif
</div>
</li>
@empty
<li class="px-5 py-8 text-center text-sm text-slate-400">No teammates yet.</li>
@endforelse
</ul>
</div>
</div>
@endsection
@@ -1,27 +0,0 @@
@extends('layouts.email')
@section('title', 'Wallet — Ladill Email')
@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>
<p class="mt-0.5 text-sm text-slate-500">Your Ladill wallet funds mailboxes across every Ladill app.</p>
<div class="mt-6 rounded-2xl bg-gradient-to-br from-indigo-700 via-indigo-600 to-blue-500 p-6 text-white shadow-sm">
<p class="text-xs font-medium uppercase tracking-wide text-indigo-100">Balance</p>
<p class="mt-1 text-3xl font-semibold">{{ $fmt($balanceMinor) }}</p>
<a href="{{ $topupUrl }}" class="mt-4 inline-block rounded-lg bg-white/15 px-4 py-2 text-sm font-semibold text-white backdrop-blur transition hover:bg-white/25">Add funds</a>
</div>
<div class="mt-4 grid gap-4 sm:grid-cols-2">
<div class="rounded-2xl border border-slate-200 bg-white p-5">
<p class="text-xs font-medium uppercase tracking-wide text-slate-400">Spent on email</p>
<p class="mt-1.5 text-2xl font-semibold text-slate-900">{{ $fmt($spentMinor) }}</p>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-5">
<p class="text-xs font-medium uppercase tracking-wide text-slate-400">Refunded / credited</p>
<p class="mt-1.5 text-2xl font-semibold text-slate-900">{{ $fmt($creditedMinor) }}</p>
</div>
</div>
<p class="mt-4 text-xs text-slate-400">Mailboxes beyond your free allowance are billed monthly from this wallet.</p>
</div>
@endsection
-47
View File
@@ -1,47 +0,0 @@
@extends('layouts.email')
@section('title', 'Overview — Ladill Email')
@section('content')
<div class="mx-auto max-w-5xl">
<div class="flex items-center justify-between">
<div>
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Overview</h1>
<p class="mt-0.5 text-sm text-slate-500">Your mailboxes at a glance.</p>
</div>
<x-btn.create :href="route('email.mailboxes.create')">New mailbox</x-btn.create>
</div>
@if($error)<div class="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">{{ $error }}</div>@endif
<div class="mt-6 grid gap-4 sm:grid-cols-3">
@foreach([['Mailboxes', $mailboxCount, route('email.mailboxes.index')], ['Domains', $domainCount, route('email.domains.index')], ['Verified domains', $verifiedDomainCount, route('email.domains.index')]] as [$label, $value, $link])
<a href="{{ $link }}" class="rounded-2xl border border-slate-200 bg-white p-5 transition hover:border-indigo-200">
<p class="text-xs font-medium uppercase tracking-wide text-slate-400">{{ $label }}</p>
<p class="mt-2 text-2xl font-semibold text-slate-900">{{ $value }}</p>
</a>
@endforeach
</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">Recent mailboxes</h2>
<a href="{{ route('email.mailboxes.index') }}" class="text-xs font-medium text-indigo-600 hover:underline">View all</a>
</div>
@if(empty($recent))
<div class="px-5 py-12 text-center">
<p class="text-sm font-semibold text-slate-700">No mailboxes yet</p>
<p class="mx-auto mt-1 max-w-sm text-xs text-slate-400">Add a domain, verify it, then create your first mailbox.</p>
<a href="{{ route('email.domains.index') }}" class="mt-4 inline-block rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Set up a domain</a>
</div>
@else
<div class="divide-y divide-slate-50">
@foreach($recent as $m)
<a href="{{ route('email.mailboxes.show', $m['id']) }}" class="flex items-center justify-between px-5 py-3.5 transition hover:bg-slate-50">
<span class="text-sm font-medium text-slate-900">{{ $m['address'] }}</span>
<span class="rounded-full bg-slate-100 px-2.5 py-1 text-[11px] font-medium capitalize text-slate-600">{{ $m['status'] }}</span>
</a>
@endforeach
</div>
@endif
</div>
</div>
@endsection
@@ -1,30 +0,0 @@
@extends('layouts.email')
@section('title', 'Domains — Ladill Email')
@section('content')
<div class="mx-auto max-w-3xl">
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Email domains</h1>
<p class="mt-0.5 text-sm text-slate-500">Add a domain and verify it to create mailboxes on it.</p>
@if($error)<div class="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">{{ $error }}</div>@endif
<form method="POST" action="{{ route('email.domains.store') }}" class="mt-5 flex gap-2 rounded-2xl border border-slate-200 bg-white p-2">
@csrf
<input name="domain" required placeholder="yourdomain.com" class="flex-1 rounded-xl border-0 bg-transparent px-3 py-2 text-sm focus:outline-none focus:ring-0">
<x-btn.create type="submit">Add domain</x-btn.create>
</form>
@if(!empty($domains))
<div class="mt-6 divide-y divide-slate-100 overflow-hidden rounded-2xl border border-slate-200 bg-white">
@foreach($domains as $d)
<a href="{{ route('email.domains.show', $d['id']) }}" class="flex items-center justify-between px-5 py-4 transition hover:bg-slate-50">
<span class="text-sm font-semibold text-slate-900">{{ $d['domain'] }}</span>
@if($d['active'] ?? false)
<span class="rounded-full bg-emerald-50 px-2.5 py-1 text-[11px] font-medium text-emerald-700">Verified</span>
@else
<span class="rounded-full bg-amber-50 px-2.5 py-1 text-[11px] font-medium text-amber-700">Pending verification</span>
@endif
</a>
@endforeach
</div>
@endif
</div>
@endsection
@@ -1,71 +0,0 @@
@extends('layouts.email')
@section('title', $domain['domain'].' — Ladill Email')
@section('content')
<div class="mx-auto max-w-3xl">
<div class="flex items-center gap-2 text-sm text-slate-400">
<a href="{{ route('email.domains.index') }}" class="hover:text-slate-600">Domains</a><span>/</span>
<span class="text-slate-600">{{ $domain['domain'] }}</span>
</div>
<div class="mt-2 flex items-center justify-between">
<h1 class="text-xl font-semibold tracking-tight text-slate-900">{{ $domain['domain'] }}</h1>
@if($domain['active'] ?? false)
<span class="rounded-full bg-emerald-50 px-2.5 py-1 text-[11px] font-medium text-emerald-700">Verified</span>
@else
<span class="rounded-full bg-amber-50 px-2.5 py-1 text-[11px] font-medium text-amber-700">Pending</span>
@endif
</div>
@unless($domain['active'] ?? false)
<div class="mt-6 rounded-2xl border border-slate-200 bg-white">
<div class="border-b border-slate-100 px-5 py-3.5"><h2 class="text-sm font-semibold text-slate-900">Publish these DNS records</h2></div>
@php $records = $domain['dns_records'] ?? []; @endphp
@if(empty($records))
<p class="px-5 py-6 text-sm text-slate-400">No DNS records returned. Try refreshing.</p>
@else
<table class="w-full text-sm">
<thead class="text-left text-[11px] uppercase tracking-wide text-slate-400">
<tr><th class="px-5 py-2 font-medium">Type</th><th class="px-2 py-2 font-medium">Name</th><th class="px-2 py-2 font-medium">Value</th></tr>
</thead>
<tbody class="divide-y divide-slate-50">
@foreach($records as $r)
<tr>
<td class="px-5 py-2.5 font-medium text-slate-700">{{ $r['type'] ?? '' }}</td>
<td class="px-2 py-2.5 font-mono text-xs text-slate-600">{{ $r['name'] ?? ($r['host'] ?? '@') }}</td>
<td class="px-2 py-2.5 max-w-xs truncate font-mono text-xs text-slate-600" title="{{ $r['value'] ?? '' }}">{{ $r['value'] ?? '' }}</td>
</tr>
@endforeach
</tbody>
</table>
@endif
</div>
<form method="POST" action="{{ route('email.domains.verify', $domain['id']) }}" class="mt-4">
@csrf
<button class="btn-primary">Verify domain</button>
<span class="ml-2 text-xs text-slate-400">DNS changes can take time to propagate.</span>
</form>
@else
<div class="mt-6 rounded-2xl border border-slate-200 bg-white p-5">
<p class="text-sm text-slate-600">This domain is verified. <a href="{{ route('email.mailboxes.create') }}" class="font-semibold text-indigo-600 hover:underline">Create a mailbox</a> on it.</p>
<dl class="mt-3 flex gap-4 text-xs text-slate-500">
<div>SPF <span class="{{ ($domain['spf'] ?? false) ? 'text-emerald-600' : 'text-rose-600' }}">{{ ($domain['spf'] ?? false) ? '✓' : '✗' }}</span></div>
<div>DKIM <span class="{{ ($domain['dkim'] ?? false) ? 'text-emerald-600' : 'text-rose-600' }}">{{ ($domain['dkim'] ?? false) ? '✓' : '✗' }}</span></div>
<div>DMARC <span class="{{ ($domain['dmarc'] ?? false) ? 'text-emerald-600' : 'text-rose-600' }}">{{ ($domain['dmarc'] ?? false) ? '✓' : '✗' }}</span></div>
</dl>
</div>
@endunless
<x-confirm-dialog
:name="'remove-domain-'.$domain['id']"
title="Remove domain?"
:message="'Remove '.$domain['domain'].'?'"
:action="route('email.domains.destroy', $domain['id'])"
method="DELETE"
confirm-label="Remove domain"
class="mt-4"
>
<x-slot:trigger>
<button type="button" class="text-xs font-medium text-rose-600 hover:underline">Remove domain</button>
</x-slot:trigger>
</x-confirm-dialog>
</div>
@endsection
@@ -1,97 +0,0 @@
@extends('layouts.email')
@section('title', 'New mailbox — Ladill Email')
@section('content')
<div class="mx-auto max-w-lg">
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Create a mailbox</h1>
@if(empty($domains))
<div class="mt-6 rounded-2xl border border-amber-200 bg-amber-50 p-6 text-sm text-amber-800">
You need a <strong>verified domain</strong> first. <a href="{{ route('email.domains.index') }}" class="font-semibold underline">Set up a domain</a>.
</div>
@else
@php $fmt = fn ($m) => $quota['currency'].' '.number_format($m / 100, 2); @endphp
<div x-data="{
tiers: @js($quota['tiers']),
sel: {{ $quota['default_mb'] }},
balance: {{ $walletBalanceMinor }},
get tier() { return this.tiers.find(t => t.mb == this.sel) || this.tiers[0]; },
get price() { return this.tier.price_minor; },
get free() { return this.price === 0; },
fmt(m) { return '{{ $quota['currency'] }} ' + (m / 100).toFixed(2); },
}">
{{-- Pricing banner (reacts to the selected storage plan) --}}
<template x-if="!free">
<div class="mt-5 rounded-2xl border border-indigo-200 bg-indigo-50 px-4 py-3 text-sm text-indigo-800">
This plan is <strong><span x-text="fmt(price)"></span>/month</strong>, charged from your Ladill wallet
(balance: <span x-text="fmt(balance)"></span>).
<a href="{{ $topupUrl }}" class="font-semibold underline" x-show="balance < price" x-cloak>Top up</a>
</div>
</template>
<template x-if="free">
<div class="mt-5 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
The <strong>1 GB</strong> plan is <strong>free, forever</strong>. You can upgrade for more storage anytime.
</div>
</template>
<form method="POST" action="{{ route('email.mailboxes.store') }}" class="mt-4 space-y-4 rounded-2xl border border-slate-200 bg-white p-6">
@csrf
<div>
<label class="block text-xs font-medium text-slate-600">Domain</label>
<select name="email_domain_id" class="mt-1 w-full rounded-xl border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none">
@foreach($domains as $d)<option value="{{ $d['id'] }}">{{ $d['domain'] }}</option>@endforeach
</select>
</div>
<div>
<label class="block text-xs font-medium text-slate-600">Mailbox name</label>
<input name="local_part" required value="{{ old('local_part') }}" placeholder="you"
class="mt-1 w-full rounded-xl border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none">
@error('local_part')<p class="mt-1 text-xs text-rose-600">{{ $message }}</p>@enderror
</div>
<div>
<label class="block text-xs font-medium text-slate-600">Display name</label>
<input name="display_name" required value="{{ old('display_name') }}" placeholder="Your Name"
class="mt-1 w-full rounded-xl border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none">
</div>
{{-- Storage plan (sets quota + price) --}}
<div>
<label class="block text-xs font-medium text-slate-600">Storage plan</label>
<div class="mt-1.5 grid grid-cols-1 gap-2">
@foreach($quota['tiers'] as $t)
<label class="flex cursor-pointer items-center justify-between rounded-xl border bg-slate-50 px-3.5 py-2.5 text-sm transition"
:class="sel == {{ $t['mb'] }} ? 'border-indigo-400 bg-indigo-50 ring-1 ring-indigo-200' : 'border-slate-200 hover:bg-slate-100'">
<span class="flex items-center gap-2.5">
<input type="radio" name="quota_mb" value="{{ $t['mb'] }}" x-model.number="sel" class="text-indigo-600 focus:ring-indigo-500">
<span class="font-medium text-slate-800">{{ $t['label'] }}</span>
</span>
@if($t['price_minor'] === 0)
<span class="font-semibold text-emerald-600">Free</span>
@else
<span class="text-slate-500">{{ $fmt($t['price_minor']) }}<span class="text-xs">/mo</span></span>
@endif
</label>
@endforeach
</div>
@error('quota_mb')<p class="mt-1 text-xs text-rose-600">{{ $message }}</p>@enderror
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-slate-600">Password</label>
<input type="password" name="password" required class="mt-1 w-full rounded-xl border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none">
</div>
<div>
<label class="block text-xs font-medium text-slate-600">Confirm</label>
<input type="password" name="password_confirmation" required class="mt-1 w-full rounded-xl border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none">
</div>
</div>
@error('password')<p class="text-xs text-rose-600">{{ $message }}</p>@enderror
<button class="btn-primary w-full btn-primary-lg">
<svg class="h-4 w-4 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg>
<span x-text="free ? 'Create free mailbox' : ('Create mailbox — ' + fmt(price) + '/mo')">Create mailbox</span>
</button>
</form>
</div>
@endif
</div>
@endsection
@@ -1,32 +0,0 @@
@extends('layouts.email')
@section('title', 'Mailboxes — Ladill Email')
@section('content')
<div class="mx-auto max-w-4xl">
<div class="flex items-center justify-between">
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Mailboxes</h1>
<x-btn.create :href="route('email.mailboxes.create')">New mailbox</x-btn.create>
</div>
@if($error)<div class="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">{{ $error }}</div>@endif
@if(empty($mailboxes))
<div class="mt-6 rounded-2xl border border-dashed border-slate-300 bg-white p-12 text-center">
<p class="text-sm font-semibold text-slate-700">No mailboxes yet</p>
<p class="mx-auto mt-1 max-w-sm text-xs text-slate-400">Verify a domain, then create a mailbox like you@yourdomain.com.</p>
<x-btn.create :href="route('email.mailboxes.create')" class="mt-4">Create mailbox</x-btn.create>
</div>
@else
<div class="mt-6 divide-y divide-slate-100 overflow-hidden rounded-2xl border border-slate-200 bg-white">
@foreach($mailboxes as $m)
<a href="{{ route('email.mailboxes.show', $m['id']) }}" class="flex items-center justify-between gap-3 px-5 py-4 transition hover:bg-slate-50">
<div class="min-w-0">
<p class="truncate text-sm font-semibold text-slate-900">{{ $m['address'] }}</p>
<p class="mt-0.5 text-xs text-slate-400">{{ $m['display_name'] ?? '' }} · {{ number_format(($m['quota_mb'] ?? 0)/1024, 0) }} GB</p>
</div>
@php $st = $m['status'] ?? 'pending'; $badge = $st === 'active' ? 'bg-emerald-50 text-emerald-700' : ($st === 'failed' ? 'bg-rose-50 text-rose-700' : 'bg-slate-100 text-slate-600'); @endphp
<span class="shrink-0 rounded-full px-2.5 py-1 text-[11px] font-medium capitalize {{ $badge }}">{{ $st }}</span>
</a>
@endforeach
</div>
@endif
</div>
@endsection
@@ -1,92 +0,0 @@
@extends('layouts.email')
@section('title', $mailbox['address'].' — Ladill Email')
@section('content')
@php $host = 'mail.'.config('app.platform_domain'); @endphp
<div class="mx-auto max-w-2xl">
<div class="flex items-center gap-2 text-sm text-slate-400">
<a href="{{ route('email.mailboxes.index') }}" class="hover:text-slate-600">Mailboxes</a><span>/</span>
<span class="text-slate-600">{{ $mailbox['address'] }}</span>
</div>
<div class="mt-2 flex items-center justify-between">
<h1 class="text-xl font-semibold tracking-tight text-slate-900">{{ $mailbox['address'] }}</h1>
<span class="rounded-full bg-slate-100 px-2.5 py-1 text-[11px] font-medium capitalize text-slate-600">{{ $mailbox['status'] ?? '' }}</span>
</div>
<div class="mt-6 rounded-2xl border border-slate-200 bg-white p-5">
<h2 class="text-sm font-semibold text-slate-900">Connection settings</h2>
<p class="mt-0.5 text-xs text-slate-400">Use these in any mail client, or just open webmail.</p>
<dl class="mt-4 space-y-2 text-sm">
<div class="flex justify-between"><dt class="text-slate-500">Username</dt><dd class="font-mono text-slate-800">{{ $mailbox['address'] }}</dd></div>
<div class="flex justify-between"><dt class="text-slate-500">IMAP</dt><dd class="font-mono text-slate-800">{{ $host }}:993 (SSL)</dd></div>
<div class="flex justify-between"><dt class="text-slate-500">SMTP</dt><dd class="font-mono text-slate-800">{{ $host }}:587 (STARTTLS)</dd></div>
</dl>
<a href="https://{{ $host }}" class="btn-primary mt-4">Open Webmail </a>
</div>
@php
$quotaMb = (int) ($mailbox['quota_mb'] ?? 0);
$usedBytes = (int) ($mailbox['used_bytes'] ?? 0);
$price = \App\Support\MailboxPricing::priceMinorFor($quotaMb);
$tiers = \App\Support\MailboxPricing::tiers();
$maxMb = collect($tiers)->max('mb');
$pct = $quotaMb > 0 ? min(100, (int) round($usedBytes / ($quotaMb * 1048576) * 100)) : 0;
$fmt = fn ($m) => config('email.currency', 'GHS').' '.number_format($m / 100, 2);
@endphp
<div class="mt-4 rounded-2xl border border-slate-200 bg-white p-5">
<div class="flex items-start justify-between">
<div>
<h2 class="text-sm font-semibold text-slate-900">Storage plan</h2>
<p class="mt-0.5 text-sm text-slate-600">
<span class="font-semibold">{{ \App\Support\MailboxPricing::label($quotaMb) }}</span>
@if($price === 0)
<span class="ml-1 rounded-full bg-emerald-50 px-2 py-0.5 text-[11px] font-medium text-emerald-700">Free</span>
@else
<span class="ml-1 text-xs text-slate-400">{{ $fmt($price) }}/month</span>
@endif
</p>
</div>
@if($quotaMb < $maxMb)
<a href="{{ route('email.mailboxes.upgrade', $mailbox['id']) }}" class="btn-primary">Upgrade</a>
@else
<span class="rounded-lg bg-slate-100 px-3 py-2 text-xs font-medium text-slate-500">Top plan</span>
@endif
</div>
@if($quotaMb > 0)
<div class="mt-4">
<div class="h-2 w-full overflow-hidden rounded-full bg-slate-100">
<div class="h-full rounded-full {{ $pct >= 90 ? 'bg-rose-500' : 'bg-indigo-500' }}" style="width: {{ max(2, $pct) }}%"></div>
</div>
<p class="mt-1.5 text-xs text-slate-400">{{ number_format($usedBytes / 1048576, 0) }} MB of {{ \App\Support\MailboxPricing::label($quotaMb) }} used ({{ $pct }}%)</p>
</div>
@endif
</div>
<div class="mt-4 rounded-2xl border border-slate-200 bg-white p-5">
<h2 class="text-sm font-semibold text-slate-900">Reset password</h2>
<form method="POST" action="{{ route('email.mailboxes.password', $mailbox['id']) }}" class="mt-3 grid grid-cols-2 gap-3">
@csrf @method('PATCH')
<input type="password" name="password" required placeholder="New password" class="rounded-xl border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none">
<input type="password" name="password_confirmation" required placeholder="Confirm" class="rounded-xl border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none">
<button class="col-span-2 rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Update password</button>
</form>
</div>
<div class="mt-4 rounded-2xl border border-rose-200 bg-white p-5">
<h2 class="text-sm font-semibold text-rose-700">Delete mailbox</h2>
<p class="mt-0.5 text-xs text-slate-400">This permanently removes the mailbox and its email.</p>
<x-confirm-dialog
:name="'delete-mailbox-'.$mailbox['id']"
title="Delete mailbox?"
:message="'Delete '.$mailbox['address'].'? This cannot be undone.'"
:action="route('email.mailboxes.destroy', $mailbox['id'])"
method="DELETE"
confirm-label="Delete mailbox"
class="mt-3"
>
<x-slot:trigger>
<button type="button" class="rounded-lg bg-rose-600 px-4 py-2 text-sm font-semibold text-white hover:bg-rose-700">Delete mailbox</button>
</x-slot:trigger>
</x-confirm-dialog>
</div>
</div>
@endsection
@@ -1,52 +0,0 @@
@extends('layouts.email')
@section('title', 'Upgrade storage — Ladill Email')
@section('content')
@php $fmt = fn ($m) => $currency.' '.number_format($m / 100, 2); @endphp
<div class="mx-auto max-w-lg">
<div class="flex items-center gap-2 text-sm text-slate-400">
<a href="{{ route('email.mailboxes.show', $mailbox['id']) }}" class="hover:text-slate-600">{{ $mailbox['address'] }}</a><span>/</span>
<span class="text-slate-600">Upgrade</span>
</div>
<h1 class="mt-2 text-xl font-semibold tracking-tight text-slate-900">Upgrade storage</h1>
<p class="mt-0.5 text-sm text-slate-500">
Currently on <strong>{{ \App\Support\MailboxPricing::label($currentMb) }}</strong>. Pick a larger plan billed monthly from your wallet.
</p>
<div x-data="{
tiers: @js($tiers),
sel: {{ $defaultMb }},
balance: {{ $walletBalanceMinor }},
get tier() { return this.tiers.find(t => t.mb == this.sel) || this.tiers[0]; },
get price() { return this.tier.price_minor; },
fmt(m) { return '{{ $currency }} ' + (m / 100).toFixed(2); },
}">
<div class="mt-5 rounded-2xl border border-indigo-200 bg-indigo-50 px-4 py-3 text-sm text-indigo-800">
New plan: <strong><span x-text="fmt(price)"></span>/month</strong>, charged now from your wallet
(balance: <span x-text="fmt(balance)"></span>).
<a href="{{ $topupUrl }}" class="font-semibold underline" x-show="balance < price" x-cloak>Top up</a>
</div>
<form method="POST" action="{{ route('email.mailboxes.upgrade.store', $mailbox['id']) }}" class="mt-4 space-y-4 rounded-2xl border border-slate-200 bg-white p-6">
@csrf @method('PATCH')
<div>
<label class="block text-xs font-medium text-slate-600">Choose a plan</label>
<div class="mt-1.5 grid grid-cols-1 gap-2">
@foreach($tiers as $t)
<label class="flex cursor-pointer items-center justify-between rounded-xl border bg-slate-50 px-3.5 py-2.5 text-sm transition"
:class="sel == {{ $t['mb'] }} ? 'border-indigo-400 bg-indigo-50 ring-1 ring-indigo-200' : 'border-slate-200 hover:bg-slate-100'">
<span class="flex items-center gap-2.5">
<input type="radio" name="quota_mb" value="{{ $t['mb'] }}" x-model.number="sel" class="text-indigo-600 focus:ring-indigo-500">
<span class="font-medium text-slate-800">{{ $t['label'] }}</span>
</span>
<span class="text-slate-500">{{ $fmt($t['price_minor']) }}<span class="text-xs">/mo</span></span>
</label>
@endforeach
</div>
@error('quota_mb')<p class="mt-1 text-xs text-rose-600">{{ $message }}</p>@enderror
</div>
<button class="btn-primary w-full btn-primary-lg"
x-text="'Upgrade — ' + fmt(price) + '/mo'">Upgrade</button>
</form>
</div>
</div>
@endsection
@@ -1,17 +0,0 @@
<!DOCTYPE html>
<html lang="en" class="h-full">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Signed out Ladill Email</title>
@include('partials.favicon')
@vite(['resources/css/app.css'])
<meta http-equiv="refresh" content="2;url={{ 'https://'.config('app.platform_domain') }}">
</head>
<body class="flex h-full items-center justify-center bg-slate-100 font-sans">
<div class="text-center">
<img src="{{ asset('images/logo/ladillemail-logo.svg') }}" alt="Ladill Email" class="mx-auto h-7 w-auto">
<p class="mt-6 text-sm text-slate-600">Youve been signed out. Redirecting…</p>
<a href="{{ 'https://'.config('app.platform_domain') }}" class="mt-2 inline-block text-sm font-medium text-indigo-600 hover:underline">Go to ladill.com</a>
</div>
</body>
</html>
-56
View File
@@ -1,56 +0,0 @@
<!DOCTYPE html>
<html lang="en" class="h-full bg-slate-100">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>@yield('title', 'Ladill Hosting')</title>
@include('partials.favicon')
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="h-full font-sans antialiased bg-slate-100" x-data="{ sidebarOpen: false }" data-ladill-search-url="{{ route('mini.search') }}">
<div class="min-h-screen max-lg:min-h-dvh">
<div x-show="sidebarOpen" x-cloak class="fixed inset-0 z-30 bg-black/50 lg:hidden" @click="sidebarOpen = false"></div>
<aside x-cloak class="fixed inset-y-0 left-0 z-40 w-64 transform transition-transform lg:translate-x-0"
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'">
@include('partials.sidebar')
</aside>
<div class="flex min-h-screen flex-col min-w-0 lg:pl-64">
@include('partials.topbar')
<main class="flex-1 p-6 pb-24 lg:p-6 lg:pb-6">
@include('partials.flash')
@yield('content')
</main>
</div>
</div>
@auth
@php
$navUser = auth()->user();
$navInitials = collect(explode(' ', trim((string) $navUser?->name)))
->filter()->take(2)
->map(fn ($part) => strtoupper(substr($part, 0, 1)))
->implode('');
$navAcct = 'https://'.config('app.account_domain');
@endphp
@include('partials.mobile-bottom-nav', [
'homeUrl' => route('hosting.dashboard'),
'homeActive' => request()->routeIs('hosting.dashboard') || request()->routeIs('hosting.index'),
'searchUrl' => route('hosting.dashboard'),
'searchActive' => request()->routeIs('hosting.*'),
'notificationsUrl' => route('notifications.index'),
'notificationsActive' => request()->routeIs('notifications.*'),
'unreadUrl' => route('notifications.unread'),
'profileActive' => false,
'profileName' => $navUser?->name ?? '',
'profileSubtitle' => $navUser?->email ?? '',
'profileMenuItems' => \App\Support\UserProfileMenu::items($navUser),
'avatarUrl' => $navUser?->avatar_url,
'initials' => $navInitials !== '' ? $navInitials : 'U',
])
@include('partials.afia')
@endauth
@include('partials.confirm-prompt')
</body>
</html>
-260
View File
@@ -1,260 +0,0 @@
@php
$mobileFullScreenPage = request()->routeIs('account.settings')
|| request()->routeIs('mini.payment-qrs.create')
|| request()->routeIs('mini.payment-qrs.show');
$qrMobilePage = request()->routeIs('mini.payment-qrs.create') || request()->routeIs('mini.payment-qrs.show');
@endphp
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" @class(['qr-mobile-page' => $qrMobilePage])>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="csrf-token" content="{{ csrf_token() }}">
@include('partials.favicon')
<title>{{ $title ?? 'Dashboard' }} - Ladill</title>
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="font-sans antialiased bg-slate-100" x-data="{ sidebarOpen: false }">
<div class="min-h-screen max-lg:min-h-dvh">
{{-- Mobile sidebar overlay --}}
<div x-show="sidebarOpen" x-cloak class="fixed inset-0 z-30 bg-black/50 lg:hidden" @click="sidebarOpen = false"></div>
{{-- Sidebar full product nav (Bird and other extracted apps have their
own nav in their own apps). --}}
@php $sidebarPartial = 'partials.sidebar'; @endphp
<aside x-cloak class="fixed inset-y-0 left-0 z-40 w-64 transform transition-transform lg:translate-x-0"
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'">
@include($sidebarPartial)
</aside>
{{-- Main content --}}
<div class="flex min-h-screen flex-col min-w-0 lg:pl-64">
<div class="{{ $mobileFullScreenPage ? 'hidden lg:block' : '' }}">
@include('partials.topbar-qr')
</div>
<div @class([
'px-6 pt-4',
'hidden lg:block' => $mobileFullScreenPage,
])>
@include('partials.flash')
</div>
<main @class([
'flex-1 lg:p-6 lg:pb-6',
'px-4 pt-4 max-lg:pb-[calc(4rem+max(env(safe-area-inset-bottom,0px),20px))]' => $qrMobilePage,
'p-0 pb-24 lg:p-6 lg:pb-6' => $mobileFullScreenPage && ! $qrMobilePage,
'p-6 pb-24 lg:pb-6' => ! $mobileFullScreenPage,
])>
{{ $slot }}
</main>
</div>
{{-- Mobile Bottom Navigation (hidden on QR create/show which have their own action bar) --}}
@unless(request()->routeIs('mini.payment-qrs.create') || request()->routeIs('mini.payment-qrs.show'))
@php
$navUser = auth()->user();
$navInitials = collect(explode(' ', trim((string) $navUser?->name)))
->filter()->take(2)
->map(fn ($part) => strtoupper(substr($part, 0, 1)))
->implode('');
@endphp
@include('partials.mobile-bottom-nav', [
'homeUrl' => route('mini.dashboard'),
'homeActive' => request()->routeIs('mini.dashboard'),
'searchUrl' => route('mini.search'),
'searchActive' => request()->routeIs('mini.search'),
'notificationsUrl' => route('notifications.index'),
'notificationsActive' => request()->routeIs('notifications.*'),
'unreadUrl' => route('notifications.unread'),
'profileActive' => request()->routeIs('account.settings'),
'profileName' => $navUser?->name ?? '',
'profileSubtitle' => $navUser?->email ?? '',
'profileMenuItems' => \App\Support\UserProfileMenu::items($navUser),
'avatarUrl' => $navUser?->avatarUrl(),
'initials' => $navInitials !== '' ? $navInitials : 'U',
])
@endunless
@if ($qrMobilePage)
<div class="qr-mobile-bottom-bleed lg:hidden" aria-hidden="true"></div>
@endif
</div>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('notificationDropdown', (config = {}) => ({
open: false,
loading: false,
notifications: [],
unreadCount: 0,
unreadUrl: config.unreadUrl || '/notifications/unread',
markReadUrl: config.markReadUrl || '/notifications/__ID__/read',
markAllReadUrl: config.markAllReadUrl || '/notifications/mark-all-read',
indexUrl: config.indexUrl || '/notifications',
csrfToken: config.csrfToken || document.querySelector('meta[name="csrf-token"]')?.content || '',
init() {
this.fetchUnread();
setInterval(() => this.fetchUnread(), 60000);
},
async fetchUnread() {
try {
const res = await fetch(this.unreadUrl, {
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await res.json();
this.notifications = data.notifications || [];
this.unreadCount = data.unread_count || 0;
} catch (e) {
console.error('Failed to fetch notifications', e);
}
},
toggle() {
this.open = !this.open;
if (this.open) {
this.loading = true;
this.fetchUnread().finally(() => this.loading = false);
}
},
async markRead(id) {
const url = this.markReadUrl.replace('__ID__', id);
try {
await fetch(url, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': this.csrfToken,
'X-Requested-With': 'XMLHttpRequest',
},
});
this.notifications = this.notifications.filter(n => n.id !== id);
this.unreadCount = Math.max(0, this.unreadCount - 1);
} catch (e) {
console.error('Failed to mark notification as read', e);
}
},
async markAllRead() {
try {
await fetch(this.markAllReadUrl, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': this.csrfToken,
'X-Requested-With': 'XMLHttpRequest',
},
});
this.notifications = [];
this.unreadCount = 0;
} catch (e) {
console.error('Failed to mark all notifications as read', e);
}
},
getIconBg(icon) {
const map = {
website: 'bg-blue-50',
domain: 'bg-emerald-50',
hosting: 'bg-violet-50',
email: 'bg-pink-50',
billing: 'bg-amber-50',
lead: 'bg-cyan-50',
success: 'bg-green-50',
};
return map[icon] || 'bg-slate-100';
},
getIconColor(icon) {
const map = {
website: 'text-blue-600',
domain: 'text-emerald-600',
hosting: 'text-violet-600',
email: 'text-pink-600',
billing: 'text-amber-600',
lead: 'text-cyan-600',
success: 'text-green-600',
};
return map[icon] || 'text-slate-500';
},
}));
Alpine.data('balanceGate', (config = {}) => ({
balance: Number(config.balance ?? 0),
price: Number(config.price ?? 0),
modalId: config.modalId ?? null,
topupUrl: config.topupUrl ?? null,
href: config.href ?? null,
requiresPayment: Object.prototype.hasOwnProperty.call(config, 'requiresPayment')
? config.requiresPayment !== false
: true,
needsTopupFor(requiresPayment = this.requiresPayment) {
if (requiresPayment === false) {
return false;
}
return this.balance <= 0 || this.balance < this.price;
},
needsTopup() {
return this.needsTopupFor(this.requiresPayment);
},
openTopup() {
if (this.topupUrl) {
window.location.href = this.topupUrl;
return;
}
if (! this.modalId) {
return;
}
window.dispatchEvent(new CustomEvent('open-modal', {
detail: this.modalId,
bubbles: true,
}));
},
goOrTopup(event, href = null, requiresPayment = null) {
const paymentRequired = requiresPayment === null
? this.requiresPayment
: requiresPayment !== false;
if (this.needsTopupFor(paymentRequired)) {
event?.preventDefault();
this.openTopup();
return;
}
const target = href ?? this.href;
if (target) {
window.location.href = target;
}
},
submitOrTopup(event, form) {
if (this.needsTopup()) {
event?.preventDefault();
this.openTopup();
return;
}
if (event?.type === 'submit') {
return;
}
const targetForm = form || event?.target?.closest?.('form') || this.$refs?.createForm;
targetForm?.requestSubmit();
},
}));
});
</script>
@include('partials.afia')
@include('partials.sso-keepalive')
@include('partials.confirm-prompt')
</body>
</html>
@@ -1,90 +0,0 @@
@extends('mail.notifications.layout')
@section('email-header')
@include('mail.partials.brand-header', ['brand' => 'domains'])
@endsection
@section('email-footer')
@include('mail.partials.brand-footer', ['brand' => 'domains'])
@endsection
@section('content')
<div class="highlight-box highlight-box-success">
<p class="highlight-box-text">Domain Verified!</p>
<p class="highlight-box-subtext">{{ $domain->host }} is now active</p>
</div>
<h1 class="email-title">Your Domain is Ready</h1>
<p class="email-text">
Great news! Your domain <strong>{{ $domain->host }}</strong> has been successfully verified
and is now fully active on Ladill.
</p>
<div class="email-details">
<p class="email-details-title">What's Been Set Up</p>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 12px 0; border-bottom: 1px solid #e2e8f0;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td width="32" valign="top">
<span style="display: inline-block; width: 24px; height: 24px; background-color: #dcfce7; border-radius: 50%; text-align: center; line-height: 24px; color: #166534;"></span>
</td>
<td>
<strong style="color: #0f172a;">DNS Management</strong><br>
<span style="color: #64748b; font-size: 14px;">Your DNS records are now managed by Ladill</span>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding: 12px 0; border-bottom: 1px solid #e2e8f0;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td width="32" valign="top">
<span style="display: inline-block; width: 24px; height: 24px; background-color: #dcfce7; border-radius: 50%; text-align: center; line-height: 24px; color: #166534;"></span>
</td>
<td>
<strong style="color: #0f172a;">Email Ready</strong><br>
<span style="color: #64748b; font-size: 14px;">You can now create professional email addresses</span>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding: 12px 0;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td width="32" valign="top">
<span style="display: inline-block; width: 24px; height: 24px; background-color: #dbeafe; border-radius: 50%; text-align: center; line-height: 24px; color: #1e40af;"></span>
</td>
<td>
<strong style="color: #0f172a;">SSL Certificate</strong><br>
<span style="color: #64748b; font-size: 14px;">Being provisioned automatically (usually within minutes)</span>
</td>
</tr>
</table>
</td>
</tr>
</table>
</div>
<p class="email-text">
<strong>What's next?</strong> You can now:
</p>
<ul style="color: #475569; font-size: 16px; line-height: 1.8; padding-left: 20px;">
<li>Create email addresses like <strong>hello@{{ $domain->host }}</strong></li>
<li>Connect this domain to your website</li>
<li>Manage DNS records from your dashboard</li>
</ul>
<p style="text-align: center; margin-top: 32px;">
<a href="{{ $manageUrl }}" class="email-button">Manage Domain</a>
<br>
<a href="{{ $emailUrl }}" class="email-button-secondary" style="margin-top: 12px;">Create Email Address</a>
</p>
@endsection
@@ -1,37 +0,0 @@
@extends('mail.notifications.layout')
@section('email-header')
@include('mail.partials.brand-header', ['brand' => 'events'])
@endsection
@section('email-footer')
@include('mail.partials.brand-footer', ['brand' => 'events'])
@endsection
@section('content')
<div class="highlight-box highlight-box-success">
<p class="highlight-box-text">📋 Programme Outline</p>
<p class="highlight-box-subtext">{{ $eventName }}</p>
</div>
<h1 class="email-title">Here's the programme{{ $attendeeName ? ', ' . explode(' ', $attendeeName)[0] : '' }}!</h1>
<p class="email-text">
The organiser of <strong>{{ $eventName }}</strong> has shared the event programme outline with you.
Tap below to view the full schedule.
</p>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin:24px 0;">
<tr>
<td align="center">
<a href="{{ $programmeUrl }}" class="email-button" style="display:inline-block;background:#4f46e5;color:#ffffff;text-decoration:none;padding:12px 28px;border-radius:10px;font-size:14px;font-weight:600;">
View programme
</a>
</td>
</tr>
</table>
<p class="email-text" style="font-size:13px;color:#6b7280;">
Or open this link: <a href="{{ $programmeUrl }}" style="color:#4f46e5;">{{ $programmeUrl }}</a>
</p>
@endsection
@@ -1,64 +0,0 @@
@extends('mail.notifications.layout')
@section('email-header')
@include('mail.partials.brand-header', ['brand' => 'hosting'])
@endsection
@section('email-footer')
@include('mail.partials.brand-footer', ['brand' => 'hosting'])
@endsection
@section('content')
<h1 class="email-title">Your hosting is now active! 🖥️</h1>
<p class="email-text">
Your hosting plan has been activated and is ready to use. You can now start uploading files,
creating databases, and hosting your websites.
</p>
<div class="highlight-box">
<p class="highlight-box-text">{{ $planName }}</p>
<p class="highlight-box-subtext">Your hosting is live and ready</p>
</div>
<div class="email-details">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 10px 0; border-bottom: 1px solid #e2e8f0;">
<span style="color: #64748b; font-size: 14px;">Plan</span><br>
<strong style="color: #0f172a;">{{ $planName }}</strong>
</td>
</tr>
@if (!empty($domainName))
<tr>
<td style="padding: 10px 0; border-bottom: 1px solid #e2e8f0;">
<span style="color: #64748b; font-size: 14px;">Domain</span><br>
<strong style="color: #0f172a;">{{ $domainName }}</strong>
</td>
</tr>
@endif
<tr>
<td style="padding: 10px 0; border-bottom: 1px solid #e2e8f0;">
<span style="color: #64748b; font-size: 14px;">Activation Date</span><br>
<strong style="color: #0f172a;">{{ $activationDate }}</strong>
</td>
</tr>
<tr>
<td style="padding: 10px 0;">
<span style="color: #64748b; font-size: 14px;">Status</span><br>
<span class="status-badge status-success">Active</span>
</td>
</tr>
</table>
</div>
<p style="text-align: center;">
<a href="{{ $manageUrl }}" class="email-button">Manage Hosting</a>
</p>
<div class="email-divider"></div>
<p class="email-text" style="font-size: 14px;">
<strong>Getting started:</strong> Access your control panel to manage files, databases, email accounts, and more.
</p>
@endsection
@@ -1,47 +0,0 @@
@extends('mail.notifications.layout')
@section('email-header')
@include('mail.partials.brand-header', ['brand' => 'hosting'])
@endsection
@section('email-footer')
@include('mail.partials.brand-footer', ['brand' => 'hosting'])
@endsection
@section('content')
<h1 style="margin: 0 0 16px; font-size: 24px; font-weight: 700; color: #0f172a;">You were added to a hosting team</h1>
<p style="margin: 0 0 16px; color: #475569; font-size: 15px; line-height: 1.7;">
{{ $ownerName }} granted you developer access to the following Ladill hosting environment{{ count($accountLabels) === 1 ? '' : 's' }}:
</p>
<ul style="margin: 0 0 20px; padding-left: 20px; color: #0f172a; font-size: 15px; line-height: 1.8;">
@foreach ($accountLabels as $label)
<li>{{ $label }}</li>
@endforeach
</ul>
<p style="margin: 0 0 16px; color: #475569; font-size: 15px; line-height: 1.7;">
You now have full access to the hosting panel for {{ count($accountLabels) === 1 ? 'this account' : 'these accounts' }} manage files, domains, databases, SSL, cron jobs, and more.
</p>
<p style="margin: 0 0 16px; color: #475569; font-size: 15px; line-height: 1.7;">
To connect via SSH or SFTP, add your public key from the hosting panel <strong>Settings</strong> page after signing in.
</p>
@if ($setupUrl)
<div style="margin: 24px 0;">
<a href="{{ $setupUrl }}" style="display: inline-block; background: #4f46e5; color: #ffffff; text-decoration: none; font-weight: 600; padding: 12px 18px; border-radius: 10px;">Set your password</a>
</div>
<p style="margin: 0 0 16px; color: #64748b; font-size: 14px; line-height: 1.7;">
Use the button above to activate your account before signing in.
</p>
@else
<div style="margin: 24px 0;">
<a href="{{ $loginUrl }}" style="display: inline-block; background: #4f46e5; color: #ffffff; text-decoration: none; font-weight: 600; padding: 12px 18px; border-radius: 10px;">Sign in to Ladill</a>
</div>
@endif
<p style="margin: 0; color: #64748b; font-size: 14px; line-height: 1.7;">
After signing in, open your dashboard here: <a href="{{ $dashboardUrl }}" style="color: #4f46e5;">{{ $dashboardUrl }}</a>
</p>
@endsection
@@ -1,56 +0,0 @@
@extends('mail.notifications.layout')
@section('email-header')
@include('mail.partials.brand-header', ['brand' => 'hosting'])
@endsection
@section('email-footer')
@include('mail.partials.brand-footer', ['brand' => 'hosting'])
@endsection
@section('content')
<div class="highlight-box highlight-box-warning">
<p class="highlight-box-text">Hosting Expiring Soon</p>
<p class="highlight-box-subtext">{{ $account->primary_domain ?: $account->username }}</p>
</div>
<h1 class="email-title">Your Hosting Plan is Expiring Soon</h1>
<p class="email-text">
Your hosting account for <strong>{{ $account->primary_domain ?: $account->username }}</strong> will expire soon.
Renew before the expiry date to keep your website online and avoid service interruption.
</p>
<div class="email-details">
<p class="email-details-title">Account Details</p>
<div class="email-details-row">
<p class="email-details-label">Domain / Account</p>
<p class="email-details-value">{{ $account->primary_domain ?: $account->username }}</p>
</div>
<div class="email-details-row">
<p class="email-details-label">Expiration Date</p>
<p class="email-details-value">{{ $account->expires_at?->format('F j, Y') ?? 'N/A' }}</p>
</div>
<div class="email-details-row">
<p class="email-details-label">Days Remaining</p>
<p class="email-details-value">
<span class="status-badge status-warning">{{ $daysRemaining }} days</span>
</p>
</div>
</div>
<p class="email-text">
<strong>What happens if hosting expires?</strong><br>
Your website will go offline. Your files are kept for a grace period, but the site will not be accessible until you renew.
</p>
<p style="text-align: center; margin-top: 24px;">
<a href="{{ $renewUrl }}" class="email-button">Renew Hosting</a>
</p>
<div class="email-divider"></div>
<p class="email-text-sm">
If you have wallet auto-renew enabled and sufficient balance, your plan may renew automatically on the expiry date.
</p>
@endsection
@@ -1,59 +0,0 @@
@extends('mail.notifications.layout')
@section('email-header')
@include('mail.partials.brand-header', ['brand' => 'hosting'])
@endsection
@section('email-footer')
@include('mail.partials.brand-footer', ['brand' => 'hosting'])
@endsection
@section('content')
<div class="highlight-box highlight-box-warning">
<p class="highlight-box-text">Account Suspended</p>
<p class="highlight-box-subtext">Action Required</p>
</div>
<h1 class="email-title">Your Hosting Account Has Been Suspended</h1>
<p class="email-text">
We regret to inform you that your hosting account has been suspended. This may be due to an overdue payment, terms of service violation, or other issues that require your attention.
</p>
<div class="email-details">
<p class="email-details-title">Account Details</p>
<div class="email-details-row">
<p class="email-details-label">Account</p>
<p class="email-details-value">{{ $account->domain ?? $account->name ?? 'Hosting Account' }}</p>
</div>
<div class="email-details-row">
<p class="email-details-label">Suspended On</p>
<p class="email-details-value">{{ $account->suspended_at?->format('F j, Y') ?? now()->format('F j, Y') }}</p>
</div>
@if($reason ?? null)
<div class="email-details-row">
<p class="email-details-label">Reason</p>
<p class="email-details-value">{{ $reason }}</p>
</div>
@endif
<div class="email-details-row">
<p class="email-details-label">Status</p>
<p class="email-details-value"><span class="status-badge status-warning">Suspended</span></p>
</div>
</div>
<p class="email-text">
<strong>What happens next?</strong><br>
Your website and services are currently offline. To restore access, please resolve the issue by logging into your dashboard or contacting our support team.
</p>
<p style="text-align: center; margin-top: 24px;">
<a href="{{ $dashboardUrl ?? config('app.url') . '/dashboard' }}" class="email-button">Go to Dashboard</a>
</p>
<div class="email-divider"></div>
<p class="email-text-sm">
If you believe this suspension was made in error, please contact our support team immediately. We're here to help resolve this issue as quickly as possible.
</p>
@endsection
@@ -1,404 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>{{ $subject ?? 'Ladill Notification' }}</title>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<style>
body {
margin: 0;
padding: 0;
width: 100%;
background-color: #f8fafc;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.email-wrapper {
width: 100%;
background-color: #f8fafc;
padding: 48px 0;
}
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
}
.email-header {
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
padding: 32px 40px;
text-align: center;
}
.email-logo {
max-height: 28px;
width: auto;
}
.email-logo-transfer {
max-height: 24px;
width: auto;
}
.email-icon-header {
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
padding: 48px 40px;
text-align: center;
}
.email-icon {
width: 64px;
height: 64px;
background-color: rgba(255, 255, 255, 0.15);
border-radius: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
}
.email-icon svg {
width: 32px;
height: 32px;
color: #ffffff;
}
.email-header-title {
color: #ffffff;
font-size: 24px;
font-weight: 700;
margin: 0;
letter-spacing: -0.025em;
}
.email-header-subtitle {
color: rgba(255, 255, 255, 0.8);
font-size: 14px;
margin: 8px 0 0 0;
}
.email-body {
padding: 40px;
}
.email-title {
font-size: 24px;
font-weight: 700;
color: #0f172a;
margin: 0 0 16px 0;
line-height: 1.3;
letter-spacing: -0.025em;
}
.email-text {
font-size: 16px;
line-height: 1.7;
color: #475569;
margin: 0 0 16px 0;
}
.email-text-sm {
font-size: 14px;
line-height: 1.6;
color: #64748b;
margin: 0 0 12px 0;
}
.email-button {
display: inline-block;
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
color: #ffffff !important;
text-decoration: none;
padding: 14px 32px;
border-radius: 10px;
font-size: 14px;
font-weight: 600;
margin: 24px 0;
box-shadow: 0 4px 14px 0 rgba(79, 70, 229, 0.4);
}
.email-button-secondary {
display: inline-block;
background-color: #f1f5f9;
color: #0f172a !important;
text-decoration: none;
padding: 12px 24px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
margin: 8px 4px;
}
.email-divider {
height: 1px;
background: linear-gradient(90deg, transparent, #e2e8f0, transparent);
margin: 32px 0;
}
.email-details {
background-color: #f8fafc;
border-radius: 12px;
padding: 24px;
margin: 24px 0;
border: 1px solid #e2e8f0;
}
.email-details-title {
font-size: 12px;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0 0 16px 0;
}
.email-details-row {
padding: 12px 0;
border-bottom: 1px solid #e2e8f0;
}
.email-details-row:last-child {
border-bottom: none;
padding-bottom: 0;
}
.email-details-row:first-of-type {
padding-top: 0;
}
.email-details-label {
font-size: 13px;
color: #64748b;
margin: 0 0 4px 0;
}
.email-details-value {
font-size: 15px;
font-weight: 600;
color: #0f172a;
margin: 0;
word-break: break-all;
}
.email-card {
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
border-radius: 12px;
padding: 24px;
margin: 24px 0;
border: 1px solid #e2e8f0;
}
.email-card-icon {
width: 48px;
height: 48px;
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
border-radius: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
}
.email-card-icon svg {
width: 24px;
height: 24px;
color: #ffffff;
}
.email-footer {
background-color: #0f172a;
padding: 40px;
text-align: center;
}
.email-footer-text {
font-size: 13px;
color: #94a3b8;
margin: 0 0 8px 0;
line-height: 1.6;
}
.email-footer-link {
color: #a5b4fc;
text-decoration: none;
}
.email-footer-link:hover {
text-decoration: underline;
}
.email-footer-links {
margin: 20px 0;
}
.email-footer-links a {
color: #94a3b8;
text-decoration: none;
font-size: 13px;
margin: 0 12px;
}
.email-footer-links a:hover {
color: #ffffff;
}
.highlight-box {
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
border-radius: 12px;
padding: 28px;
margin: 24px 0;
text-align: center;
}
.highlight-box-text {
color: #ffffff;
font-size: 20px;
font-weight: 700;
margin: 0;
letter-spacing: -0.025em;
}
.highlight-box-subtext {
color: rgba(255, 255, 255, 0.85);
font-size: 14px;
margin: 8px 0 0 0;
}
.highlight-box-success {
background: linear-gradient(135deg, #059669 0%, #10b981 100%);
}
.highlight-box-warning {
background: linear-gradient(135deg, #d97706 0%, #f59e0b 100%);
}
.highlight-box-info {
background: linear-gradient(135deg, #0284c7 0%, #0ea5e9 100%);
}
.status-badge {
display: inline-block;
padding: 6px 14px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.status-success {
background-color: #dcfce7;
color: #166534;
}
.status-warning {
background-color: #fef3c7;
color: #92400e;
}
.status-info {
background-color: #dbeafe;
color: #1e40af;
}
.status-error {
background-color: #fee2e2;
color: #991b1b;
}
.checklist-item {
display: flex;
align-items: flex-start;
padding: 12px 0;
border-bottom: 1px solid #e2e8f0;
}
.checklist-item:last-child {
border-bottom: none;
}
.checklist-icon {
width: 24px;
height: 24px;
background-color: #dcfce7;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 12px;
flex-shrink: 0;
}
.checklist-icon svg {
width: 14px;
height: 14px;
color: #166534;
}
.checklist-content {
flex: 1;
}
.checklist-title {
font-size: 14px;
font-weight: 600;
color: #0f172a;
margin: 0 0 2px 0;
}
.checklist-desc {
font-size: 13px;
color: #64748b;
margin: 0;
}
.code-block {
background-color: #1e293b;
border-radius: 8px;
padding: 16px 20px;
margin: 16px 0;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 14px;
color: #e2e8f0;
word-break: break-all;
}
.amount-large {
font-size: 36px;
font-weight: 700;
color: #0f172a;
letter-spacing: -0.025em;
}
.amount-currency {
font-size: 18px;
color: #64748b;
font-weight: 500;
}
@media only screen and (max-width: 620px) {
.email-wrapper {
padding: 24px 16px;
}
.email-container {
border-radius: 12px;
}
.email-header,
.email-icon-header {
padding: 24px;
}
.email-body,
.email-footer {
padding: 28px 24px;
}
.email-title {
font-size: 20px;
}
.email-header-title {
font-size: 20px;
}
.amount-large {
font-size: 28px;
}
}
</style>
</head>
<body>
<div class="email-wrapper">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center">
<div class="email-container">
{{-- Header with Logo --}}
<div class="email-header">
@hasSection('email-header')
@yield('email-header')
@else
@include('mail.partials.brand-header', ['brand' => 'ladill'])
@endif
</div>
@hasSection('icon-header')
@yield('icon-header')
@endif
{{-- Email Body --}}
<div class="email-body">
@yield('content')
</div>
{{-- Footer --}}
<div class="email-footer">
@hasSection('email-footer')
@yield('email-footer')
@else
@include('mail.partials.brand-footer', ['brand' => 'ladill'])
@endif
</div>
</div>
</td>
</tr>
</table>
</div>
</body>
</html>
@@ -1,64 +0,0 @@
@extends('mail.notifications.layout')
@section('email-header')
@include('mail.partials.brand-header', ['brand' => 'hosting'])
@endsection
@section('email-footer')
@include('mail.partials.brand-footer', ['brand' => 'hosting'])
@endsection
@section('content')
<div class="highlight-box highlight-box-warning">
<p class="highlight-box-text">⚠️ SSL Certificate Expiring</p>
<p class="highlight-box-subtext">{{ $daysUntilExpiry }} days remaining</p>
</div>
<h1 class="email-title">Action May Be Required</h1>
<p class="email-text">
Your SSL certificate for <strong>{{ $domain->host }}</strong> is expiring soon.
While we attempt to auto-renew certificates, please verify your domain is still properly configured.
</p>
<div class="email-details">
<p class="email-details-title">Certificate Details</p>
<div class="email-details-row">
<p class="email-details-label">Domain</p>
<p class="email-details-value">{{ $domain->host }}</p>
</div>
<div class="email-details-row">
<p class="email-details-label">Status</p>
<p class="email-details-value"><span class="status-badge status-warning">Expiring Soon</span></p>
</div>
<div class="email-details-row">
<p class="email-details-label">Expires</p>
<p class="email-details-value">{{ $expiresAt ? $expiresAt->format('F j, Y') : 'Unknown' }}</p>
</div>
<div class="email-details-row">
<p class="email-details-label">Days Remaining</p>
<p class="email-details-value">{{ $daysUntilExpiry }} days</p>
</div>
</div>
<p class="email-text">
<strong>What you should check:</strong>
</p>
<ul style="color: #475569; font-size: 16px; line-height: 1.8; padding-left: 20px;">
<li>Ensure your domain's nameservers still point to Ladill</li>
<li>Verify your domain hasn't expired at your registrar</li>
<li>Check that your website is accessible</li>
</ul>
<p style="text-align: center; margin-top: 32px;">
<a href="{{ $manageUrl }}" class="email-button">Check Domain Settings</a>
</p>
<div class="email-divider"></div>
<p class="email-text-sm">
If everything looks correct, no action is needed. We'll automatically attempt to renew your certificate.
If renewal fails, we'll notify you immediately.
</p>
@endsection
@@ -1,64 +0,0 @@
@extends('mail.notifications.layout')
@section('email-header')
@include('mail.partials.brand-header', ['brand' => 'hosting'])
@endsection
@section('email-footer')
@include('mail.partials.brand-footer', ['brand' => 'hosting'])
@endsection
@section('content')
<div class="highlight-box highlight-box-success">
<p class="highlight-box-text">🔒 SSL Certificate Active</p>
<p class="highlight-box-subtext">{{ $domain->host }} is now secure</p>
</div>
<h1 class="email-title">Your Site is Secure</h1>
<p class="email-text">
Your SSL certificate for <strong>{{ $domain->host }}</strong> has been successfully provisioned.
Your website is now accessible via HTTPS and visitors will see the secure padlock icon.
</p>
<div class="email-details">
<p class="email-details-title">Certificate Details</p>
<div class="email-details-row">
<p class="email-details-label">Domain</p>
<p class="email-details-value">{{ $domain->host }}</p>
</div>
<div class="email-details-row">
<p class="email-details-label">Status</p>
<p class="email-details-value"><span class="status-badge status-success">Active</span></p>
</div>
<div class="email-details-row">
<p class="email-details-label">Expires</p>
<p class="email-details-value">{{ $expiresAt ? $expiresAt->format('F j, Y') : 'Auto-renewing' }}</p>
</div>
<div class="email-details-row">
<p class="email-details-label">Secure URL</p>
<p class="email-details-value"><a href="{{ $websiteUrl }}" style="color: #4f46e5;">{{ $websiteUrl }}</a></p>
</div>
</div>
<p class="email-text">
<strong>What this means:</strong>
</p>
<ul style="color: #475569; font-size: 16px; line-height: 1.8; padding-left: 20px;">
<li>All traffic to your site is encrypted</li>
<li>Visitors see a secure padlock in their browser</li>
<li>Better search engine rankings (Google prefers HTTPS)</li>
<li>Certificate auto-renews before expiry</li>
</ul>
<p style="text-align: center; margin-top: 32px;">
<a href="{{ $websiteUrl }}" class="email-button">Visit Your Secure Site</a>
</p>
<div class="email-divider"></div>
<p class="email-text-sm">
Your SSL certificate will automatically renew before it expires. No action is required from you.
</p>
@endsection
@@ -1,20 +0,0 @@
@php
$brandKey = $brand ?? 'ladill';
$brandConfig = config("mail_brands.brands.{$brandKey}", config('mail_brands.brands.ladill'));
$appBase = rtrim((string) ($brandConfig['app_url'] ?? config('app.url')), '/');
$dashboardPath = $brandConfig['dashboard_path'] ?? '/';
$accountBase = rtrim((string) ($brandConfig['account_url'] ?? config('mail_brands.account_url', 'https://account.ladill.com')), '/');
$supportUrl = $brandConfig['support_url'] ?? $accountBase.'/support-tickets';
$homeLabel = $brandConfig['home_label'] ?? parse_url($appBase, PHP_URL_HOST) ?: 'ladill.com';
@endphp
<p class="email-footer-text">
You're receiving this email because you have {{ $brandConfig['footer_account'] ?? 'an account with Ladill' }}.
</p>
<div class="email-footer-links">
<a href="{{ $appBase }}{{ $dashboardPath }}">Dashboard</a>
<a href="{{ $supportUrl }}">Support</a>
<a href="{{ $appBase }}">{{ $homeLabel }}</a>
</div>
<p class="email-footer-text" style="margin-top: 20px; color: #64748b;">
&copy; {{ date('Y') }} Ladill Technologies. All rights reserved.
</p>
@@ -1,7 +0,0 @@
@php
$brandKey = $brand ?? 'ladill';
$brandConfig = config("mail_brands.brands.{$brandKey}", config('mail_brands.brands.ladill'));
$assetBase = rtrim((string) ($brandConfig['asset_url'] ?? config('app.url')), '/');
$logoClass = trim((string) ($brandConfig['logo_class'] ?? 'email-logo'));
@endphp
<img src="{{ $assetBase }}/images/logo/{{ $brandConfig['logo'] }}" alt="{{ $brandConfig['name'] }}" class="{{ $logoClass }}">
@@ -1,10 +1,6 @@
@extends('layouts.hosting')
@section('title', 'Notifications')
@section('content')
<x-app-layout title="Notifications">
@include('notifications._list', [
'subtitle' => 'Hosting activity for your account.',
'emptyMessage' => "You're all caught up. We'll notify you when something happens with your hosting.",
'subtitle' => 'Activity for your account.',
'emptyMessage' => "You're all caught up. We'll notify you when something needs your attention.",
])
@endsection
</x-app-layout>
@@ -1,24 +0,0 @@
@props([
'compact' => false,
'handler' => 'dispatch',
])
@auth
<button type="button"
@if ($handler === 'openAfia')
@click="openAfia()"
@else
@click="window.dispatchEvent(new CustomEvent('afia-open'))"
@endif
@class([
'inline-flex shrink-0 items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-indigo-700 via-blue-600 to-emerald-400 text-sm font-semibold text-white shadow-sm transition hover:opacity-95',
'h-9 w-9 px-0 lg:h-auto lg:w-auto lg:px-4 lg:py-2' => $compact,
'px-3 py-2 lg:px-4' => ! $compact,
])
aria-label="Open Afia AI assistant">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 2.25c.414 0 .75.336.75.75a5.25 5.25 0 0 0 5.25 5.25.75.75 0 0 1 0 1.5A5.25 5.25 0 0 0 12.75 15a.75.75 0 0 1-1.5 0A5.25 5.25 0 0 0 6 9.75a.75.75 0 0 1 0-1.5A5.25 5.25 0 0 0 11.25 3a.75.75 0 0 1 .75-.75Zm6.75 11.25a.75.75 0 0 1 .75.75A2.25 2.25 0 0 0 21.75 16.5a.75.75 0 0 1 0 1.5A2.25 2.25 0 0 0 19.5 20.25a.75.75 0 0 1-1.5 0A2.25 2.25 0 0 0 15.75 18a.75.75 0 0 1 0-1.5A2.25 2.25 0 0 0 18 14.25a.75.75 0 0 1 .75-.75ZM5.25 14.25a.75.75 0 0 1 .75.75 3 3 0 0 0 3 3 .75.75 0 0 1 0 1.5 3 3 0 0 0-3 3 .75.75 0 0 1-1.5 0 3 3 0 0 0-3-3 .75.75 0 0 1 0-1.5 3 3 0 0 0 3-3 .75.75 0 0 1 .75-.75Z"/>
</svg>
<span @class(['sr-only lg:not-sr-only lg:inline' => $compact, 'hidden sm:inline' => ! $compact])>AI</span>
</button>
@endauth
-106
View File
@@ -1,106 +0,0 @@
@php
$afiaGreeting = "Hi, I'm Afia 👋 Ask me about payment QRs — creating one, accepting payments, payouts, or the 3.5% platform fee…";
$afiaSuggestions = [
'How do I create a payment QR?',
'How do customers pay me?',
'When do payouts reach my wallet?',
'What is the platform fee?',
];
@endphp
{{-- Afia Ladill AI assistant slide-over. Opened via $dispatch('afia-open'). --}}
<div x-data="afia({
chatUrl: '{{ route('mini.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
x-transition:enter="transition transform ease-out duration-200"
x-transition:enter-start="translate-x-full" x-transition:enter-end="translate-x-0"
x-transition:leave="transition transform ease-in duration-150"
x-transition:leave-start="translate-x-0" x-transition:leave-end="translate-x-full"
class="fixed inset-y-0 right-0 z-50 flex w-full max-w-md flex-col bg-white shadow-2xl">
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-3.5">
<div class="flex items-center gap-2.5">
<span class="relative flex h-8 w-8 shrink-0 items-center justify-center">
<svg viewBox="0 0 36 36" class="h-8 w-8" aria-hidden="true">
<defs>
<radialGradient id="afia-orb" cx="40%" cy="35%" r="60%">
<stop offset="0%" stop-color="#a5b4fc"/>
<stop offset="50%" stop-color="#6366f1"/>
<stop offset="100%" stop-color="#3730a3"/>
</radialGradient>
</defs>
<circle cx="18" cy="18" r="14" fill="url(#afia-orb)">
<animate attributeName="r" values="14;14.8;14" dur="3s" repeatCount="indefinite"/>
</circle>
<circle cx="18" cy="18" r="11" fill="none" stroke="#c7d2fe" stroke-width=".6" opacity=".5">
<animate attributeName="r" values="11;12.5;11" dur="4s" repeatCount="indefinite"/>
<animate attributeName="opacity" values=".5;.15;.5" dur="4s" repeatCount="indefinite"/>
</circle>
<circle r="1.2" fill="#c7d2fe" opacity=".8">
<animateMotion dur="3s" repeatCount="indefinite" path="M18,8 A10,10 0 1,1 17.99,8" rotate="auto"/>
</circle>
</svg>
<span class="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border-2 border-white bg-emerald-400"></span>
</span>
<div class="leading-tight">
<p class="text-sm font-semibold text-slate-900">Afia</p>
<p class="text-[11px] text-slate-400">Mini assistant</p>
</div>
</div>
<div class="flex items-center gap-1">
<a href="{{ ladill_account_url('ai') }}"
class="rounded-lg px-2 py-1 text-[11px] font-medium text-slate-500 hover:bg-slate-100 hover:text-slate-700"
title="AI usage history on your Ladill account">History</a>
<button @click="close()" class="rounded-lg p-1.5 text-slate-400 hover:bg-slate-100 hover:text-slate-600">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/></svg>
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto px-4 py-4" x-ref="scroll">
<div class="space-y-3">
<template x-for="(m, i) in messages" :key="i">
<div class="flex" :class="m.role === 'user' ? 'justify-end' : 'justify-start'">
<div class="max-w-[85%] whitespace-pre-line rounded-2xl px-3.5 py-2.5 text-[13px] leading-relaxed"
:class="m.role === 'user' ? 'bg-indigo-600 text-white rounded-br-md' : 'bg-slate-100 text-slate-700 rounded-bl-md'"
x-html="m.text.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\*\*([^*]+?)\*\*/g,'<strong>$1</strong>')"></div>
</div>
</template>
<div x-show="loading" class="flex justify-start">
<div class="rounded-2xl rounded-bl-md bg-slate-100 px-4 py-3">
<div class="flex gap-1">
<span class="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400" style="animation-delay:0ms"></span>
<span class="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400" style="animation-delay:150ms"></span>
<span class="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400" style="animation-delay:300ms"></span>
</div>
</div>
</div>
</div>
<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">
<span class="text-indigo-400"></span><span x-text="s"></span>
</button>
</template>
</div>
</div>
<div class="border-t border-slate-100 px-4 pb-4 pt-3">
<form @submit.prevent="send()" class="flex items-end gap-2">
<input type="text" x-ref="input" x-model="input" :disabled="loading" placeholder="Message Afia…"
class="flex-1 rounded-xl border border-slate-200 bg-slate-50 px-4 py-2.5 text-sm text-slate-700 placeholder-slate-400 focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
<button type="submit" :disabled="loading || !input.trim()"
class="btn-fab shrink-0 disabled:opacity-40">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 10.5 12 3m0 0 7.5 7.5M12 3v18"/></svg>
</button>
</form>
<p class="mt-2 text-center text-[10px] text-slate-400">Afia can make mistakes verify important details.</p>
</div>
</section>
</div>
@@ -1,65 +0,0 @@
<div
x-cloak
x-show="$store.ladillConfirm.open"
x-transition:enter="transition-opacity ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition-opacity ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 z-[60] flex items-end sm:items-center sm:justify-center"
style="background: rgba(0,0,0,0.45); backdrop-filter: blur(3px);"
@keydown.escape.window="$store.ladillConfirm.answer(false)"
>
<div class="absolute inset-0" @click="$store.ladillConfirm.answer(false)"></div>
<div
x-show="$store.ladillConfirm.open"
class="relative z-10 w-full max-h-[90vh] overflow-y-auto rounded-t-3xl bg-white shadow-2xl sm:mb-6 sm:max-h-[85vh] sm:max-w-md sm:rounded-2xl sm:border sm:border-gray-200"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="translate-y-full sm:translate-y-0 sm:opacity-0 sm:scale-95"
x-transition:enter-end="translate-y-0 sm:opacity-100 sm:scale-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="translate-y-0 sm:opacity-100 sm:scale-100"
x-transition:leave-end="translate-y-full sm:translate-y-0 sm:opacity-0 sm:scale-95"
@click.stop
>
<div class="flex justify-center pb-1 pt-3 sm:hidden">
<div class="h-1 w-10 rounded-full bg-slate-200"></div>
</div>
<div class="px-5 pb-6 pt-2 sm:px-6 sm:pb-6 sm:pt-4">
<div class="flex flex-col items-center text-center sm:items-start sm:text-left">
<div class="flex h-12 w-12 items-center justify-center rounded-full"
:class="$store.ladillConfirm.variant === 'primary' ? 'bg-violet-100 text-violet-600' : 'bg-red-100 text-red-600'">
<template x-if="$store.ladillConfirm.variant === 'primary'">
<svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 5.25h.008v.008H12v-.008Z"/>
</svg>
</template>
<template x-if="$store.ladillConfirm.variant !== 'primary'">
<svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"/>
</svg>
</template>
</div>
<h2 class="mt-4 text-lg font-semibold text-slate-900" x-text="$store.ladillConfirm.title"></h2>
<p x-show="$store.ladillConfirm.message" class="mt-2 whitespace-pre-line text-sm leading-relaxed text-slate-500" x-text="$store.ladillConfirm.message"></p>
</div>
<div class="mt-6 flex flex-col-reverse gap-2.5 sm:flex-row sm:justify-end">
<button type="button"
@click="$store.ladillConfirm.answer(false)"
class="rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 transition hover:bg-slate-50"
x-text="$store.ladillConfirm.cancelLabel">
</button>
<button type="button"
@click="$store.ladillConfirm.answer(true)"
class="rounded-xl px-4 py-2.5 text-sm font-semibold text-white transition"
:class="$store.ladillConfirm.variant === 'primary' ? 'bg-violet-600 hover:bg-violet-700' : 'bg-red-600 hover:bg-red-700'"
x-text="$store.ladillConfirm.confirmLabel">
</button>
</div>
</div>
</div>
</div>
+3 -5
View File
@@ -1,8 +1,6 @@
@php
$svgVer = @filemtime(public_path('favicon.svg')) ?: '1';
$icoVer = @filemtime(public_path('favicon.ico')) ?: '1';
@endphp
<link rel="icon" type="image/svg+xml" href="{{ asset('favicon.svg') }}?v={{ $svgVer }}">
<link rel="alternate icon" href="{{ asset('favicon.ico') }}?v={{ $icoVer }}" sizes="any">
<link rel="shortcut icon" href="{{ asset('favicon.svg') }}?v={{ $svgVer }}">
<link rel="apple-touch-icon" href="{{ asset('favicon.svg') }}?v={{ $svgVer }}">
<link rel="icon" href="{{ asset('favicon.ico') }}?v={{ $icoVer }}" sizes="any">
<link rel="shortcut icon" href="{{ asset('favicon.ico') }}?v={{ $icoVer }}">
<link rel="apple-touch-icon" href="{{ asset('favicon.ico') }}?v={{ $icoVer }}">
@@ -1,2 +0,0 @@
@props(['class' => 'h-5 w-5'])
<img src="{{ asset('images/ladill-icons/pro.svg') }}?v={{ @filemtime(public_path('images/ladill-icons/pro.svg')) ?: '1' }}" alt="" @class([$class]) width="14" height="14">
@@ -1,46 +0,0 @@
@if(($mailboxLinkReminder['visible'] ?? false) === true)
@php
$stage = $mailboxLinkReminder['stage'] ?? 'needs_link';
$copy = match ($stage) {
'needs_domain' => [
'title' => 'Set up Ladill Email',
'body' => 'Add and verify an email domain before you can create a mailbox and link it to your Ladill account.',
'cta' => 'Add email domain',
'url' => route('email.domains.index'),
],
'needs_mailbox' => [
'title' => 'Create your Ladill mailbox',
'body' => 'Your domain is ready. Create a mailbox, then link it in Settings so Ladill Mail opens with your Ladill sign-in.',
'cta' => 'Create mailbox',
'url' => route('email.mailboxes.create'),
],
default => [
'title' => 'Link your Ladill mailbox',
'body' => 'Your account uses '.$mailboxLinkReminder['account_email'].' — choose a mailbox in Settings to connect Ladill Mail sign-in.',
'cta' => 'Link mailbox',
'url' => route('account.settings'),
],
};
@endphp
<div class="mb-4 rounded-xl border border-indigo-200 bg-gradient-to-r from-indigo-50 to-sky-50 px-4 py-3.5 text-sm text-slate-800 shadow-sm" role="status">
<div class="flex items-start gap-3">
<span class="mt-0.5 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-white shadow-sm" aria-hidden="true">
@include('partials.ladill-pro-icon', ['class' => 'h-4 w-4'])
</span>
<div class="min-w-0 flex-1">
<p class="font-semibold text-slate-900">{{ $copy['title'] }}</p>
<p class="mt-1 leading-relaxed text-slate-600">{{ $copy['body'] }}</p>
<a href="{{ $copy['url'] }}" class="mt-2.5 inline-flex items-center gap-1 text-sm font-semibold text-indigo-700 hover:text-indigo-800">
{{ $copy['cta'] }}
<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>
</div>
<form method="POST" action="{{ route('account.mailbox-link-banner.dismiss') }}" class="shrink-0">
@csrf
<button type="submit" class="rounded-lg p-1.5 text-slate-400 transition hover:bg-white/70 hover:text-slate-600" aria-label="Dismiss for now">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/></svg>
</button>
</form>
</div>
</div>
@endif
@@ -1,3 +0,0 @@
<div class="shrink-0 border-t border-slate-100 bg-slate-50/80 px-4 py-2.5 text-center">
<p class="text-[11px] text-slate-400">Secured by Paystack. Powered by Ladill Pay</p>
</div>
@@ -1,15 +0,0 @@
<div class="relative shrink-0 border-b border-slate-100 px-4 pb-3 pt-3">
<div class="absolute left-1/2 top-2 h-1 w-10 -translate-x-1/2 rounded-full bg-slate-200 md:hidden"></div>
<div class="flex items-center justify-between gap-3 pt-2 md:pt-0">
<img src="{{ asset('images/logo/ladillmini-logo.svg') }}?v={{ @filemtime(public_path('images/logo/ladillmini-logo.svg')) ?: '1' }}"
alt="Ladill Mini" class="h-5 w-auto shrink-0">
<button type="button"
@click="showSheet = false"
class="shrink-0 rounded-full p-1.5 text-slate-400 transition hover:bg-slate-100 hover:text-slate-700"
aria-label="Close">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/>
</svg>
</button>
</div>
</div>
@@ -1,123 +0,0 @@
@php
$gridCols = !empty($centerCompose) ? 'grid-cols-5' : 'grid-cols-4';
$avatarUrl = $avatarUrl ?? null;
$initials = $initials ?? 'U';
$notificationsUrl = $notificationsUrl ?? '#';
$unreadUrl = $unreadUrl ?? null;
$profileUrl = $profileUrl ?? '#';
$profileName = trim((string) ($profileName ?? ''));
$profileSubtitle = trim((string) ($profileSubtitle ?? ''));
$profileMenuItems = $profileMenuItems ?? [];
if ($profileMenuItems === [] && $profileUrl !== '#') {
$profileMenuItems = [['type' => 'link', 'label' => 'Profile', 'href' => $profileUrl]];
}
$navActive = fn (bool $active) => $active ? 'text-indigo-600' : 'text-slate-600';
@endphp
<div x-data="{ profileOpen: false }" class="lg:hidden">
<nav class="fixed inset-x-0 bottom-0 z-40 border-t border-slate-200 bg-white"
style="padding-bottom: env(safe-area-inset-bottom, 0px)">
<div class="grid h-16 {{ $gridCols }}">
<a href="{{ $homeUrl }}"
class="flex flex-col items-center justify-center gap-1 px-2 py-2 transition hover:text-slate-900 {{ $navActive($homeActive ?? false) }}">
<svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"/></svg>
<span class="text-[10px] font-medium">Home</span>
</a>
<a href="{{ $searchUrl }}"
class="flex flex-col items-center justify-center gap-1 px-2 py-2 transition hover:text-slate-900 {{ $navActive($searchActive ?? false) }}">
<svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"/></svg>
<span class="text-[10px] font-medium">Search</span>
</a>
@if (!empty($centerCompose))
<div class="flex items-center justify-center">
<button type="button"
@click="$dispatch('compose-open')"
class="btn-fab -translate-y-1 ring-4 ring-white shadow-md"
aria-label="Compose">
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Z"/></svg>
</button>
</div>
@endif
<a href="{{ $notificationsUrl }}"
@if ($unreadUrl)
x-data="{
unread: 0,
init() {
fetch({{ \Illuminate\Support\Js::from($unreadUrl) }}, {
headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
}).then(r => r.json()).then(d => this.unread = d.unread_count || 0).catch(() => {});
},
}"
@endif
class="relative flex flex-col items-center justify-center gap-1 px-2 py-2 transition hover:text-slate-900 {{ $navActive($notificationsActive ?? false) }}">
<svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9a6 6 0 1 0-12 0v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.08 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0"/></svg>
@if ($unreadUrl)
<span x-show="unread > 0" x-cloak x-text="unread > 9 ? '9+' : unread"
class="absolute right-3 top-1.5 inline-flex min-w-4 items-center justify-center rounded-full bg-rose-500 px-1 py-0.5 text-[9px] font-semibold text-white"></span>
@endif
<span class="text-[10px] font-medium">Notifications</span>
</a>
<button type="button"
@click="profileOpen = true"
class="flex flex-col items-center justify-center gap-1 px-2 py-2 transition hover:text-slate-900 {{ $navActive($profileActive ?? false) }}"
:class="profileOpen ? 'text-indigo-600' : ''"
aria-label="Open profile menu">
@if ($avatarUrl)
<img src="{{ $avatarUrl }}" alt="" class="h-7 w-7 rounded-full object-cover ring-1 ring-slate-200">
@else
<span class="inline-flex h-7 w-7 items-center justify-center rounded-full bg-slate-900 text-[10px] font-semibold text-white">{{ $initials }}</span>
@endif
<span class="text-[10px] font-medium">Profile</span>
</button>
</div>
</nav>
{{-- Profile menu bottom sheet (matches desktop avatar dropdown) --}}
<div x-show="profileOpen" x-cloak class="fixed inset-0 z-50" @keydown.escape.window="profileOpen = false">
<div x-show="profileOpen" x-transition.opacity.duration.200ms
@click="profileOpen = false"
class="absolute inset-0 bg-slate-900/40 backdrop-blur-[1px]"></div>
<div x-show="profileOpen"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="translate-y-full"
x-transition:enter-end="translate-y-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="translate-y-0"
x-transition:leave-end="translate-y-full"
class="absolute inset-x-0 bottom-0 rounded-t-2xl border-t border-slate-200 bg-white shadow-2xl"
style="padding-bottom: env(safe-area-inset-bottom, 0px)"
@click.outside="profileOpen = false">
<div class="flex justify-center pt-3">
<span class="h-1 w-10 rounded-full bg-slate-200" aria-hidden="true"></span>
</div>
@if ($profileName !== '' || $profileSubtitle !== '')
<div class="flex items-center gap-3 border-b border-slate-100 px-5 py-4">
@if ($avatarUrl)
<img src="{{ $avatarUrl }}" alt="" class="h-12 w-12 rounded-full object-cover ring-1 ring-slate-200">
@else
<span class="inline-flex h-12 w-12 items-center justify-center rounded-full bg-slate-900 text-sm font-semibold text-white">{{ $initials }}</span>
@endif
<div class="min-w-0">
@if ($profileName !== '')
<p class="truncate text-sm font-semibold text-slate-900">{{ $profileName }}</p>
@endif
@if ($profileSubtitle !== '')
<p class="truncate text-xs text-slate-500">{{ $profileSubtitle }}</p>
@endif
</div>
</div>
@endif
@include('partials.user-profile-menu', [
'items' => $profileMenuItems,
'variant' => 'sheet',
'onNavigate' => 'profileOpen = false',
])
</div>
</div>
</div>
@@ -1,6 +0,0 @@
@php
$title = $mobileTopbarTitle ?? 'Ladill';
@endphp
<div class="min-w-0 flex-1 lg:hidden">
<h1 class="truncate text-base font-semibold text-slate-900">{{ $title }}</h1>
</div>
@@ -1,44 +0,0 @@
{{--
Paystack mobile bottom-sheet iframe.
Requires Alpine.js ancestor with: showSheet (bool), checkoutUrl (string).
On mobile (< md) the sheet slides up; on desktop nothing renders.
--}}
<template x-teleport="body">
<div x-show="showSheet"
x-cloak
class="fixed inset-0 z-[9999] flex flex-col justify-end md:hidden"
role="dialog"
aria-modal="true">
<div class="absolute inset-0 bg-black/60"
x-show="showSheet"
x-transition:enter="transition-opacity duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition-opacity duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@click="showSheet = false">
</div>
<div class="relative flex flex-col overflow-hidden rounded-t-2xl bg-white"
style="height:90dvh; padding-bottom: env(safe-area-inset-bottom, 0px)"
x-show="showSheet"
x-transition:enter="transition-transform duration-300 ease-out"
x-transition:enter-start="translate-y-full"
x-transition:enter-end="translate-y-0"
x-transition:leave="transition-transform duration-200 ease-in"
x-transition:leave-start="translate-y-0"
x-transition:leave-end="translate-y-full">
@include('partials.mini-paystack-frame-header')
<iframe :src="showSheet ? checkoutUrl : ''"
class="min-h-0 flex-1 w-full border-0"
allow="payment"
title="Paystack checkout"></iframe>
@include('partials.mini-paystack-frame-footer')
</div>
</div>
</template>
@@ -1,100 +0,0 @@
@php
$searchUrl = $searchUrl ?? url('/search');
@endphp
<div class="min-h-full bg-white lg:min-h-0 lg:bg-transparent"
x-data="topbarSearch({
searchUrl: @js($searchUrl),
initialQuery: @js($query),
initialResults: @js($results),
openOnInit: @js($query !== ''),
autoFocus: true
})">
<x-mobile-page-header :title="$heading" subtitle="Search" :back-url="$backUrl">
<div class="px-4 pb-3">
<div class="relative">
<svg class="pointer-events-none absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"/></svg>
<input data-ladill-search-input x-ref="input"
type="text"
x-model="query"
@focus="onFocus()"
@input.debounce.200ms="search()"
@keydown.arrow-down.prevent="moveDown()"
@keydown.arrow-up.prevent="moveUp()"
@keydown.enter.prevent="go()"
placeholder="{{ $placeholder }}"
class="w-full rounded-2xl border border-slate-200 bg-slate-50 py-3 pl-12 pr-4 text-sm text-slate-700 placeholder-slate-400 transition focus:border-indigo-300 focus:bg-white focus:ring-2 focus:ring-indigo-100 focus:outline-none">
</div>
</div>
</x-mobile-page-header>
<div class="sticky top-0 z-20 hidden border-b border-slate-200 bg-white/95 backdrop-blur lg:block">
<div class="mx-auto max-w-4xl px-6 py-3">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Search</p>
<h1 class="text-2xl font-semibold text-slate-900">{{ $heading }}</h1>
<div class="relative mt-3">
<svg class="pointer-events-none absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"/></svg>
<input data-ladill-search-input x-ref="input"
type="text"
x-model="query"
@focus="onFocus()"
@input.debounce.200ms="search()"
@keydown.arrow-down.prevent="moveDown()"
@keydown.arrow-up.prevent="moveUp()"
@keydown.enter.prevent="go()"
placeholder="{{ $placeholder }}"
class="w-full rounded-2xl border border-slate-200 bg-slate-50 py-3 pl-12 pr-4 text-sm text-slate-700 placeholder-slate-400 transition focus:border-indigo-300 focus:bg-white focus:ring-2 focus:ring-indigo-100 focus:outline-none">
</div>
</div>
</div>
<div class="mx-auto max-w-4xl px-4 py-4 pb-28 lg:px-6 lg:py-6 lg:pb-6">
<template x-if="loading">
<div class="rounded-2xl border border-slate-200 bg-white px-4 py-8 text-center text-sm text-slate-500 shadow-sm">
Searching…
</div>
</template>
<template x-if="!loading && query.trim().length < 2">
<div class="rounded-2xl border border-dashed border-slate-200 bg-slate-50 px-5 py-10 text-center">
<p class="text-sm font-medium text-slate-900">Start typing to search</p>
<p class="mt-1 text-sm text-slate-500">{{ $emptyHint }}</p>
</div>
</template>
<template x-if="!loading && query.trim().length >= 2 && results.length === 0">
<div class="rounded-2xl border border-slate-200 bg-white px-5 py-10 text-center shadow-sm">
<p class="text-sm font-medium text-slate-900">No results for "<span x-text="query"></span>"</p>
<p class="mt-1 text-sm text-slate-500">{{ $emptyHint }}</p>
</div>
</template>
<template x-if="!loading && results.length > 0">
<div class="space-y-3">
<template x-for="(item, idx) in results" :key="item.url">
<a :href="item.url"
:class="idx === active ? 'border-indigo-200 bg-indigo-50/70 shadow-sm' : 'border-slate-200 bg-white'"
@mouseenter="active = idx"
class="flex items-start gap-4 rounded-2xl border px-4 py-4 transition hover:border-indigo-200 hover:bg-indigo-50/60">
<span class="mt-0.5 flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl"
:class="{
'bg-violet-100 text-violet-600': item.type === 'event',
'bg-indigo-100 text-indigo-600': item.type === 'programme',
'bg-fuchsia-100 text-fuchsia-600': item.type === 'qr',
'bg-teal-100 text-teal-600': item.type === 'hosting',
'bg-orange-100 text-orange-600': item.type === 'order',
'bg-slate-100 text-slate-600': !['event','programme','qr','hosting','order'].includes(item.type),
}">
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"/></svg>
</span>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-semibold text-slate-900" x-text="item.title"></span>
<span class="mt-1 block text-sm text-slate-500" x-text="item.subtitle"></span>
</span>
<svg class="mt-1 h-5 w-5 shrink-0 text-slate-300" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m9 5 7 7-7 7"/></svg>
</a>
</template>
</div>
</template>
</div>
</div>
@@ -1,23 +0,0 @@
@php
$useInternal = $internal ?? false;
if ($useInternal) {
$supportUrl = route('user.support-tickets.index');
$openExternal = false;
} else {
$supportUrl = function_exists('ladill_account_url')
? ladill_account_url('/support-tickets')
: 'https://'.config('app.account_domain', 'account.ladill.com').'/support-tickets';
$openExternal = true;
}
@endphp
<a href="{{ $supportUrl }}"
@if($openExternal) target="_blank" rel="noopener" @endif
class="group flex items-center gap-3 rounded-lg px-3 py-2 text-[13px] text-slate-600 transition hover:bg-slate-50 hover:text-slate-900">
<svg class="h-[18px] w-[18px] shrink-0 text-slate-400 group-hover:text-slate-500" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.712 4.33a9.027 9.027 0 0 1 1.652 1.306c.51.51.944 1.064 1.306 1.652M16.712 4.33l-3.448 4.138m3.448-4.138a9.014 9.014 0 0 0-9.424 0M19.67 7.288l-4.138 3.448m4.138-3.448a9.014 9.014 0 0 1 0 9.424m-4.138-5.976a3.736 3.736 0 0 0-.88-1.388 3.737 3.737 0 0 0-1.388-.88m2.268 2.268a3.765 3.765 0 0 1 0 2.528m-2.268-4.796a3.765 3.765 0 0 0-2.528 0m4.796 4.796c-.181.506-.475.982-.88 1.388a3.736 3.736 0 0 1-1.388.88m2.268-2.268 4.138 3.448m0 0a9.027 9.027 0 0 1-1.306 1.652c-.51.51-1.064.944-1.652 1.306m0 0-3.448-4.138m3.448 4.138a9.014 9.014 0 0 1-9.424 0m5.976-4.138a3.765 3.765 0 0 1-2.528 0m0 0a3.736 3.736 0 0 1-1.388-.88 3.737 3.737 0 0 1-.88-1.388m2.268 2.268L7.288 19.67m0 0a9.024 9.024 0 0 1-1.652-1.306 9.027 9.027 0 0 1-1.306-1.652m0 0 4.138-3.448M4.33 16.712a9.014 9.014 0 0 1 0-9.424m4.138 5.976a3.765 3.765 0 0 1 0-2.528m0 0c.181-.506.475-.982.88-1.388a3.736 3.736 0 0 1 1.388-.88m-2.268 2.268L4.33 7.288m6.406 1.18L7.288 4.33m0 0a9.024 9.024 0 0 0-1.652 1.306A9.025 9.025 0 0 0 4.33 7.288"/>
</svg>
<span class="flex-1 truncate">Support</span>
@if($openExternal)
<span class="text-[10px] text-slate-400" aria-hidden="true"></span>
@endif
</a>
@@ -1,21 +0,0 @@
@if (auth()->check())
@php
$authPing = 'https://'.config('app.auth_domain').'/sso/ping';
$platformSignedOutUrl = route('sso.platform-signed-out', ['redirect' => url()->current()]);
@endphp
<script>
(function () {
const pingUrl = @js($authPing);
const signedOutUrl = @js($platformSignedOutUrl);
const ping = () => fetch(pingUrl, { credentials: 'include' })
.then((response) => {
if (response.status === 401) {
window.location.href = signedOutUrl;
}
})
.catch(() => {});
ping();
setInterval(ping, 5 * 60 * 1000);
})();
</script>
@endif
@@ -1,24 +0,0 @@
{{-- Account switcher (desktop) only when the user belongs to more than one account. --}}
@if (isset($accessibleAccounts) && $accessibleAccounts->count() > 1)
<div x-data="{ accountSwitcherOpen: false }" @click.outside="accountSwitcherOpen = false" class="relative hidden lg:block">
<button type="button" @click="accountSwitcherOpen = !accountSwitcherOpen"
class="inline-flex items-center gap-1.5 rounded-full border border-slate-200 px-3 py-2 text-sm text-slate-700 transition hover:bg-slate-50">
<span class="max-w-[140px] truncate">{{ $actingAccount->name ?? $actingAccount->email }}</span>
<svg class="h-4 w-4 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m19 9-7 7-7-7"/></svg>
</button>
<div x-show="accountSwitcherOpen" x-cloak x-transition
class="absolute right-0 z-30 mt-2 w-60 rounded-xl border border-slate-200 bg-white p-1 shadow-lg">
<p class="px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest text-slate-400">Switch account</p>
@foreach ($accessibleAccounts as $acctOption)
<form method="POST" action="{{ route('account.switch') }}">
@csrf
<input type="hidden" name="account" value="{{ $acctOption->id }}">
<button type="submit" class="flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-50 {{ $acctOption->id === $actingAccount->id ? 'font-semibold text-indigo-700' : 'text-slate-700' }}">
<span class="truncate">{{ $acctOption->id === auth()->id() ? 'My account' : ($acctOption->name ?? $acctOption->email) }}</span>
@if ($acctOption->id === $actingAccount->id)<span class="text-indigo-600"></span>@endif
</button>
</form>
@endforeach
</div>
</div>
@endif
@@ -1,6 +1,6 @@
{{--
Standard desktop top-right widgets (CRM / Invoice layout):
Afia notifications launcher divider avatar dropdown.
Standard desktop top-right widgets:
notifications launcher divider avatar dropdown.
--}}
@php
$topbarUser = $user ?? auth()->user();
@@ -12,8 +12,6 @@
$showUserHeader = $showUser ?? true;
@endphp
@includeIf('partials.afia-button', ['compact' => true])
@includeIf('partials.notification-dropdown')
@include('partials.launcher')
@@ -1,29 +0,0 @@
@php
$user = auth()->user();
$initials = collect(explode(' ', trim((string) $user?->name)))
->filter()
->take(2)
->map(fn ($part) => strtoupper(substr($part, 0, 1)))
->implode('');
@endphp
<header class="flex h-16 items-center justify-between border-b border-slate-200 bg-white px-6">
<div class="flex min-w-0 flex-1 items-center gap-3">
<button @click="sidebarOpen = !sidebarOpen" class="shrink-0 text-slate-500 hover:text-slate-700 lg:hidden">
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg>
</button>
@include('partials.mobile-topbar-title')
</div>
<div class="flex items-center gap-3">
@auth
@includeIf('partials.topbar-account-switcher')
@includeIf('partials.topbar-widgets-prepend')
@include('partials.topbar-desktop-widgets', ['user' => $user ?? $u ?? auth()->user(), 'showUser' => true])
@includeIf('partials.topbar-widgets-append')
@else
<a href="{{ route('login') }}" class="btn-primary">Sign in</a>
@include('partials.launcher')
@endauth
</div>
</header>
@@ -1,2 +0,0 @@
@props(['class' => 'h-8 w-8'])
<img src="{{ asset('images/ladill-icons/wordpress.svg') }}?v={{ @filemtime(public_path('images/ladill-icons/wordpress.svg')) ?: '1' }}" alt="WordPress" @class([$class])>
File diff suppressed because one or more lines are too long
+16
View File
@@ -64,10 +64,19 @@ class PosCommerceTest extends TestCase
[
'name' => 'Imported Mug',
'sku' => 'MUG-1',
'type' => 'product',
'unit_price_minor' => 2500,
'currency' => 'GHS',
'active' => true,
],
[
'name' => 'Consulting Hour',
'sku' => 'SVC-1',
'type' => 'service',
'unit_price_minor' => 9000,
'currency' => 'GHS',
'active' => true,
],
],
], 200),
]);
@@ -83,6 +92,13 @@ class PosCommerceTest extends TestCase
$this->assertNotNull($product);
$this->assertSame('Imported Mug', $product->name);
$this->assertSame(2500, $product->price_minor);
// Services must never be imported as POS products.
$this->assertNull(PosProduct::owned($user->public_id)->where('sku', 'SVC-1')->first());
// The CRM request must constrain to products only.
Http::assertSent(fn ($request) => str_contains($request->url(), 'crm.test/api/products')
&& str_contains($request->url(), 'type=product'));
}
public function test_paid_sale_links_to_invoice_prefill(): void
+1 -1
View File
@@ -46,7 +46,7 @@ class PosRegisterTest extends TestCase
->get(route('pos.dashboard'))
->assertOk()
->assertSee('Overview')
->assertSee('favicon.svg', false);
->assertSee('favicon.ico', false);
}
public function test_register_renders_products(): void