Bootstrap Ladill Link from QR Plus extract template.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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) ?? '');
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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, '/') : '');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user