Bootstrap Ladill Link from QR Plus extract template.

This commit is contained in:
isaacclad
2026-06-27 10:54:31 +00:00
commit 04e4f6ab51
243 changed files with 26587 additions and 0 deletions
@@ -0,0 +1,324 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\QrTeamMember;
use App\Models\User;
use Illuminate\Http\Client\Response as HttpResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Str;
use Illuminate\View\View;
/**
* "Sign in with Ladill" Authorization Code + PKCE against auth.ladill.com.
* Establishes a local session + thin user mirror keyed by the OIDC `sub`.
*/
class SsoLoginController extends Controller
{
public function connect(Request $request): RedirectResponse|View
{
$intended = (string) $request->query('redirect', route('qr.dashboard'));
if (Auth::check()) {
return $this->safeRedirect($intended, route('qr.dashboard'));
}
if ($this->attemptSilentRefresh($request, $intended)) {
return $this->safeRedirect($intended, route('qr.dashboard'));
}
$verifier = Str::random(64);
$state = Str::random(40);
$request->session()->put('sso.verifier', $verifier);
$request->session()->put('sso.state', $state);
$request->session()->put('sso.intended', $intended);
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
$query = [
'response_type' => 'code',
'client_id' => (string) config('services.ladill_sso.client_id'),
'redirect_uri' => (string) config('services.ladill_sso.redirect'),
'scope' => 'openid profile email',
'state' => $state,
'code_challenge' => $challenge,
'code_challenge_method' => 'S256',
];
$loginHint = (string) $request->session()->get('sso.login_hint', '');
if ($loginHint !== '') {
$query['login_hint'] = $loginHint;
}
if (! $request->boolean('interactive')) {
$query['prompt'] = 'none';
}
$authorizeUrl = rtrim((string) config('services.ladill_sso.issuer'), '/').'/oauth/authorize?'.http_build_query($query);
if ($request->boolean('interactive') && ! $request->boolean('fallback')) {
$request->session()->put('sso.popup', true);
return view('auth.sso-signing-in', [
'authorizeUrl' => $authorizeUrl,
'intended' => $intended,
'fallbackUrl' => route('sso.connect', [
'redirect' => $intended,
'interactive' => 1,
'fallback' => 1,
]),
]);
}
return redirect()->away($authorizeUrl);
}
public function callback(Request $request): RedirectResponse|View
{
$intended = (string) $request->session()->get('sso.intended', route('qr.dashboard'));
$popup = (bool) $request->session()->get('sso.popup');
if ($request->filled('error')) {
if (in_array($request->query('error'), ['login_required', 'interaction_required', 'consent_required'], true)
&& ! $request->boolean('interactive')) {
return redirect()->away((string) config('ladill.marketing_url'));
}
return $this->finishCallback($request, $intended, (string) $request->query('error_description', $request->query('error')), $popup);
}
if (! $request->filled('code')
|| $request->query('state') !== $request->session()->pull('sso.state')) {
return $this->finishCallback($request, $intended, 'invalid_state', $popup);
}
$issuer = rtrim((string) config('services.ladill_sso.issuer'), '/');
$tokenRes = Http::asForm()->post($issuer.'/oauth/token', [
'grant_type' => 'authorization_code',
'client_id' => (string) config('services.ladill_sso.client_id'),
'client_secret' => (string) config('services.ladill_sso.client_secret'),
'redirect_uri' => (string) config('services.ladill_sso.redirect'),
'code' => (string) $request->query('code'),
'code_verifier' => (string) $request->session()->pull('sso.verifier'),
]);
if ($tokenRes->failed()) {
return $this->finishCallback($request, $intended, 'token_exchange_failed', $popup);
}
$user = $this->loginFromTokenResponse($request, $tokenRes);
if (! $user) {
return $this->finishCallback($request, $intended, 'userinfo_failed', $popup);
}
QrTeamMember::linkPendingInvitesFor($user);
Auth::login($user, remember: true);
$request->session()->regenerate();
return $this->finishCallback($request, $intended, null, $popup);
}
public function logout(Request $request): RedirectResponse
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
// Per-app sign-out: end only this app's session and keep the platform
// (auth.ladill.com) SSO session alive so "Sign in again" re-auths silently
// without a password. Full "sign out of all Ladill apps" lives on account.
return redirect()->away($this->defaultSignedOutUrl());
}
/** Platform session ended — clear this app and offer silent sign-in again. */
public function platformSignedOut(Request $request): RedirectResponse
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('sso.connect', [
'redirect' => (string) $request->query('redirect', ''),
]);
}
public function logoutBridge(Request $request): View
{
$return = $this->safeReturnUrl((string) $request->query('return', ''));
$hubUrl = 'https://'.config('app.auth_domain').'/logout/sso/hub?'.http_build_query([
'embedded' => 1,
'return' => $return,
]);
return view('auth.sso-logout-bridge', [
'hubUrl' => $hubUrl,
'return' => $return,
'authOrigin' => 'https://'.config('app.auth_domain'),
]);
}
public function frontchannelLogout(Request $request): Response|RedirectResponse
{
if ($this->shouldLogoutForMailbox($request)) {
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
}
$return = (string) $request->query('return', '');
$root = (string) config('app.platform_domain', 'ladill.com');
$host = parse_url($return, PHP_URL_HOST);
if (str_starts_with($return, 'https://') && is_string($host) && ($host === $root || str_ends_with($host, '.'.$root))) {
return redirect()->away($return);
}
return response('', 204);
}
private function attemptSilentRefresh(Request $request, string $intended): bool
{
$refreshToken = (string) $request->session()->get('sso.refresh_token', '');
if ($refreshToken === '') {
return false;
}
$issuer = rtrim((string) config('services.ladill_sso.issuer'), '/');
$tokenRes = Http::asForm()->post($issuer.'/oauth/token', [
'grant_type' => 'refresh_token',
'refresh_token' => $refreshToken,
'client_id' => (string) config('services.ladill_sso.client_id'),
'client_secret' => (string) config('services.ladill_sso.client_secret'),
'scope' => 'openid profile email',
]);
if ($tokenRes->failed()) {
$request->session()->forget('sso.refresh_token');
return false;
}
$user = $this->loginFromTokenResponse($request, $tokenRes);
if (! $user) {
return false;
}
Auth::login($user, remember: true);
$request->session()->put('sso.intended', $intended);
return true;
}
private function loginFromTokenResponse(Request $request, HttpResponse $tokenRes): ?User
{
$refreshToken = (string) $tokenRes->json('refresh_token', '');
if ($refreshToken !== '') {
$request->session()->put('sso.refresh_token', $refreshToken);
}
$issuer = rtrim((string) config('services.ladill_sso.issuer'), '/');
$claims = Http::withToken((string) $tokenRes->json('access_token'))->acceptJson()->get($issuer.'/oauth/userinfo');
if ($claims->failed() || ! $claims->json('sub')) {
return null;
}
$email = (string) ($claims->json('email') ?: '');
if ($email !== '') {
$request->session()->put('sso.login_hint', $email);
}
return User::updateOrCreate(
['public_id' => (string) $claims->json('sub')],
[
'name' => $claims->json('name'),
'email' => $email !== '' ? $email : (string) $claims->json('sub').'@users.ladill.com',
'avatar_url' => $claims->json('picture'),
],
);
}
private function shouldLogoutForMailbox(Request $request): bool
{
$mailbox = strtolower(trim((string) $request->query('mailbox', '')));
if ($mailbox === '' || ! str_contains($mailbox, '@')) {
return true;
}
$user = Auth::user();
if (! $user) {
return false;
}
return strtolower((string) $user->email) !== $mailbox;
}
private function finishCallback(Request $request, string $intended, ?string $error = null, bool $popup = false): RedirectResponse|View
{
if ($popup) {
return view('auth.sso-popup-done', [
'intended' => $intended,
'error' => $error,
'appOrigin' => rtrim((string) config('app.url'), '/'),
'fallbackUrl' => route('sso.connect', [
'redirect' => $intended,
'interactive' => 1,
'fallback' => 1,
]),
]);
}
if ($error) {
return redirect()->route('sso.connect', [
'redirect' => $intended,
'interactive' => 1,
]);
}
return $this->safeRedirect($intended, route('qr.dashboard'));
}
private function defaultSignedOutUrl(): string
{
foreach (array_keys(Route::getRoutes()->getRoutesByName()) as $name) {
if (str_ends_with($name, '.signed-out')) {
return route($name);
}
}
return 'https://'.config('app.platform_domain');
}
private function safeReturnUrl(string $url): string
{
$host = parse_url($url, PHP_URL_HOST);
$root = (string) config('app.platform_domain', 'ladill.com');
if (is_string($host) && str_starts_with($url, 'https://')
&& ($host === $root || str_ends_with($host, '.'.$root))) {
return $url;
}
return $this->defaultSignedOutUrl();
}
private function safeRedirect(string $url, string $fallback): RedirectResponse
{
$host = parse_url($url, PHP_URL_HOST);
$root = (string) config('app.platform_domain', 'ladill.com');
if (is_string($host) && str_starts_with($url, 'https://')
&& ($host === $root || str_ends_with($host, '.'.$root))) {
return redirect()->away($url);
}
return redirect()->away($fallback);
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
abstract class Controller
{
use AuthorizesRequests;
}
@@ -0,0 +1,64 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class NotificationController extends Controller
{
public function index(Request $request): View
{
$notifications = $request->user()
->notifications()
->latest()
->paginate(20);
return view('notifications.index', compact('notifications'));
}
public function unread(Request $request): JsonResponse
{
$notifications = $request->user()
->unreadNotifications()
->latest()
->take(10)
->get()
->map(fn ($n) => [
'id' => $n->id,
'type' => class_basename($n->type),
'title' => $n->data['title'] ?? 'Notification',
'message' => $n->data['message'] ?? '',
'icon' => $n->data['icon'] ?? 'bell',
'url' => $n->data['url'] ?? null,
'created_at' => $n->created_at->diffForHumans(),
]);
return response()->json([
'notifications' => $notifications,
'unread_count' => $request->user()->unreadNotifications()->count(),
]);
}
public function markAsRead(Request $request, string $id): JsonResponse
{
$notification = $request->user()
->notifications()
->where('id', $id)
->first();
if ($notification) {
$notification->markAsRead();
}
return response()->json(['success' => true]);
}
public function markAllAsRead(Request $request): JsonResponse
{
$request->user()->unreadNotifications->markAsRead();
return response()->json(['success' => true]);
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers;
use App\Models\QrCode;
use App\Support\Qr\QrTypeCatalog;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class SearchController extends Controller
{
public function __invoke(Request $request): JsonResponse|View
{
$q = trim((string) $request->query('q'));
$results = mb_strlen($q) >= 2 ? $this->results($q) : [];
if ($request->expectsJson() || $request->ajax() || $request->wantsJson()) {
return response()->json(['results' => $results]);
}
return view('search.index', [
'query' => $q,
'results' => $results,
]);
}
/** @return list<array{type: string, title: string, subtitle: string, url: string}> */
private function results(string $q): array
{
$like = '%'.$q.'%';
return ladill_account()->qrCodes()
->whereIn('type', QrTypeCatalog::plusTypes())
->where(function ($query) use ($like): void {
$query->where('label', 'like', $like)
->orWhere('short_code', 'like', $like)
->orWhere('destination_url', 'like', $like);
})
->orderByDesc('updated_at')
->limit(15)
->get()
->map(fn (QrCode $code): array => [
'type' => 'qr',
'title' => $code->label,
'subtitle' => $code->typeLabel().' · '.$code->publicUrl(),
'url' => route('user.qr-codes.show', $code),
])
->all();
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Http\Controllers;
use App\Services\Billing\BillingClient;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
/**
* Lightweight wallet balance for the avatar dropdown widget.
*/
class WalletBalanceController extends Controller
{
public function balance(Request $request, BillingClient $billing): JsonResponse
{
$publicId = (string) $request->user()->public_id;
$minor = Cache::remember("wallet_balance:{$publicId}", now()->addSeconds(30), function () use ($billing, $publicId) {
try {
return $billing->balanceMinor($publicId);
} catch (\Throwable) {
return null;
}
});
if ($minor === null) {
return response()->json(['available' => false]);
}
$currency = (string) config('billing.currency', 'GHS');
return response()->json([
'available' => true,
'balance_minor' => $minor,
'currency' => $currency,
'formatted' => $currency.' '.number_format($minor / 100, 2),
]);
}
}
@@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers;
use App\Services\Billing\BillingClient;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
/**
* In-app wallet top-up. Posts to the central identity API to start a Paystack
* checkout against the one Ladill wallet, then hands the user to checkout. This
* backs the two-step "Add funds" modal so users never leave for the wallet page
* when they hit an insufficient-balance action.
*/
class WalletTopupController extends Controller
{
public function store(Request $request, BillingClient $billing): JsonResponse|RedirectResponse
{
$validated = $request->validate([
'amount' => ['required', 'numeric', 'min:5', 'max:5000'],
'return_url' => ['nullable', 'url', 'max:2048'],
]);
$returnUrl = $validated['return_url'] ?? (string) (url()->previous() ?: url('/'));
try {
$checkoutUrl = $billing->topup(
(string) $request->user()->public_id,
(float) $validated['amount'],
$returnUrl,
);
} catch (\Throwable) {
$checkoutUrl = '';
}
if ($checkoutUrl === '') {
if ($request->expectsJson()) {
return response()->json(['error' => 'Unable to start payment. Please try again.'], 422);
}
return back()->with('error', 'Unable to start payment. Please try again.');
}
if ($request->expectsJson()) {
return response()->json(['checkout_url' => $checkoutUrl]);
}
return redirect()->away($checkoutUrl);
}
}
@@ -0,0 +1,58 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;
use Symfony\Component\HttpFoundation\Response;
/**
* App sessions are subordinate to the platform (auth.ladill.com) session.
* When the platform session ends, clear this app's local session too.
*/
class EnsurePlatformSession
{
public function handle(Request $request, Closure $next): Response
{
if (! Auth::check()) {
return $next($request);
}
$authDomain = trim((string) config('app.auth_domain', ''));
if ($authDomain === '') {
return $next($request);
}
$cookieHeader = (string) $request->headers->get('Cookie', '');
if ($cookieHeader === '') {
return $this->clearAppSession($request);
}
try {
$response = Http::withHeaders(['Cookie' => $cookieHeader])
->timeout(3)
->get('https://'.$authDomain.'/sso/ping');
} catch (\Throwable) {
return $next($request);
}
if ($response->status() === 401) {
return $this->clearAppSession($request);
}
return $next($request);
}
private function clearAppSession(Request $request): Response
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('sso.connect', [
'redirect' => $request->fullUrl(),
]);
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\View;
use Symfony\Component\HttpFoundation\Response;
/**
* Injects a branded boot splash (Ladill Mail style) into authenticated HTML
* pages. The splash shows once per browser session while the app loads, then
* fades out masking the client-side boot so the app feels instant.
*/
class InjectBootSplash
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
if (! $request->isMethod('GET') || ! Auth::check()) {
return $response;
}
if (! str_contains((string) $response->headers->get('Content-Type', ''), 'text/html')) {
return $response;
}
$content = $response->getContent();
if (! is_string($content)
|| stripos($content, '<body') === false
|| str_contains($content, 'id="ladill-boot"')) {
return $response;
}
$splash = View::make('partials.boot-splash')->render();
$content = preg_replace_callback('/<body\b[^>]*>/i', fn ($m) => $m[0].$splash, $content, 1);
if (is_string($content)) {
$response->setContent($content);
}
return $response;
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace App\Http\Middleware;
use App\Models\User;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\View;
use Symfony\Component\HttpFoundation\Response;
class SetActingAccount
{
public function handle(Request $request, Closure $next): Response
{
if ($user = $request->user()) {
if ($request->is('api/*')) {
$accountId = (int) ($request->header('X-Ladill-Account') ?: $user->id);
} else {
$accountId = (int) $request->session()->get('ladill_account', $user->id);
}
if (! $user->canAccessAccount($accountId)) {
$accountId = $user->id;
if (! $request->is('api/*')) {
$request->session()->put('ladill_account', $accountId);
}
}
$account = $accountId === $user->id ? $user : (User::find($accountId) ?? $user);
$request->attributes->set('actingAccount', $account);
if (! $request->is('api/*')) {
View::share('actingAccount', $account);
View::share('accessibleAccounts', $user->accessibleAccounts());
}
}
return $next($request);
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
namespace App\Models;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Collection;
use Laravel\Sanctum\HasApiTokens;
/**
* Thin local mirror of the platform identity (auth.ladill.com owns users).
*/
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasApiTokens, HasFactory, Notifiable;
protected $fillable = ['public_id', 'name', 'email', 'avatar_url', 'password'];
protected $hidden = ['password', 'remember_token'];
protected function casts(): array
{
return ['email_verified_at' => 'datetime', 'password' => 'hashed'];
}
public function memberships(): HasMany
{
return $this->hasMany(QrTeamMember::class, 'user_id')
->where('status', QrTeamMember::STATUS_ACTIVE);
}
public function canAccessAccount(int $accountId): bool
{
return $accountId === $this->id
|| $this->memberships()->where('account_id', $accountId)->exists();
}
/** @return Collection<int, User> */
public function accessibleAccounts(): Collection
{
$ids = $this->memberships()->pluck('account_id')->all();
return collect([$this])->merge(self::whereIn('id', $ids)->get())->unique('id')->values();
}
public function qrWallet(): HasOne
{
return $this->hasOne(QrWallet::class);
}
public function qrCodes(): HasMany
{
return $this->hasMany(QrCode::class);
}
public function qrSetting(): HasOne
{
return $this->hasOne(QrSetting::class);
}
public function getOrCreateQrSetting(): QrSetting
{
return $this->qrSetting()->firstOrCreate([]);
}
public function getOrCreateQrWallet(): QrWallet
{
return $this->qrWallet()->firstOrCreate(
[],
['credit_balance' => 0, 'qr_codes_total' => 0, 'scans_total' => 0, 'status' => QrWallet::STATUS_ACTIVE],
);
}
public function avatarUrl(): ?string
{
$url = trim((string) $this->avatar_url);
return $url !== '' ? $url : null;
}
}
@@ -0,0 +1,45 @@
<?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),
];
}
}
@@ -0,0 +1,53 @@
<?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'),
];
}
}
@@ -0,0 +1,54 @@
<?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'),
];
}
}
@@ -0,0 +1,68 @@
<?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',
};
}
}
@@ -0,0 +1,47 @@
<?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),
];
}
}
@@ -0,0 +1,48 @@
<?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),
];
}
}
@@ -0,0 +1,47 @@
<?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),
];
}
}
@@ -0,0 +1,45 @@
<?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),
];
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Providers;
use App\Models\QrCode;
use App\Policies\QrCodePolicy;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;
use App\Support\MobileTopbar;
use Illuminate\Support\Facades\View;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
//
}
public function boot(): void
{
Gate::policy(QrCode::class, QrCodePolicy::class);
View::composer(['partials.topbar', 'partials.topbar-qr'], function ($view) {
$view->with(MobileTopbar::resolve());
});
}
}
+126
View File
@@ -0,0 +1,126 @@
<?php
namespace App\Services\Afia;
use Illuminate\Support\Facades\Http;
use RuntimeException;
/**
* Afia Ladill in-app AI assistant scoped to a product (QR Plus).
*/
class AfiaService
{
public function enabled(): bool
{
return (bool) config('afia.enabled', true) && (string) config('afia.api_key', '') !== '';
}
/**
* @param array<int,array{role:string,text:string}> $history
* @param array<string,mixed> $context
*/
public function chat(string $message, array $history, array $context): string
{
if (! $this->enabled()) {
throw new RuntimeException('Afia is not configured.');
}
$provider = (string) config('afia.provider', 'openai');
$model = (string) config('afia.model', 'gpt-4o-mini');
$apiKey = (string) config('afia.api_key');
$messages = [['role' => 'system', 'content' => $this->systemPrompt($context)]];
foreach (array_slice($history, -8) as $turn) {
$role = ($turn['role'] ?? 'user') === 'assistant' ? 'assistant' : 'user';
$text = trim((string) ($turn['text'] ?? ''));
if ($text !== '') {
$messages[] = ['role' => $role, 'content' => $text];
}
}
$messages[] = ['role' => 'user', 'content' => $message];
return $provider === 'anthropic'
? $this->viaAnthropic($model, $apiKey, $messages)
: $this->viaOpenAi($model, $apiKey, $messages);
}
private function viaOpenAi(string $model, string $apiKey, array $messages): string
{
$res = Http::withToken($apiKey)->acceptJson()->timeout(45)
->post('https://api.openai.com/v1/chat/completions', [
'model' => $model,
'temperature' => 0.3,
'max_tokens' => 600,
'messages' => $messages,
]);
if ($res->failed()) {
throw new RuntimeException('OpenAI request failed: '.$res->status());
}
return trim((string) $res->json('choices.0.message.content', ''));
}
private function viaAnthropic(string $model, string $apiKey, array $messages): string
{
$system = $messages[0]['content'];
$turns = array_values(array_filter($messages, fn ($m) => $m['role'] !== 'system'));
$res = Http::withHeaders([
'x-api-key' => $apiKey,
'anthropic-version' => '2023-06-01',
])->acceptJson()->timeout(45)->post('https://api.anthropic.com/v1/messages', [
'model' => $model,
'max_tokens' => 600,
'system' => $system,
'messages' => array_map(fn ($m) => ['role' => $m['role'], 'content' => $m['content']], $turns),
]);
if ($res->failed()) {
throw new RuntimeException('Anthropic request failed: '.$res->status());
}
return trim((string) $res->json('content.0.text', ''));
}
/** @param array<string,mixed> $context */
private function systemPrompt(array $context): string
{
$ctx = collect($context)->map(fn ($v, $k) => "- {$k}: {$v}")->implode("\n");
return match ((string) config('afia.product', 'qr')) {
'qr' => $this->qrSystemPrompt($ctx),
default => $this->qrSystemPrompt($ctx),
};
}
private function qrSystemPrompt(string $ctx): string
{
return <<<PROMPT
You are Afia, the assistant inside Ladill QR Plus (qrplus.ladill.com).
Help users create and manage dynamic QR codes, understand scans and billing, and print codes that work reliably.
Be concise, friendly, and actionable: short numbered steps and name the screen or section to use.
What Ladill QR Plus does and where things live:
- Overview (Dashboard): recent codes, active code count, scans in the last 30 days, wallet balance.
- My Codes: list, create, and edit QR codes. Types: Link (URL), PDF, List of Links, Business profile, WiFi, App download.
- Creating a code: pick a type, set content, customize style (colors, logo, frame), then download PNG/SVG/PDF.
- Dynamic codes: destination can change after printing the printed QR always points to ladill.com/q/{code}.
- Business QR: name, tagline, contact, hours, logo, cover, social links shown as a mobile landing page.
- WiFi QR: guests scan to join; network name and password are encoded in the code.
- PDF QR: hosts a PDF with optional download button on the viewer.
- Billing: each new code debits the Ladill wallet (account portal Wallet). Top up at account.ladill.com/wallet.
- Team: invite teammates to manage codes together (sidebar Team).
- Developers: API tokens for programmatic code creation (sidebar Developers).
Rules:
- Only answer questions about Ladill QR Plus code types, styling, downloads, scans, wallet billing, team, and API.
- If asked about domains, hosting, email, or payments/commerce QR (shop, events, donations), briefly say those live in other Ladill apps and suggest the app launcher.
- Never invent prices, short codes, or wallet balances use the user context below or tell them where to check.
- For print tips: recommend high contrast, adequate quiet zone, test-scan before mass printing.
Current user context:
{$ctx}
PROMPT;
}
}
+103
View File
@@ -0,0 +1,103 @@
<?php
namespace App\Services\Billing;
use Illuminate\Support\Facades\Http;
/**
* Client for the platform Billing HTTP API the one UserWallet lives on the
* platform; Bird only consumes it. Amounts are integer minor units; the user is
* identified by public_id. Authenticates with the per-consumer config('billing.service') key.
* Contract validated in the monolith (smtp-extraction-runbook §4-A).
*/
class BillingClient
{
private function base(): string
{
return rtrim((string) config('billing.api_url'), '/');
}
private function token(): string
{
return (string) (config('billing.api_key') ?? '');
}
private function get(string $path, array $query): array
{
$res = Http::withToken($this->token())->acceptJson()->timeout(10)->get($this->base().$path, $query);
$res->throw();
return (array) $res->json();
}
public function balanceMinor(string $publicId): int
{
return (int) ($this->get('/balance', ['user' => $publicId])['balance_minor'] ?? 0);
}
/**
* Start a Paystack checkout to top up the central wallet; returns the
* checkout URL. Backs the in-app two-step "Add funds" modal.
*/
public function topup(string $publicId, float $amount, string $returnUrl): string
{
$res = Http::withToken($this->token())->acceptJson()->timeout(15)->post($this->base().'/topup', [
'user' => $publicId,
'amount' => $amount,
'return_url' => $returnUrl,
]);
$res->throw();
return (string) ($res->json()['checkout_url'] ?? '');
}
public function canAfford(string $publicId, int $amountMinor): bool
{
return (bool) ($this->get('/can-afford', ['user' => $publicId, 'amount_minor' => $amountMinor])['affordable'] ?? false);
}
public function serviceLedger(string $publicId, string $service): array
{
return $this->get('/service-ledger', ['user' => $publicId, 'service' => $service]);
}
/**
* Debit the wallet. Returns true on success, false on insufficient balance
* (HTTP 402). Idempotent by $reference.
*/
public function debit(string $publicId, int $amountMinor, string $service, string $source, string $reference, ?int $serviceId = null, ?string $description = null): bool
{
$res = Http::withToken($this->token())->acceptJson()->timeout(10)->post($this->base().'/debit', array_filter([
'user' => $publicId,
'amount_minor' => $amountMinor,
'service' => $service,
'source' => $source,
'reference' => $reference,
'service_id' => $serviceId,
'description' => $description,
], static fn ($v) => $v !== null));
if ($res->status() === 402) {
return false;
}
$res->throw();
return true;
}
public function credit(string $publicId, int $amountMinor, string $service, string $source, string $reference, ?int $serviceId = null, ?string $description = null): array
{
$res = Http::withToken($this->token())->acceptJson()->timeout(10)->post($this->base().'/credit', array_filter([
'user' => $publicId,
'amount_minor' => $amountMinor,
'service' => $service,
'source' => $source,
'reference' => $reference,
'service_id' => $serviceId,
'description' => $description,
], static fn ($v) => $v !== null));
$res->throw();
return (array) $res->json();
}
}
+164
View File
@@ -0,0 +1,164 @@
<?php
namespace App\Services\Billing;
use App\Models\PlatformSetting;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Schema;
use RuntimeException;
class PaystackService
{
public function publicKey(): string
{
return (string) $this->settingValue('paystack_public_key', config('services.paystack.public_key', ''));
}
public function initializeTransaction(array $payload): array
{
return $this->initializeTransactionWithCredentials($payload);
}
public function initializeTransactionWithCredentials(array $payload, ?string $secretKey = null, ?string $baseUrl = null): array
{
$response = $this->request(secretKey: $secretKey, baseUrl: $baseUrl)
->post('/transaction/initialize', $payload);
return $this->extractData($response, 'Failed to initialize Paystack transaction.');
}
public function verifyTransaction(string $reference): array
{
return $this->verifyTransactionWithCredentials($reference);
}
public function verifyTransactionWithCredentials(string $reference, ?string $secretKey = null, ?string $baseUrl = null): array
{
$response = $this->request(secretKey: $secretKey, baseUrl: $baseUrl)
->get('/transaction/verify/' . urlencode($reference));
return $this->extractData($response, 'Failed to verify Paystack transaction.');
}
public function verifyWebhookSignature(string $rawBody, ?string $signature): bool
{
$secret = (string) $this->settingValue('paystack_webhook_secret', config('services.paystack.webhook_secret'));
if ($secret === '') {
$secret = (string) $this->settingValue('paystack_secret_key', config('services.paystack.secret_key'));
}
if ($secret === '' || !$signature) {
return false;
}
$computed = hash_hmac('sha512', $rawBody, $secret);
return hash_equals($computed, $signature);
}
protected function request(?string $secretKey = null, ?string $baseUrl = null)
{
$resolvedBaseUrl = rtrim((string) ($baseUrl ?: $this->settingValue('paystack_base_url', config('services.paystack.base_url', 'https://api.paystack.co'))), '/');
$secret = $this->normalizeSecretKey(
(string) ($secretKey ?: $this->settingValue('paystack_secret_key', config('services.paystack.secret_key')))
);
return Http::baseUrl($resolvedBaseUrl)
->withToken($secret)
->acceptJson()
->asJson();
}
protected function settingValue(string $key, mixed $fallback = null): mixed
{
try {
if (!Schema::hasTable('platform_settings')) {
return $fallback;
}
$setting = PlatformSetting::query()
->where('key', $key)
->where('is_active', true)
->first();
return $setting ? ($setting->value['value'] ?? $fallback) : $fallback;
} catch (\Throwable) {
return $fallback;
}
}
protected function extractData(Response $response, string $message): array
{
if ($response->failed()) {
throw new RuntimeException($message);
}
$json = $response->json();
if (!is_array($json) || !($json['status'] ?? false)) {
throw new RuntimeException((string) ($json['message'] ?? $message));
}
$data = $json['data'] ?? null;
return is_array($data) ? $data : [];
}
/**
* List supported banks / mobile money providers.
* @param string $country e.g. 'ghana', 'nigeria'
* @param string $type 'nuban', 'mobile_money', or '' for all
*/
public function listBanks(string $country = 'ghana', string $type = '', ?string $currency = null): array
{
$query = ['perPage' => 200];
if ($currency !== null && $currency !== '') {
$query['currency'] = strtoupper($currency);
} else {
$query['country'] = $country;
}
if ($type !== '') {
$query['type'] = $type;
}
$response = $this->request()->get('/bank', $query);
$json = $response->json();
return is_array($json['data'] ?? null) ? $json['data'] : [];
}
/**
* Create a transfer recipient (bank account or mobile money).
* @param array{type: string, name: string, account_number: string, bank_code: string, currency?: string} $payload
*/
public function createTransferRecipient(array $payload): array
{
$response = $this->request()->post('/transferrecipient', $payload);
return $this->extractData($response, 'Failed to create transfer recipient.');
}
/**
* Initiate a transfer to a recipient.
* @param array{source: string, amount: int, recipient: string, reason?: string, reference?: string} $payload
*/
public function initiateTransfer(array $payload): array
{
$response = $this->request()->post('/transfer', $payload);
return $this->extractData($response, 'Failed to initiate transfer.');
}
/**
* Verify a transfer by reference.
*/
public function verifyTransfer(string $reference): array
{
$response = $this->request()->get('/transfer/verify/' . urlencode($reference));
return $this->extractData($response, 'Failed to verify transfer.');
}
protected function normalizeSecretKey(string $secret): string
{
$normalized = trim($secret);
if (str_starts_with(strtolower($normalized), 'bearer ')) {
$normalized = trim(substr($normalized, 7));
}
return $normalized;
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
namespace App\Services\Identity;
use Illuminate\Support\Facades\Http;
class IdentityClient
{
/** @return array<string, mixed> */
public function mailboxLinkStatus(string $publicId): array
{
$response = $this->request()->get($this->url('/identity/mailbox-link'), [
'user' => $publicId,
]);
$response->throw();
return (array) $response->json('data', []);
}
/** @return array<string, mixed> */
public function linkMailbox(string $publicId, string $mailboxAddress): array
{
$response = $this->request()->put($this->url('/identity/mailbox-link'), [
'user' => $publicId,
'mailbox_address' => $mailboxAddress,
]);
$response->throw();
return (array) $response->json('data', []);
}
/** @return array<string, mixed> */
public function unlinkMailbox(string $publicId): array
{
$response = $this->request()->delete($this->url('/identity/mailbox-link'), [
'user' => $publicId,
]);
$response->throw();
return (array) $response->json('data', []);
}
private function request()
{
return Http::withToken((string) config('identity.api_key'))
->acceptJson()
->timeout(15);
}
private function url(string $path): string
{
return rtrim((string) config('identity.api_url'), '/').$path;
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Support;
final class CrmPrefillCodec
{
/** @return array<string, mixed>|null */
public static function decode(?string $token): ?array
{
if (! is_string($token) || $token === '') {
return null;
}
$padded = $token.str_repeat('=', (4 - strlen($token) % 4) % 4);
$json = base64_decode(strtr($padded, '-_', '+/'), true);
if ($json === false) {
return null;
}
try {
$data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return null;
}
return is_array($data) ? $data : null;
}
}
+369
View File
@@ -0,0 +1,369 @@
<?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 [];
}
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace App\Support;
final class DomainGlobeIcon
{
public const VIEW_BOX = '0 0 14 14';
/** 14×14 globe for UI icons (inherits color via currentColor). */
public const ICON_ASSET = 'images/ladill-icons/domain.svg';
public static function paths(): string
{
return '<path d="M7 13.5C10.5899 13.5 13.5 10.5899 13.5 7C13.5 3.41015 10.5899 0.5 7 0.5C3.41015 0.5 0.5 3.41015 0.5 7C0.5 10.5899 3.41015 13.5 7 13.5Z" stroke-linecap="round" stroke-linejoin="round"/>'
.'<path d="M0.5 7H13.5" stroke-linecap="round" stroke-linejoin="round"/>'
.'<path d="M9.5 7C9.3772 9.37699 8.50168 11.6533 7 13.5C5.49832 11.6533 4.6228 9.37699 4.5 7C4.6228 4.62301 5.49832 2.34665 7 0.5C8.50168 2.34665 9.3772 4.62301 9.5 7V7Z" stroke-linecap="round" stroke-linejoin="round"/>';
}
public static function svg(string $class = 'h-5 w-5'): string
{
return sprintf(
'<svg class="%s" viewBox="%s" fill="none" stroke="currentColor" stroke-width="1" aria-hidden="true">%s</svg>',
e($class),
self::VIEW_BOX,
self::paths()
);
}
}
+69
View File
@@ -0,0 +1,69 @@
<?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';
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace App\Support;
class MobileTopbar
{
public static function resolve(): array
{
$appName = config('mobile-topbar.app_name', 'Ladill');
$title = $appName === 'Ladill'
? 'Ladill'
: "Ladill {$appName}";
return [
'mobileTopbarTitle' => $title,
];
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace App\Support\Qr;
use App\Models\QrCode;
use Illuminate\Support\Collection;
/**
* Generates ZPL II label code for Zebra-style badge/label printers.
* One ^XA…^XZ label block per attendee, sized to the chosen badge size at 203 dpi.
*/
class EventBadgeZpl
{
/** @param Collection<int, \App\Models\QrEventRegistration> $registrations */
public static function forRegistrations(QrCode $qrCode, Collection $registrations): string
{
$content = $qrCode->content();
$eventName = self::sanitize($content['name'] ?? $qrCode->label);
$size = $content['badge_size'] ?? '4x3';
// Label dimensions in dots @ 203 dpi.
[$widthDots, $heightDots] = match ($size) {
'4x6' => [812, 1218],
'cr80' => [685, 431],
default => [812, 609], // 4x3
};
$labels = [];
foreach ($registrations as $reg) {
$name = self::sanitize($reg->attendee_name);
$tier = self::sanitize($reg->tier_name);
$extra = collect($reg->badge_fields ?? [])
->map(fn ($v, $k) => self::sanitize($k . ': ' . $v))
->implode(' | ');
$zpl = "^XA\n";
$zpl .= "^PW{$widthDots}\n";
$zpl .= "^LL{$heightDots}\n";
$zpl .= "^CI28\n"; // UTF-8
// Event name (top)
$zpl .= "^FO40,40^A0N,40,40^FB" . ($widthDots - 80) . ",1,0,C^FD{$eventName}^FS\n";
// Attendee name (large, centered)
$zpl .= "^FO40,150^A0N,80,80^FB" . ($widthDots - 80) . ",2,0,C^FD{$name}^FS\n";
// Tier
$zpl .= "^FO40,320^A0N,40,40^FB" . ($widthDots - 80) . ",1,0,C^FD{$tier}^FS\n";
// Extra fields
if ($extra !== '') {
$zpl .= "^FO40,375^A0N,28,28^FB" . ($widthDots - 80) . ",2,0,C^FD{$extra}^FS\n";
}
// QR of the badge code (bottom)
$qrX = (int) (($widthDots / 2) - 70);
$zpl .= "^FO{$qrX}," . ($heightDots - 200) . "^BQN,2,5^FDLA,{$reg->badge_code}^FS\n";
// Badge code text
$zpl .= "^FO40," . ($heightDots - 60) . "^A0N,34,34^FB" . ($widthDots - 80) . ",1,0,C^FD{$reg->badge_code}^FS\n";
$zpl .= "^XZ\n";
$labels[] = $zpl;
}
return implode("\n", $labels);
}
private static function sanitize(string $value): string
{
// Escape ZPL control chars (^ and ~) and collapse whitespace.
$value = str_replace(['^', '~'], [' ', ' '], $value);
return trim(preg_replace('/\s+/', ' ', $value) ?? '');
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace App\Support\Qr;
class QrCornerStyleCatalog
{
/** @return array<string, array{label: string}> */
public static function outerStyles(): array
{
return [
'square' => ['label' => 'Square eye'],
'rounded' => ['label' => 'Rounded'],
'circle' => ['label' => 'Circular'],
];
}
/** @return array<string, array{label: string}> */
public static function innerStyles(): array
{
return [
'square' => ['label' => 'Square'],
'rounded' => ['label' => 'Rounded'],
'dot' => ['label' => 'Dot'],
];
}
public static function isValidOuter(string $style): bool
{
return isset(self::outerStyles()[$style]);
}
public static function isValidInner(string $style): bool
{
return isset(self::innerStyles()[$style]);
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Support\Qr;
class QrCoverImageSpec
{
/** Wide hero banners (business, church, event, itinerary, menu, shop). */
public const BANNER = '1920×1080 px (16:9 landscape)';
/** Book product cover on the landing page. */
public const BOOK = '800×1067 px (3:4 portrait)';
public static function label(string $variant = 'banner'): string
{
return match ($variant) {
'book' => self::BOOK,
default => self::BANNER,
};
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace App\Support\Qr;
use Carbon\Carbon;
class QrDateFormatter
{
public static function forInput(?string $value): string
{
if ($value === null || trim($value) === '') {
return '';
}
try {
return Carbon::parse($value)->format('Y-m-d');
} catch (\Throwable) {
return '';
}
}
public static function forDisplay(?string $value): string
{
if ($value === null || trim($value) === '') {
return '';
}
$value = trim($value);
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) {
try {
return Carbon::createFromFormat('Y-m-d', $value)->format('D, j M Y');
} catch (\Throwable) {
return $value;
}
}
return $value;
}
public static function normalize(?string $value, int $maxLength = 60): string
{
if ($value === null || trim($value) === '') {
return '';
}
$value = trim($value);
try {
return Carbon::parse($value)->format('Y-m-d');
} catch (\Throwable) {
return mb_substr($value, 0, $maxLength);
}
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace App\Support\Qr;
class QrFrameStyleCatalog
{
/** @return array<string, array{label: string, description: string, border_px: int, mode: string, cta?: string, visible?: bool}> */
public static function all(): array
{
return [
'none' => [
'label' => 'None',
'description' => 'Code only',
'border_px' => 0,
'mode' => 'none',
],
'thin' => [
'label' => 'Border',
'description' => 'Simple white sticker edge',
'border_px' => 8,
'mode' => 'border',
],
'bold' => [
'label' => 'Wide border',
'description' => 'Legacy wide border',
'border_px' => 16,
'mode' => 'border',
'visible' => false,
],
'scan_me' => [
'label' => 'Scan me',
'description' => 'CTA sticker below code',
'border_px' => 14,
'mode' => 'label',
'cta' => 'SCAN ME',
],
'tap_to_scan' => [
'label' => 'Tap to scan',
'description' => 'Rounded CTA sticker',
'border_px' => 14,
'mode' => 'pill',
'cta' => 'TAP TO SCAN',
],
];
}
/** @return array<string, array{label: string, description: string, border_px: int, mode: string, cta?: string, visible?: bool}> */
public static function visible(): array
{
return array_filter(self::all(), fn (array $style): bool => ($style['visible'] ?? true) === true);
}
/** @return list<string> */
public static function keys(): array
{
return array_keys(self::all());
}
public static function isValid(string $style): bool
{
return isset(self::all()[$style]);
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
namespace App\Support\Qr;
class QrModuleStyleCatalog
{
/** @return array<string, array{label: string, description: string, circular: bool, circle_radius: float, connect_paths: bool, scan_risk: string, visible?: bool}> */
public static function all(): array
{
return [
'square' => [
'label' => 'Standard',
'description' => 'Traditional square QR modules',
'circular' => false,
'circle_radius' => 0.4,
'connect_paths' => false,
'scan_risk' => 'low',
],
'soft' => [
'label' => 'Rounded',
'description' => 'Soft rounded modules',
'circular' => true,
'circle_radius' => 0.36,
'connect_paths' => false,
'scan_risk' => 'medium',
'visible' => false,
],
'dots' => [
'label' => 'Dots',
'description' => 'Round dot modules',
'circular' => true,
'circle_radius' => 0.45,
'connect_paths' => false,
'scan_risk' => 'medium',
],
'bubble' => [
'label' => 'Large dots',
'description' => 'Bolder round dot modules',
'circular' => true,
'circle_radius' => 0.52,
'connect_paths' => false,
'scan_risk' => 'high',
'visible' => false,
],
'fluid' => [
'label' => 'Fluid',
'description' => 'Legacy decorative style',
'circular' => true,
'circle_radius' => 0.38,
'connect_paths' => true,
'scan_risk' => 'high',
'visible' => false,
],
'bold' => [
'label' => 'Bold',
'description' => 'Legacy thick square modules',
'circular' => false,
'circle_radius' => 0.4,
'connect_paths' => false,
'scan_risk' => 'low',
'visible' => false,
],
];
}
/** @return array<string, array{label: string, description: string, circular: bool, circle_radius: float, connect_paths: bool, scan_risk: string, visible?: bool}> */
public static function visible(): array
{
return array_filter(self::all(), fn (array $style): bool => ($style['visible'] ?? true) === true);
}
/** @return list<string> */
public static function keys(): array
{
return array_keys(self::all());
}
public static function isValid(string $style): bool
{
return isset(self::all()[$style]);
}
/** @return array<string, mixed> */
public static function optionsFor(string $style): array
{
return self::all()[$style] ?? self::all()['square'];
}
public static function isDecorative(string $style): bool
{
return in_array(self::optionsFor($style)['scan_risk'], ['medium', 'high'], true);
}
}
+102
View File
@@ -0,0 +1,102 @@
<?php
namespace App\Support\Qr;
class QrScanReliability
{
/** @param array<string, mixed> $style */
public static function contrastRatio(array $style): float
{
$fg = self::relativeLuminance((string) ($style['foreground'] ?? '#000000'));
$bg = self::relativeLuminance((string) ($style['background'] ?? '#ffffff'));
$lighter = max($fg, $bg);
$darker = min($fg, $bg);
return ($lighter + 0.05) / ($darker + 0.05);
}
/**
* @param array<string, mixed> $style
* @return array{level: string, messages: list<string>}
*/
public static function assess(array $style): array
{
$style = QrStyleDefaults::merge($style);
$messages = [];
$level = 'good';
$contrast = self::contrastRatio($style);
$isClassic = self::isClassicBlackOnWhite($style);
$moduleStyle = (string) ($style['module_style'] ?? 'square');
$moduleMeta = QrModuleStyleCatalog::optionsFor($moduleStyle);
if ($moduleMeta['scan_risk'] === 'medium') {
$messages[] = 'This module style may not scan on every phone camera. Square is the safest choice.';
$level = 'fair';
}
if ($moduleMeta['scan_risk'] === 'high') {
$messages[] = 'Decorative styles like ' . $moduleMeta['label'] . ' often fail on Samsung Camera. Use square for print and signage.';
$level = 'poor';
}
if (! $isClassic) {
$messages[] = 'Colored QR codes often fail on Samsung Camera and other basic scanners. Black on white works everywhere.';
$level = $level === 'good' ? 'fair' : 'poor';
}
if ($contrast < 4.5) {
$messages[] = sprintf(
'Contrast is low (%.1f:1). Aim for at least 4.5:1 — dark foreground, white background.',
$contrast,
);
$level = 'poor';
}
if ($moduleMeta['scan_risk'] !== 'low' && ! $isClassic) {
$level = 'poor';
}
if ($messages === []) {
$messages[] = 'Black square modules on white give the best compatibility with Samsung, iPhone, and printed codes.';
}
return ['level' => $level, 'messages' => $messages];
}
/** @param array<string, mixed> $style */
public static function isClassicBlackOnWhite(array $style): bool
{
$fg = strtolower(ltrim((string) ($style['foreground'] ?? ''), '#'));
$bg = strtolower(ltrim((string) ($style['background'] ?? ''), '#'));
$fg = strlen($fg) === 3 ? $fg[0] . $fg[0] . $fg[1] . $fg[1] . $fg[2] . $fg[2] : $fg;
$bg = strlen($bg) === 3 ? $bg[0] . $bg[0] . $bg[1] . $bg[1] . $bg[2] . $bg[2] : $bg;
return in_array($fg, ['000000', '0f172a', '111111', '1a1a1a'], true)
&& in_array($bg, ['ffffff', 'fff'], true);
}
private static function relativeLuminance(string $hex): float
{
$hex = ltrim(trim($hex), '#');
if (strlen($hex) === 3) {
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
}
if (strlen($hex) !== 6 || ! ctype_xdigit($hex)) {
return 0.0;
}
$channels = [];
foreach ([0, 2, 4] as $offset) {
$value = hexdec(substr($hex, $offset, 2)) / 255;
$channels[] = $value <= 0.03928
? $value / 12.92
: (($value + 0.055) / 1.055) ** 2.4;
}
return (0.2126 * $channels[0]) + (0.7152 * $channels[1]) + (0.0722 * $channels[2]);
}
}
+114
View File
@@ -0,0 +1,114 @@
<?php
namespace App\Support\Qr;
class QrStyleDefaults
{
/** @return array<string, mixed> */
public static function defaults(): array
{
return [
'foreground' => '#000000',
'background' => '#ffffff',
'error_correction' => 'M',
'margin' => 4,
'module_style' => 'square',
'finder_outer' => 'square',
'finder_inner' => 'square',
'frame_style' => 'none',
'frame_text' => '',
'frame_color' => '#000000',
'scale' => 8,
'logo_path' => null,
'gradient_type' => 'none',
'gradient_color1' => '#000000',
'gradient_color2' => '#7c3aed',
'gradient_rotation' => 45,
'logo_size' => 0.3,
'logo_margin' => 5,
'logo_white_bg' => false,
'logo_shape' => 'none',
];
}
/** @param array<string, mixed>|null $style */
public static function merge(?array $style): array
{
$merged = array_merge(self::defaults(), $style ?? []);
if (! in_array($merged['error_correction'], ['L', 'M', 'Q', 'H'], true)) {
$merged['error_correction'] = 'M';
}
if (! in_array($merged['module_style'], QrModuleStyleCatalog::keys(), true)) {
$merged['module_style'] = 'square';
}
if (! QrCornerStyleCatalog::isValidOuter((string) ($merged['finder_outer'] ?? 'square'))) {
$merged['finder_outer'] = 'square';
}
if (! QrCornerStyleCatalog::isValidInner((string) ($merged['finder_inner'] ?? 'square'))) {
$merged['finder_inner'] = 'square';
}
if (! QrFrameStyleCatalog::isValid((string) ($merged['frame_style'] ?? 'none'))) {
$merged['frame_style'] = 'none';
}
$merged['frame_text'] = substr(trim((string) ($merged['frame_text'] ?? '')), 0, 100);
$frameColor = trim((string) ($merged['frame_color'] ?? '#000000'));
$merged['frame_color'] = preg_match('/^#[0-9a-fA-F]{6}$/', $frameColor) ? $frameColor : '#000000';
if (! in_array($merged['gradient_type'], ['none', 'linear', 'radial'], true)) {
$merged['gradient_type'] = 'none';
}
$merged['logo_size'] = max(0.1, min(0.4, (float) ($merged['logo_size'] ?? 0.3)));
$merged['logo_margin'] = max(0, min(15, (int) ($merged['logo_margin'] ?? 5)));
if (! in_array($merged['logo_shape'], ['none', 'rounded', 'circle'], true)) {
$merged['logo_shape'] = 'none';
}
$merged['logo_white_bg'] = (bool) ($merged['logo_white_bg'] ?? false);
if ($merged['module_style'] === 'bold') {
$merged['scale'] = min(16, (int) $merged['scale'] + 2);
}
$merged['margin'] = max(0, min(10, (int) $merged['margin']));
$merged['scale'] = max(4, min(16, (int) $merged['scale']));
return $merged;
}
/** @param array<string, mixed>|null $style */
public static function mergeForRender(?array $style): array
{
return QrStyleNormalizer::normalize(self::merge($style));
}
/** @param array<string, mixed> $style */
public static function recommendedEcc(array $style): string
{
$ecc = (string) ($style['error_correction'] ?? 'M');
$order = ['L' => 0, 'M' => 1, 'Q' => 2, 'H' => 3];
$minimum = 'M';
$moduleStyle = (string) ($style['module_style'] ?? 'square');
if (in_array($moduleStyle, ['bubble', 'fluid'], true)) {
$minimum = 'H';
} elseif (QrModuleStyleCatalog::isDecorative($moduleStyle)) {
$minimum = 'Q';
}
if (! empty($style['logo_path'])) {
$minimum = 'H';
}
if (QrScanReliability::contrastRatio($style) < 4.5) {
$minimum = 'H';
}
return ($order[$minimum] ?? 1) > ($order[$ecc] ?? 1) ? $minimum : $ecc;
}
}
+138
View File
@@ -0,0 +1,138 @@
<?php
namespace App\Support\Qr;
class QrStyleNormalizer
{
/**
* Silently tune styles so more phone cameras (including Samsung) can read the code.
*
* @param array<string, mixed> $style
* @return array<string, mixed>
*/
public static function normalize(array $style): array
{
$style = self::ensureContrast($style);
$style = self::ensureQuietZone($style);
$style = self::ensureRenderScale($style);
$style['error_correction'] = QrStyleDefaults::recommendedEcc($style);
return $style;
}
/** @param array<string, mixed> $style */
private static function ensureContrast(array $style): array
{
$target = 4.5;
$attempts = 0;
while (QrScanReliability::contrastRatio($style) < $target && $attempts < 16) {
$fg = self::hexToRgb((string) $style['foreground']);
$bg = self::hexToRgb((string) $style['background']);
if (self::relativeLuminance($fg) > self::relativeLuminance($bg)) {
$style['foreground'] = self::rgbToHex(self::darkenToward($fg, 0.18));
} else {
$style['foreground'] = self::rgbToHex(self::darkenToward($fg, 0.15));
$style['background'] = self::rgbToHex(self::lightenToward($bg, 0.15));
}
$attempts++;
}
if (QrScanReliability::contrastRatio($style) < $target) {
$style['foreground'] = '#000000';
$style['background'] = '#ffffff';
}
return $style;
}
/** @param array<string, mixed> $style */
private static function ensureQuietZone(array $style): array
{
$margin = (int) ($style['margin'] ?? 4);
$moduleStyle = (string) ($style['module_style'] ?? 'square');
if (QrModuleStyleCatalog::isDecorative($moduleStyle)) {
$margin = max($margin, 5);
}
$style['margin'] = max(4, min(10, $margin));
return $style;
}
/** @param array<string, mixed> $style */
private static function ensureRenderScale(array $style): array
{
$scale = (int) ($style['scale'] ?? 8);
$module = QrModuleStyleCatalog::optionsFor((string) ($style['module_style'] ?? 'square'));
if ($module['circular'] ?? false) {
$scale = max($scale, 10);
}
if (! empty($style['logo_path'])) {
$scale = max($scale, 10);
}
$style['scale'] = max(6, min(16, $scale));
return $style;
}
/** @return array{0: int, 1: int, 2: int} */
private static function hexToRgb(string $hex): array
{
$hex = ltrim(trim($hex), '#');
if (strlen($hex) === 3) {
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
}
if (strlen($hex) !== 6 || ! ctype_xdigit($hex)) {
return [0, 0, 0];
}
return [hexdec(substr($hex, 0, 2)), hexdec(substr($hex, 2, 2)), hexdec(substr($hex, 4, 2))];
}
/** @param array{0: int, 1: int, 2: int} $rgb */
private static function rgbToHex(array $rgb): string
{
return sprintf('#%02x%02x%02x', $rgb[0], $rgb[1], $rgb[2]);
}
/** @param array{0: int, 1: int, 2: int} $rgb */
private static function darkenToward(array $rgb, float $amount): array
{
return [
(int) max(0, $rgb[0] * (1 - $amount)),
(int) max(0, $rgb[1] * (1 - $amount)),
(int) max(0, $rgb[2] * (1 - $amount)),
];
}
/** @param array{0: int, 1: int, 2: int} $rgb */
private static function lightenToward(array $rgb, float $amount): array
{
return [
(int) min(255, $rgb[0] + ((255 - $rgb[0]) * $amount)),
(int) min(255, $rgb[1] + ((255 - $rgb[1]) * $amount)),
(int) min(255, $rgb[2] + ((255 - $rgb[2]) * $amount)),
];
}
/** @param array{0: int, 1: int, 2: int} $rgb */
private static function relativeLuminance(array $rgb): float
{
$channels = [];
foreach ($rgb as $value) {
$v = $value / 255;
$channels[] = $v <= 0.03928 ? $v / 12.92 : (($v + 0.055) / 1.055) ** 2.4;
}
return (0.2126 * $channels[0]) + (0.7152 * $channels[1]) + (0.0722 * $channels[2]);
}
}
+97
View File
@@ -0,0 +1,97 @@
<?php
namespace App\Support\Qr;
use App\Models\QrCode;
/**
* QR Plus utility types only. vCard and commerce types live in other products.
*/
class QrTypeCatalog
{
/** @return list<string> */
public static function plusTypes(): array
{
return [
QrCode::TYPE_URL,
QrCode::TYPE_DOCUMENT,
QrCode::TYPE_LINK_LIST,
QrCode::TYPE_BUSINESS,
QrCode::TYPE_WIFI,
QrCode::TYPE_APP,
];
}
/** @return array<string, array{label: string, description: string, category: string, icon: string}> */
public static function all(): array
{
$catalog = [
QrCode::TYPE_URL => [
'label' => 'Link',
'description' => 'Redirect to any URL',
'category' => 'basic',
'icon' => 'website.svg',
],
QrCode::TYPE_DOCUMENT => [
'label' => 'PDF',
'description' => 'Hosted PDF reader',
'category' => 'basic',
'icon' => 'terms.svg',
],
QrCode::TYPE_LINK_LIST => [
'label' => 'List of Links',
'description' => 'Landing page with multiple links',
'category' => 'basic',
'icon' => 'list.svg',
],
QrCode::TYPE_BUSINESS => [
'label' => 'Business',
'description' => 'Business profile page',
'category' => 'contact',
'icon' => 'business-profile.svg',
],
QrCode::TYPE_WIFI => [
'label' => 'WiFi',
'description' => 'Network name and password',
'category' => 'contact',
'icon' => 'wifi.svg',
],
QrCode::TYPE_APP => [
'label' => 'App',
'description' => 'App Store and Play Store links',
'category' => 'business',
'icon' => 'app.svg',
],
];
return array_intersect_key($catalog, array_flip(self::plusTypes()));
}
/** @return list<string> */
public static function keys(): array
{
return array_keys(self::all());
}
public static function label(string $type): string
{
return self::all()[$type]['label'] ?? ucfirst(str_replace('_', ' ', $type));
}
public static function isValid(string $type): bool
{
return isset(self::all()[$type]);
}
public static function iconUrl(string $icon): string
{
$path = public_path('images/qr-icons/'.$icon);
$url = '/images/qr-icons/'.$icon;
if (is_file($path)) {
return $url.'?v='.filemtime($path);
}
return $url;
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace App\Support\Qr;
class QrWifiPayload
{
/**
* Build a WiFi QR payload (ZXing MECARD format) for one-tap network join on scan.
*
* @param array<string, mixed> $content
*/
public static function encode(array $content): string
{
$encryption = strtoupper(trim((string) ($content['encryption'] ?? 'WPA')));
$auth = $encryption === 'NOPASS' ? 'nopass' : $encryption;
if (! in_array($auth, ['WPA', 'WEP', 'nopass'], true)) {
$auth = 'WPA';
}
$ssid = self::escape(trim((string) ($content['ssid'] ?? '')));
$payload = 'WIFI:T:' . $auth . ';S:' . $ssid;
if ($auth !== 'nopass') {
$password = trim((string) ($content['password'] ?? ''));
if ($password !== '') {
$payload .= ';P:' . self::escape($password);
}
}
if (filter_var($content['hidden'] ?? false, FILTER_VALIDATE_BOOL)) {
$payload .= ';H:true';
}
return $payload . ';;';
}
private static function escape(string $value): string
{
return str_replace(
['\\', ';', ',', '"', ':'],
['\\\\', '\\;', '\\,', '\\"', '\\:'],
$value
);
}
}
+122
View File
@@ -0,0 +1,122 @@
<?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.';
}
}
+164
View File
@@ -0,0 +1,164 @@
<?php
namespace App\Support;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Facades\Route;
class UserProfileMenu
{
/**
* Standard signed-in profile links for Ladill product apps.
* Not used by Ladill Mail (mail.ladill.com) that app keeps a mailbox-specific menu.
*
* @return list<array<string, mixed>>
*/
public static function items(?Authenticatable $user = null): array
{
$user ??= auth()->user();
if (! $user) {
return [];
}
$items = [];
foreach ([
['label' => 'Home', 'path' => '', 'host' => 'home'],
['label' => 'Profile', 'path' => 'profile'],
['label' => 'Account Settings', 'path' => 'account-settings'],
['label' => 'Dashboard', 'path' => 'dashboard'],
['label' => 'Billing', 'path' => 'billing'],
] as $link) {
$href = self::platformUrl($link['host'] ?? 'account', $link['path']);
if (self::isCurrentMenuLink($link, $href)) {
continue;
}
$items[] = [
'type' => 'link',
'label' => $link['label'],
'href' => $href,
];
}
if (Route::has((string) config("billing.wallet_balance_route", "wallet.balance"))) {
$items[] = ["type" => "wallet"];
}
if (self::userIsAdmin($user)) {
$adminHref = self::platformUrl('account', 'admin');
if (! self::isCurrentMenuLink(['path' => 'admin', 'host' => 'account'], $adminHref)) {
$items[] = [
'type' => 'link',
'label' => 'Admin',
'href' => $adminHref,
];
}
}
if (Route::has('logout')) {
$items[] = [
'type' => 'logout',
'label' => 'Logout',
'action' => route('logout'),
];
}
return $items;
}
private static function platformUrl(string $host, string $path = ''): string
{
if ($host === 'home') {
if (function_exists('ladill_home_url')) {
return ladill_home_url($path);
}
$domain = config('app.home_domain');
if (! $domain) {
$platform = (string) config('app.platform_domain', parse_url((string) config('app.url', ''), PHP_URL_HOST) ?: '');
$domain = $platform !== '' ? 'home.'.$platform : (string) config('app.account_domain');
}
return self::absoluteUrl((string) $domain, $path);
}
if (function_exists('ladill_account_url')) {
return ladill_account_url($path);
}
return self::absoluteUrl((string) config('app.account_domain'), $path);
}
/**
* Hide a menu link when the signed-in user is already on that destination.
*
* @param array{label?: string, path?: string, host?: string} $link
*/
private static function isCurrentMenuLink(array $link, string $href): bool
{
if (app()->runningInConsole() && ! app()->runningUnitTests()) {
return false;
}
$request = request();
if (! $request) {
return false;
}
$linkHost = strtolower((string) parse_url($href, PHP_URL_HOST));
$currentHost = strtolower($request->getHost());
if ($linkHost === '' || $linkHost !== $currentHost) {
return false;
}
$currentPath = trim($request->getPathInfo(), '/');
$targetPath = trim((string) parse_url($href, PHP_URL_PATH), '/');
if (($link['host'] ?? 'account') === 'home') {
return $currentPath === '';
}
if (($link['path'] ?? '') === 'dashboard') {
return in_array($currentPath, ['dashboard', 'account'], true)
|| ($targetPath !== '' && self::pathMatchesSection($currentPath, $targetPath));
}
return self::pathMatchesSection($currentPath, $targetPath);
}
private static function pathMatchesSection(string $currentPath, string $targetPath): bool
{
if ($targetPath === '') {
return $currentPath === '';
}
return $currentPath === $targetPath
|| str_starts_with($currentPath, $targetPath.'/');
}
private static function absoluteUrl(string $domain, string $path = ''): string
{
$base = 'https://'.trim($domain, '/');
if ($path === '') {
return $base;
}
return $base.'/'.ltrim($path, '/');
}
private static function userIsAdmin(Authenticatable $user): bool
{
if (method_exists($user, 'isAdmin')) {
return $user->isAdmin();
}
return (bool) ($user->is_admin ?? false);
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
use App\Models\User;
if (! function_exists('ladill_account')) {
/**
* The account the current request acts within the owner User. Defaults to
* the authenticated user; a team member who switched accounts gets the owner.
* Set by the SetActingAccount middleware.
*/
function ladill_account(): ?User
{
$request = request();
return $request->attributes->get('actingAccount') ?? $request->user();
}
}
if (! function_exists('ladill_domains_url')) {
function ladill_domains_url(string $path = ''): string
{
return 'https://'.config('app.domains_domain').($path !== '' ? '/'.ltrim($path, '/') : '');
}
}
if (! function_exists('ladill_account_url')) {
function ladill_account_url(string $path = ''): string
{
return 'https://'.config('app.account_domain').($path !== '' ? '/'.ltrim($path, '/') : '');
}
}
if (! function_exists('ladill_servers_url')) {
function ladill_servers_url(string $path = ''): string
{
return 'https://'.config('app.servers_domain').($path !== '' ? '/'.ltrim($path, '/') : '');
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace App\View\Components;
use Illuminate\View\Component;
use Illuminate\View\View;
class UserLayout extends Component
{
public function render(): View
{
return view('layouts.user');
}
}