De-fork POS chrome/assets and import CRM products only
Deploy Ladill POS / deploy (push) Successful in 32s
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:
co-authored by
Claude Opus 4.8
parent
46b5091b1e
commit
b7c7a32ee6
@@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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.';
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
];
|
||||
@@ -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', '')),
|
||||
))),
|
||||
|
||||
];
|
||||
@@ -1,6 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'api_url' => env('DOMAIN_API_URL', 'https://ladill.com/api/domains'),
|
||||
'api_key' => env('DOMAIN_API_KEY_HOSTING'),
|
||||
];
|
||||
@@ -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
|
||||
],
|
||||
];
|
||||
@@ -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'),
|
||||
];
|
||||
@@ -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),
|
||||
],
|
||||
];
|
||||
@@ -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',
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -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'),
|
||||
];
|
||||
@@ -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),
|
||||
|
||||
];
|
||||
@@ -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 |
@@ -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,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 <your-token>" \
|
||||
-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 account’s 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
|
||||
@@ -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">You’ve 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>
|
||||
@@ -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>
|
||||
@@ -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;">
|
||||
© {{ 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
|
||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>').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>
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user