Extract Ladill QR Plus as standalone app at qr.ladill.com.
Deploy Ladill QR Plus / deploy (push) Failing after 1s

Utility QR types only (URL, WiFi, link list, business, app download) with
SSO, Billing API integration, public /q resolver, and qr-plus:import for
platform migration. vCard and commerce types stay on the platform.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-06 21:31:24 +00:00
co-authored by Cursor
commit cd6571f199
208 changed files with 30931 additions and 0 deletions
@@ -0,0 +1,161 @@
<?php
namespace App\Console\Commands;
use App\Models\QrCode;
use App\Models\QrScanEvent;
use App\Models\QrTransaction;
use App\Models\QrWallet;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class ImportQrPlusCommand extends Command
{
protected $signature = 'qr-plus:import
{file : Path to qr-plus-export.json from the platform}
{--commit : Actually write changes}';
protected $description = 'Import QR Plus codes from the platform export';
/** @var array<int,int> platform qr_code id → local id */
private array $codeMap = [];
public function handle(): int
{
$commit = (bool) $this->option('commit');
if (! $commit) {
$this->warn('DRY RUN — no changes will be written. Re-run with --commit to apply.');
}
$path = $this->argument('file');
if (! is_file($path)) {
$this->error("File not found: {$path}");
return self::FAILURE;
}
$payload = json_decode((string) file_get_contents($path), true);
if (! is_array($payload)) {
$this->error('Invalid JSON export file.');
return self::FAILURE;
}
$apply = function (callable $callback) use ($commit): void {
if ($commit) {
DB::transaction($callback);
} else {
$callback();
}
};
$apply(function () use ($payload, $commit): void {
foreach ($payload['qr_wallets'] ?? [] as $row) {
$this->importWallet($row, $commit);
}
foreach ($payload['qr_codes'] ?? [] as $row) {
$this->importCode($row, $commit);
}
foreach ($payload['qr_scan_events'] ?? [] as $row) {
$this->importScanEvent($row, $commit);
}
foreach ($payload['qr_transactions'] ?? [] as $row) {
$this->importTransaction($row, $commit);
}
});
$this->info(($commit ? 'Imported' : 'Would import').' '.count($this->codeMap).' QR code(s).');
return self::SUCCESS;
}
private function resolveUser(array $row): ?User
{
$publicId = $row['owner_public_id'] ?? null;
if (! $publicId) {
return null;
}
return User::firstOrCreate(
['public_id' => $publicId],
['email' => $row['owner_email'] ?? $publicId.'@users.ladill.com', 'name' => 'Imported user'],
);
}
/** @param array<string,mixed> $row */
private function importWallet(array $row, bool $commit): void
{
$user = $this->resolveUser($row);
if (! $user) {
return;
}
$attrs = collect($row)->except(['platform_id', 'owner_public_id', 'owner_email', 'id', 'user_id'])->all();
if ($commit) {
QrWallet::updateOrCreate(['user_id' => $user->id], $attrs);
}
}
/** @param array<string,mixed> $row */
private function importCode(array $row, bool $commit): void
{
$user = $this->resolveUser($row);
if (! $user) {
return;
}
$platformId = (int) ($row['platform_id'] ?? 0);
$shortCode = (string) ($row['short_code'] ?? '');
$attrs = collect($row)->except(['platform_id', 'owner_public_id', 'owner_email', 'id', 'user_id', 'qr_document_id'])->all();
$attrs['user_id'] = $user->id;
if ($commit) {
$code = QrCode::updateOrCreate(['short_code' => $shortCode], $attrs);
$this->codeMap[$platformId] = $code->id;
} else {
$this->codeMap[$platformId] = $platformId;
}
}
/** @param array<string,mixed> $row */
private function importScanEvent(array $row, bool $commit): void
{
$platformCodeId = (int) ($row['platform_qr_code_id'] ?? 0);
$localCodeId = $this->codeMap[$platformCodeId] ?? null;
if (! $localCodeId) {
return;
}
$attrs = collect($row)->except(['platform_id', 'platform_qr_code_id', 'id', 'qr_code_id'])->all();
$attrs['qr_code_id'] = $localCodeId;
if ($commit) {
QrScanEvent::updateOrCreate(
['qr_code_id' => $localCodeId, 'created_at' => $attrs['created_at'] ?? now()],
$attrs,
);
}
}
/** @param array<string,mixed> $row */
private function importTransaction(array $row, bool $commit): void
{
$user = $this->resolveUser($row);
if (! $user) {
return;
}
$wallet = QrWallet::firstOrCreate(['user_id' => $user->id]);
$reference = (string) ($row['reference'] ?? '');
$attrs = collect($row)->except(['platform_id', 'owner_public_id', 'id', 'user_id', 'qr_wallet_id', 'qr_code_id'])->all();
$attrs['user_id'] = $user->id;
$attrs['qr_wallet_id'] = $wallet->id;
if ($commit && $reference !== '') {
QrTransaction::updateOrCreate(['reference' => $reference], $attrs);
}
}
}
@@ -0,0 +1,140 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
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\Str;
/**
* "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
{
if (Auth::check()) {
return redirect()->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', $request->query('redirect', route('qr.dashboard')));
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
$query = http_build_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',
]);
return redirect()->away(rtrim((string) config('services.ladill_sso.issuer'), '/').'/oauth/authorize?'.$query);
}
public function callback(Request $request): RedirectResponse
{
$authLogin = 'https://'.config('app.auth_domain').'/login';
if ($request->filled('error') || ! $request->filled('code')
|| $request->query('state') !== $request->session()->pull('sso.state')) {
return redirect()->away($authLogin);
}
$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 redirect()->away($authLogin);
}
$claims = Http::withToken((string) $tokenRes->json('access_token'))->acceptJson()->get($issuer.'/oauth/userinfo');
if ($claims->failed() || ! $claims->json('sub')) {
return redirect()->away($authLogin);
}
$user = User::updateOrCreate(
['public_id' => (string) $claims->json('sub')],
[
'name' => $claims->json('name'),
'email' => $claims->json('email') ?: (string) $claims->json('sub').'@users.ladill.com',
'avatar_url' => $claims->json('picture'),
],
);
// Accept any pending team invites for this email on first sign-in.
\App\Models\HostingTeamMember::linkPendingInvitesFor($user);
Auth::login($user, remember: true);
$request->session()->regenerate();
$request->session()->forget('mailbox_link_banner_dismissed');
return redirect()->intended($request->session()->pull('sso.intended', route('qr.dashboard')));
}
public function logout(Request $request): RedirectResponse
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
$home = 'https://'.config('app.platform_domain');
$endSession = 'https://'.config('app.auth_domain').'/logout/sso?redirect='.urlencode($home);
return redirect()->away($endSession);
}
public function frontchannelLogout(Request $request): Response|RedirectResponse
{
if ($this->shouldLogoutForMailbox($request)) {
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
}
// SLO chain: if the central sequencer passed a (ladill.com) return URL,
// continue the top-level redirect chain to the next app; else acknowledge.
$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 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;
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
abstract class Controller
{
use AuthorizesRequests;
}
@@ -0,0 +1,64 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class NotificationController extends Controller
{
public function index(Request $request): View
{
$notifications = $request->user()
->notifications()
->latest()
->paginate(20);
return view('notifications.index', compact('notifications'));
}
public function unread(Request $request): JsonResponse
{
$notifications = $request->user()
->unreadNotifications()
->latest()
->take(10)
->get()
->map(fn ($n) => [
'id' => $n->id,
'type' => class_basename($n->type),
'title' => $n->data['title'] ?? 'Notification',
'message' => $n->data['message'] ?? '',
'icon' => $n->data['icon'] ?? 'bell',
'url' => $n->data['url'] ?? null,
'created_at' => $n->created_at->diffForHumans(),
]);
return response()->json([
'notifications' => $notifications,
'unread_count' => $request->user()->unreadNotifications()->count(),
]);
}
public function markAsRead(Request $request, string $id): JsonResponse
{
$notification = $request->user()
->notifications()
->where('id', $id)
->first();
if ($notification) {
$notification->markAsRead();
}
return response()->json(['success' => true]);
}
public function markAllAsRead(Request $request): JsonResponse
{
$request->user()->unreadNotifications->markAsRead();
return response()->json(['success' => true]);
}
}
@@ -0,0 +1,336 @@
<?php
namespace App\Http\Controllers\Public;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Services\Qr\QrScanRecorder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;
class QrScanController extends Controller
{
public function __construct(
private QrScanRecorder $scanRecorder,
) {}
public function resolve(Request $request, string $shortCode): RedirectResponse|View
{
$qrCode = QrCode::query()->where('short_code', $shortCode)->firstOrFail();
$this->scanRecorder->record($qrCode, $request);
if (! $qrCode->is_active) {
return view('public.qr.inactive', ['qrCode' => $qrCode]);
}
if ($qrCode->resolvesToRedirect()) {
$url = $qrCode->redirectUrl();
abort_unless($url, 404);
return redirect()->away($url);
}
if ($qrCode->isDocumentType()) {
return redirect()->route('qr.public.view', $shortCode);
}
if ($qrCode->isBookType()) {
return view('public.qr.book-landing', ['qrCode' => $qrCode]);
}
if ($qrCode->usesLandingPage()) {
return view('public.qr.landing', ['qrCode' => $qrCode]);
}
abort(404);
}
public function view(string $shortCode): View
{
$qrCode = QrCode::query()
->where('short_code', $shortCode)
->where('is_active', true)
->with('document')
->firstOrFail();
abort_unless($qrCode->isDocumentType() && $qrCode->document, 404);
return view('public.qr.document-viewer', [
'qrCode' => $qrCode,
'fileUrl' => route('qr.public.file', $shortCode),
'allowDownload' => (bool) ($qrCode->content()['allow_download'] ?? true),
]);
}
public function file(string $shortCode): StreamedResponse
{
$qrCode = QrCode::query()
->where('short_code', $shortCode)
->where('is_active', true)
->with('document')
->firstOrFail();
$document = $qrCode->document;
abort_unless($document && Storage::disk($document->disk)->exists($document->path), 404);
return Storage::disk($document->disk)->response(
$document->path,
($document->title ?: 'document') . '.pdf',
['Content-Type' => 'application/pdf'],
);
}
public function image(string $shortCode, int $index): StreamedResponse
{
$qrCode = QrCode::query()
->where('short_code', $shortCode)
->where('is_active', true)
->firstOrFail();
abort_unless($qrCode->isImageType(), 404);
$images = $qrCode->content()['images'] ?? [];
abort_unless(isset($images[$index]['path']), 404);
$path = $images[$index]['path'];
abort_unless(Storage::disk('qr')->exists($path), 404);
return Storage::disk('qr')->response($path);
}
public function itemImage(string $shortCode, int $sectionIndex, int $itemIndex): StreamedResponse
{
$qrCode = QrCode::query()
->where('short_code', $shortCode)
->where('is_active', true)
->firstOrFail();
abort_unless(in_array($qrCode->type, [QrCode::TYPE_MENU, QrCode::TYPE_SHOP], true), 404);
$sections = $qrCode->content()['sections'] ?? [];
$path = $sections[$sectionIndex]['items'][$itemIndex]['image_path'] ?? null;
abort_unless($path && Storage::disk('qr')->exists($path), 404);
return Storage::disk('qr')->response($path);
}
public function vcard(string $shortCode): StreamedResponse
{
$qrCode = QrCode::query()
->where('short_code', $shortCode)
->where('is_active', true)
->firstOrFail();
abort_unless($qrCode->type === QrCode::TYPE_VCARD, 404);
$c = $qrCode->content();
$lines = [
'BEGIN:VCARD',
'VERSION:3.0',
'N:' . ($c['last_name'] ?? '') . ';' . ($c['first_name'] ?? '') . ';;;',
'FN:' . trim(($c['first_name'] ?? '') . ' ' . ($c['last_name'] ?? '')),
];
if (! empty($c['company'])) {
$lines[] = 'ORG:' . $this->escapeVcard($c['company']);
}
if (! empty($c['phone'])) {
$lines[] = 'TEL;TYPE=CELL:' . $this->escapeVcard($c['phone']);
}
if (! empty($c['email'])) {
$lines[] = 'EMAIL;TYPE=INTERNET:' . $this->escapeVcard($c['email']);
}
if (! empty($c['website'])) {
$lines[] = 'URL:' . $this->escapeVcard($c['website']);
}
if (! empty($c['address'])) {
$lines[] = 'ADR;TYPE=WORK:;;' . $this->escapeVcard($c['address']) . ';;;;';
}
if (! empty($c['note'])) {
$lines[] = 'NOTE:' . $this->escapeVcard($c['note']);
}
$lines[] = 'END:VCARD';
$vcf = implode("\r\n", $lines) . "\r\n";
$filename = Str::slug($qrCode->label ?: 'contact') . '.vcf';
return response()->streamDownload(fn () => print($vcf), $filename, [
'Content-Type' => 'text/vcard',
]);
}
public function vcardAvatar(string $shortCode): StreamedResponse
{
$qrCode = QrCode::query()
->where('short_code', $shortCode)
->where('is_active', true)
->firstOrFail();
abort_unless($qrCode->type === QrCode::TYPE_VCARD, 404);
$path = $qrCode->content()['avatar_path'] ?? null;
abort_unless($path && Storage::disk('qr')->exists($path), 404);
return Storage::disk('qr')->response($path);
}
public function bookCover(string $shortCode): StreamedResponse
{
$qrCode = QrCode::query()
->where('short_code', $shortCode)
->where('is_active', true)
->firstOrFail();
abort_unless($qrCode->isBookType(), 404);
$path = $qrCode->content()['cover_path'] ?? null;
abort_unless($path && Storage::disk('qr')->exists($path), 404);
return Storage::disk('qr')->response($path);
}
public function menuLogo(string $shortCode): StreamedResponse
{
$qrCode = QrCode::query()
->where('short_code', $shortCode)
->where('is_active', true)
->firstOrFail();
abort_unless(in_array($qrCode->type, [QrCode::TYPE_MENU, QrCode::TYPE_SHOP], true), 404);
$path = $qrCode->content()['logo_path'] ?? null;
abort_unless($path && Storage::disk('qr')->exists($path), 404);
return Storage::disk('qr')->response($path);
}
public function menuCover(string $shortCode): StreamedResponse
{
$qrCode = QrCode::query()
->where('short_code', $shortCode)
->where('is_active', true)
->firstOrFail();
abort_unless(in_array($qrCode->type, [QrCode::TYPE_MENU, QrCode::TYPE_SHOP], true), 404);
$path = $qrCode->content()['cover_path'] ?? null;
abort_unless($path && Storage::disk('qr')->exists($path), 404);
return Storage::disk('qr')->response($path);
}
public function businessLogo(string $shortCode): StreamedResponse
{
$qrCode = QrCode::query()
->where('short_code', $shortCode)
->where('is_active', true)
->firstOrFail();
abort_unless($qrCode->type === QrCode::TYPE_BUSINESS, 404);
$path = $qrCode->content()['logo_path'] ?? null;
abort_unless($path && Storage::disk('qr')->exists($path), 404);
return Storage::disk('qr')->response($path);
}
public function businessCover(string $shortCode): StreamedResponse
{
$qrCode = QrCode::query()
->where('short_code', $shortCode)
->where('is_active', true)
->firstOrFail();
abort_unless($qrCode->type === QrCode::TYPE_BUSINESS, 404);
$path = $qrCode->content()['cover_path'] ?? null;
abort_unless($path && Storage::disk('qr')->exists($path), 404);
return Storage::disk('qr')->response($path);
}
public function churchLogo(string $shortCode): StreamedResponse
{
$qrCode = QrCode::query()
->where('short_code', $shortCode)
->where('is_active', true)
->firstOrFail();
abort_unless($qrCode->type === QrCode::TYPE_CHURCH, 404);
$path = $qrCode->content()['logo_path'] ?? null;
abort_unless($path && Storage::disk('qr')->exists($path), 404);
return Storage::disk('qr')->response($path);
}
public function churchCover(string $shortCode): StreamedResponse
{
$qrCode = QrCode::query()
->where('short_code', $shortCode)
->where('is_active', true)
->firstOrFail();
abort_unless($qrCode->type === QrCode::TYPE_CHURCH, 404);
$path = $qrCode->content()['cover_path'] ?? null;
abort_unless($path && Storage::disk('qr')->exists($path), 404);
return Storage::disk('qr')->response($path);
}
public function eventLogo(string $shortCode): StreamedResponse
{
return $this->serveContentImage($shortCode, QrCode::TYPE_EVENT, 'logo_path');
}
public function eventCover(string $shortCode): StreamedResponse
{
return $this->serveContentImage($shortCode, QrCode::TYPE_EVENT, 'cover_path');
}
public function itineraryCover(string $shortCode): StreamedResponse
{
return $this->serveContentImage($shortCode, QrCode::TYPE_ITINERARY, 'cover_path');
}
private function serveContentImage(string $shortCode, string $type, string $key): StreamedResponse
{
$qrCode = QrCode::query()
->where('short_code', $shortCode)
->where('is_active', true)
->firstOrFail();
abort_unless($qrCode->type === $type, 404);
$path = $qrCode->content()[$key] ?? null;
abort_unless($path && Storage::disk('qr')->exists($path), 404);
return Storage::disk('qr')->response($path);
}
public function appIcon(string $shortCode): StreamedResponse
{
$qrCode = QrCode::query()
->where('short_code', $shortCode)
->where('is_active', true)
->firstOrFail();
abort_unless($qrCode->type === QrCode::TYPE_APP, 404);
$path = $qrCode->content()['icon_path'] ?? null;
abort_unless($path && Storage::disk('qr')->exists($path), 404);
return Storage::disk('qr')->response($path);
}
private function escapeVcard(string $value): string
{
return str_replace(["\n", "\r", ',', ';'], [' ', ' ', '\\,', '\\;'], $value);
}
}
@@ -0,0 +1,61 @@
<?php
namespace App\Http\Controllers\Qr;
use App\Http\Controllers\Controller;
use App\Services\Billing\BillingClient;
use Illuminate\View\View;
class AccountController extends Controller
{
public function __construct(private BillingClient $billing) {}
private function topupUrl(): string
{
return 'https://'.config('app.account_domain').'/wallet';
}
/** @return array{0: int, 1: array<string, mixed>} */
private function billingSnapshot(?string $publicId): array
{
if (! $publicId) {
return [0, []];
}
$balanceMinor = $this->billing->balanceMinor($publicId);
$ledger = $this->billing->serviceLedger($publicId, 'qr');
return [$balanceMinor, $ledger];
}
public function wallet(): View
{
$user = auth()->user();
[$balanceMinor, $ledger] = $this->billingSnapshot($user?->public_id);
return view('qr.account.wallet', [
'balanceMinor' => $balanceMinor,
'spentMinor' => (int) ($ledger['spent_minor'] ?? 0),
'creditedMinor' => (int) ($ledger['credited_minor'] ?? 0),
'topupUrl' => $this->topupUrl(),
]);
}
public function billing(): View
{
$user = auth()->user();
[$balanceMinor, $ledger] = $this->billingSnapshot($user?->public_id);
return view('qr.account.billing', [
'balanceMinor' => $balanceMinor,
'spentMinor' => (int) ($ledger['spent_minor'] ?? 0),
'creditedMinor' => (int) ($ledger['credited_minor'] ?? 0),
'topupUrl' => $this->topupUrl(),
]);
}
public function settings(): View
{
return view('qr.account.settings', ['account' => auth()->user()]);
}
}
@@ -0,0 +1,49 @@
<?php
namespace App\Http\Controllers\Qr;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Services\Billing\BillingClient;
use App\Services\Qr\QrCodeManagerService;
use App\Support\Qr\QrTypeCatalog;
use Illuminate\Http\Request;
use Illuminate\View\View;
class OverviewController extends Controller
{
public function __construct(
private QrCodeManagerService $manager,
private BillingClient $billing,
) {}
public function index(Request $request): View
{
$user = $request->user();
$wallet = $this->manager->walletFor($user);
$codes = $user->qrCodes()
->whereIn('type', QrTypeCatalog::plusTypes())
->latest()
->limit(5)
->get();
$activeCount = $user->qrCodes()
->whereIn('type', QrTypeCatalog::plusTypes())
->where('is_active', true)
->count();
$scans30d = $user->qrCodes()
->whereIn('type', QrTypeCatalog::plusTypes())
->withSum(['scanEvents as scans_30d' => fn ($q) => $q->where('created_at', '>=', now()->subDays(30))], 'id')
->get()
->sum('scans_30d');
return view('qr.dashboard', [
'wallet' => $wallet,
'recentCodes' => $codes,
'activeCount' => $activeCount,
'scans30d' => (int) $scans30d,
'balanceMinor' => $this->billing->balanceMinor($user->public_id),
]);
}
}
@@ -0,0 +1,415 @@
<?php
namespace App\Http\Controllers\Qr;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Models\QrWallet;
use App\Services\Billing\BillingClient;
use App\Services\Qr\QrAnalyticsService;
use App\Services\Qr\QrCodeManagerService;
use App\Services\Qr\QrImageGeneratorService;
use App\Services\Qr\QrPdfExporter;
use App\Services\Qr\QrWalletBillingService;
use App\Support\Qr\QrCornerStyleCatalog;
use App\Support\Qr\QrFrameStyleCatalog;
use App\Support\Qr\QrModuleStyleCatalog;
use App\Support\Qr\QrTypeCatalog;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\View\View;
use RuntimeException;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
class QrCodeController extends Controller
{
public function __construct(
private QrCodeManagerService $manager,
private QrWalletBillingService $billing,
private QrAnalyticsService $analytics,
private QrImageGeneratorService $imageGenerator,
private QrPdfExporter $pdfExporter,
private BillingClient $platformBilling,
) {}
public function index(Request $request): View
{
$user = $request->user();
$wallet = $this->manager->walletFor($user);
$qrCodes = $user->qrCodes()
->whereIn('type', QrTypeCatalog::plusTypes())
->latest()
->get();
return view('qr-codes.index', [
'wallet' => $wallet,
'qrCodes' => $qrCodes,
'pricePerQr' => QrWallet::pricePerQr(),
'minTopup' => QrWallet::minTopupGhs(),
'ladillWalletBalance' => $this->platformBilling->balanceMinor($user->public_id) / 100,
'topupUrl' => 'https://'.config('app.account_domain').'/wallet',
]);
}
public function create(Request $request): View
{
$wallet = $this->manager->walletFor($request->user());
return view('qr-codes.create', [
'wallet' => $wallet,
'types' => QrTypeCatalog::all(),
'moduleStyles' => QrModuleStyleCatalog::visible(),
'cornerOuterStyles' => QrCornerStyleCatalog::outerStyles(),
'cornerInnerStyles' => QrCornerStyleCatalog::innerStyles(),
'frameStyles' => QrFrameStyleCatalog::visible(),
'pricePerQr' => QrWallet::pricePerQr(),
'minTopup' => QrWallet::minTopupGhs(),
'ladillWalletBalance' => $this->platformBilling->balanceMinor($request->user()->public_id) / 100,
'topupUrl' => 'https://'.config('app.account_domain').'/wallet',
]);
}
public function checkSlug(Request $request): JsonResponse
{
$code = (string) $request->query('code', '');
if (! preg_match('/^[a-z0-9][a-z0-9-]{1,18}[a-z0-9]$/', $code)) {
return response()->json(['available' => false, 'reason' => 'invalid']);
}
$taken = QrCode::where('short_code', $code)->exists();
return response()->json(['available' => ! $taken]);
}
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'label' => ['required', 'string', 'max:120'],
'type' => ['required', 'in:' . implode(',', QrTypeCatalog::keys())],
'custom_short_code' => ['nullable', 'string', 'regex:/^[a-z0-9][a-z0-9-]{1,18}[a-z0-9]$/', 'unique:qr_codes,short_code'],
'destination_url' => ['nullable', 'url', 'max:2048'],
'document' => ['nullable', 'file', 'mimes:pdf', 'max:102400'],
'image' => ['nullable', 'image', 'max:10240'],
'images' => ['nullable', 'array'],
'images.*' => ['image', 'max:10240'],
'item_images' => ['nullable', 'array'],
'item_images.*' => ['array'],
'item_images.*.*' => ['image', 'max:4096'],
'logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:2048'],
'menu_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
'menu_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
'business_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
'business_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
'church_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
'church_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
'event_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
'event_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
'itinerary_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
...$this->styleRules(),
]);
if ($validated['type'] === QrCode::TYPE_DOCUMENT && ! $request->hasFile('document')) {
return back()->withInput()->with('error', 'Upload a PDF for PDF QR codes.');
}
if ($validated['type'] === QrCode::TYPE_IMAGE && ! $request->hasFile('image') && ! $request->hasFile('images')) {
return back()->withInput()->with('error', 'Upload at least one image.');
}
try {
$qrCode = $this->manager->create($request->user(), array_merge(
$request->all(),
[
'style' => $validated['style'] ?? [],
'custom_short_code' => $validated['custom_short_code'] ?? null,
'avatar' => $request->file('avatar'),
'book_file' => $request->file('book_file'),
'cover' => $request->file('cover'),
'menu_logo' => $request->file('menu_logo'),
'menu_cover' => $request->file('menu_cover'),
'business_logo' => $request->file('business_logo'),
'business_cover' => $request->file('business_cover'),
'church_logo' => $request->file('church_logo'),
'church_cover' => $request->file('church_cover'),
'event_logo' => $request->file('event_logo'),
'event_cover' => $request->file('event_cover'),
'itinerary_cover' => $request->file('itinerary_cover'),
],
));
} catch (RuntimeException $e) {
if (str_contains($e->getMessage(), 'Insufficient') || str_contains($e->getMessage(), 'Add at least')) {
return back()->withInput()->with('open_topup_modal', 'qr');
}
return back()->withInput()->with('error', $e->getMessage());
}
return redirect()->route('user.qr-codes.show', $qrCode)
->with('success', 'QR code created.');
}
public function show(Request $request, QrCode $qrCode): View
{
$this->authorize('view', $qrCode);
$previewDataUri = $this->imageGenerator->previewDataUri($qrCode);
$qrCode = $qrCode->fresh();
$qrStyle = $qrCode->style();
$logoDataUri = ! empty($qrStyle['logo_path'])
? $this->imageGenerator->logoDataUri($qrStyle['logo_path'])
: null;
$wallet = $this->manager->walletFor($request->user());
$summary = $this->analytics->summaryFor($qrCode);
$dailyScans = $this->analytics->dailyScans($qrCode, 30);
$devices = $this->analytics->breakdown($qrCode, 'device_type');
$browsers = $this->analytics->breakdown($qrCode, 'browser');
$recentScans = $this->analytics->recentScans($qrCode);
return view('qr-codes.show', [
'qrCode' => $qrCode,
'previewDataUri' => $previewDataUri,
'logoDataUri' => $logoDataUri,
'types' => QrTypeCatalog::all(),
'moduleStyles' => QrModuleStyleCatalog::visible(),
'cornerOuterStyles' => QrCornerStyleCatalog::outerStyles(),
'cornerInnerStyles' => QrCornerStyleCatalog::innerStyles(),
'frameStyles' => QrFrameStyleCatalog::visible(),
'wallet' => $wallet,
'summary' => $summary,
'dailyScans' => $dailyScans,
'devices' => $devices,
'browsers' => $browsers,
'recentScans' => $recentScans,
'pricePerQr' => QrWallet::pricePerQr(),
'minTopup' => QrWallet::minTopupGhs(),
'ladillWalletBalance' => $this->platformBilling->balanceMinor($request->user()->public_id) / 100,
'topupUrl' => 'https://'.config('app.account_domain').'/wallet',
]);
}
public function update(Request $request, QrCode $qrCode): RedirectResponse
{
$this->authorize('update', $qrCode);
$validated = $request->validate([
'label' => ['sometimes', 'string', 'max:120'],
'destination_url' => ['nullable', 'url', 'max:2048'],
'document' => ['nullable', 'file', 'mimes:pdf', 'max:102400'],
'image' => ['nullable', 'image', 'max:10240'],
'images' => ['nullable', 'array'],
'images.*' => ['image', 'max:10240'],
'item_images' => ['nullable', 'array'],
'item_images.*' => ['array'],
'item_images.*.*' => ['image', 'max:4096'],
'logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:2048'],
'menu_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
'menu_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
'business_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
'business_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
'church_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
'church_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
'event_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
'event_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
'itinerary_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
'remove_logo' => ['sometimes', 'boolean'],
'is_active' => ['sometimes', 'boolean'],
...$this->styleRules(),
]);
try {
$this->manager->update($qrCode, array_merge(
$request->all(),
[
'document' => $request->file('document'),
'logo' => $request->file('logo'),
'style' => $validated['style'] ?? null,
'is_active' => $request->boolean('is_active', $qrCode->is_active),
'remove_logo' => $request->boolean('remove_logo'),
'avatar' => $request->file('avatar'),
'book_file' => $request->file('book_file'),
'cover' => $request->file('cover'),
'menu_logo' => $request->file('menu_logo'),
'menu_cover' => $request->file('menu_cover'),
'business_logo' => $request->file('business_logo'),
'business_cover' => $request->file('business_cover'),
'church_logo' => $request->file('church_logo'),
'church_cover' => $request->file('church_cover'),
'event_logo' => $request->file('event_logo'),
'event_cover' => $request->file('event_cover'),
'itinerary_cover' => $request->file('itinerary_cover'),
],
));
} catch (RuntimeException $e) {
return back()->with('error', $e->getMessage());
}
return back()->with('success', 'QR code updated.');
}
public function stylePreview(Request $request): Response
{
$validated = $request->validate(array_merge($this->styleRules(), [
'short_code' => ['nullable', 'string', 'max:16'],
'logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:2048'],
'use_existing_logo' => ['nullable', 'boolean'],
'existing_logo_path' => ['nullable', 'string', 'max:500'],
]));
$shortCode = $validated['short_code'] ?? 'preview';
$style = $validated['style'] ?? [];
$tempLogoPath = null;
if ($request->hasFile('logo')) {
$tempLogoPath = $request->file('logo')->store('tmp/previews', 'qr');
$style['logo_path'] = $tempLogoPath;
} elseif ($request->boolean('use_existing_logo') && ! empty($validated['existing_logo_path'])) {
$existingPath = $validated['existing_logo_path'];
$userPrefix = $request->user()->id . '/';
if (str_starts_with($existingPath, $userPrefix) && Storage::disk('qr')->exists($existingPath)) {
$style['logo_path'] = $existingPath;
}
}
$png = $this->imageGenerator->renderPng(QrCode::publicBaseUrl() . '/q/' . $shortCode, $style);
if ($tempLogoPath) {
Storage::disk('qr')->delete($tempLogoPath);
}
return response($png, 200, [
'Content-Type' => 'image/png',
'Cache-Control' => 'no-store',
]);
}
/** @return array<string, mixed> */
private function styleRules(): array
{
return [
'style' => ['nullable', 'array'],
'style.foreground' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'],
'style.background' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'],
'style.error_correction' => ['nullable', 'in:L,M,Q,H'],
'style.margin' => ['nullable', 'integer', 'min:0', 'max:10'],
'style.module_style' => ['nullable', 'in:' . implode(',', QrModuleStyleCatalog::keys())],
'style.finder_outer' => ['nullable', 'in:' . implode(',', array_keys(QrCornerStyleCatalog::outerStyles()))],
'style.finder_inner' => ['nullable', 'in:' . implode(',', array_keys(QrCornerStyleCatalog::innerStyles()))],
'style.frame_style' => ['nullable', 'in:' . implode(',', QrFrameStyleCatalog::keys())],
'style.frame_text' => ['nullable', 'string', 'max:100'],
'style.frame_color' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'],
'style.scale' => ['nullable', 'integer', 'min:4', 'max:16'],
'style.gradient_type' => ['nullable', 'in:none,linear,radial'],
'style.gradient_color1' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'],
'style.gradient_color2' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'],
'style.gradient_rotation' => ['nullable', 'integer', 'min:0', 'max:360'],
'style.logo_size' => ['nullable', 'numeric', 'min:0.1', 'max:0.4'],
'style.logo_margin' => ['nullable', 'integer', 'min:0', 'max:15'],
'style.logo_white_bg' => ['nullable', 'boolean'],
'style.logo_shape' => ['nullable', 'in:none,rounded,circle'],
];
}
public function preview(QrCode $qrCode): Response
{
$this->authorize('view', $qrCode);
$qrCode = $this->imageGenerator->ensureValidImages($qrCode);
$bytes = $this->imageGenerator->normalizeStoredPng($qrCode->png_path);
if ($bytes === null) {
$bytes = $this->imageGenerator->renderPng($qrCode->encodedPayload(), $qrCode->style());
$this->imageGenerator->generateAndStore($qrCode);
}
return response($bytes, 200, [
'Content-Type' => 'image/png',
'Cache-Control' => 'private, max-age=3600',
]);
}
public function download(QrCode $qrCode, string $format): StreamedResponse
{
$this->authorize('view', $qrCode);
$qrCode = $this->imageGenerator->ensureValidImages($qrCode);
$path = $format === 'svg' ? $qrCode->svg_path : $qrCode->png_path;
$filename = Str::slug($qrCode->label) . '-qr.' . ($format === 'svg' ? 'svg' : 'png');
if ($format === 'png') {
$bytes = $this->imageGenerator->normalizeStoredPng($path);
if ($bytes === null) {
$bytes = $this->imageGenerator->renderPng($qrCode->encodedPayload(), $qrCode->style());
$this->imageGenerator->generateAndStore($qrCode);
}
return response()->streamDownload(fn () => print($bytes), $filename, [
'Content-Type' => 'image/png',
]);
}
if ($format === 'pdf') {
$bytes = $this->imageGenerator->normalizeStoredPng($qrCode->png_path);
if ($bytes === null) {
$bytes = $this->imageGenerator->renderPng($qrCode->encodedPayload(), $qrCode->style());
$this->imageGenerator->generateAndStore($qrCode);
}
$pdf = $this->pdfExporter->fromPng($bytes, $qrCode->label);
$filename = Str::slug($qrCode->label) . '-qr.pdf';
return response()->streamDownload(fn () => print($pdf), $filename, [
'Content-Type' => 'application/pdf',
]);
}
abort_unless($path && Storage::disk('qr')->exists($path), 404);
return Storage::disk('qr')->download($path, $filename);
}
/**
* Persist the exact client-rendered QR (qr-code-styling) as the canonical
* SVG + PNG so downloads match the preview pixel-for-pixel. The browser
* renders with the real /q/{code} URL on the show page, then posts here.
*/
public function storeCanonicalImage(Request $request, QrCode $qrCode): JsonResponse
{
$this->authorize('update', $qrCode);
$validated = $request->validate([
'svg' => ['required', 'string', 'max:600000'],
'png' => ['required', 'string', 'max:4000000'],
]);
$svg = $this->imageGenerator->sanitizeSvg($validated['svg']);
if ($svg === null) {
return response()->json(['error' => 'Invalid SVG.'], 422);
}
$pngB64 = $validated['png'];
if (str_contains($pngB64, ',')) {
$pngB64 = substr($pngB64, strpos($pngB64, ',') + 1);
}
$png = base64_decode($pngB64, true);
if ($png === false || ! $this->imageGenerator->isValidPngBinary($png)) {
return response()->json(['error' => 'Invalid PNG.'], 422);
}
$basePath = $qrCode->user_id . '/codes/' . $qrCode->id;
Storage::disk('qr')->put($basePath . '/qr.svg', $svg);
Storage::disk('qr')->put($basePath . '/qr.png', $png);
$qrCode->update([
'svg_path' => $basePath . '/qr.svg',
'png_path' => $basePath . '/qr.png',
]);
return response()->json(['ok' => true]);
}
}
+219
View File
@@ -0,0 +1,219 @@
<?php
namespace App\Models;
use App\Support\Qr\QrStyleDefaults;
use App\Support\Qr\QrTypeCatalog;
use App\Support\Qr\QrWifiPayload;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class QrCode extends Model
{
public const TYPE_URL = 'url';
public const TYPE_DOCUMENT = 'document';
public const TYPE_LINK_LIST = 'link_list';
public const TYPE_VCARD = 'vcard';
public const TYPE_BUSINESS = 'business';
public const TYPE_IMAGE = 'image';
public const TYPE_CHURCH = 'church';
public const TYPE_MENU = 'menu';
public const TYPE_SHOP = 'shop';
public const TYPE_APP = 'app';
public const TYPE_BOOK = 'book';
public const TYPE_WIFI = 'wifi';
public const TYPE_COUPON = 'coupon';
public const TYPE_EVENT = 'event';
public const TYPE_ITINERARY = 'itinerary';
protected $fillable = [
'user_id',
'short_code',
'type',
'label',
'destination_url',
'qr_document_id',
'payload',
'is_active',
'png_path',
'svg_path',
'scans_total',
'unique_scans_total',
'last_scanned_at',
'destination_updated_at',
];
protected $casts = [
'payload' => 'array',
'is_active' => 'boolean',
'scans_total' => 'integer',
'unique_scans_total' => 'integer',
'last_scanned_at' => 'datetime',
'destination_updated_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function document(): BelongsTo
{
return $this->belongsTo(QrDocument::class, 'qr_document_id');
}
public function scanEvents(): HasMany
{
return $this->hasMany(QrScanEvent::class);
}
public function transactions(): HasMany
{
return $this->hasMany(QrTransaction::class);
}
public function saleOrders(): HasMany
{
return $this->hasMany(QrSaleOrder::class);
}
public function eventRegistrations(): HasMany
{
return $this->hasMany(QrEventRegistration::class);
}
public function publicUrl(): string
{
return self::publicBaseUrl() . '/q/' . $this->short_code;
}
/**
* Base URL for public QR links. Always the short platform domain
* (ladill.com) never the signed-in account/product host so printed
* codes and shared links stay short and host-independent of where the QR
* was created.
*/
public static function publicBaseUrl(): string
{
$appUrl = (string) config('app.url');
$scheme = parse_url($appUrl, PHP_URL_SCHEME) ?: 'https';
$host = (string) config('app.platform_domain')
?: (parse_url($appUrl, PHP_URL_HOST) ?: 'ladill.com');
return $scheme . '://' . $host;
}
/**
* String encoded inside the QR image.
*
* Most types encode the stable short link so the printed code never changes
* when content is edited. WiFi is the exception: it bakes the network
* credentials directly into the image so devices auto-join on scan. That
* payload is frozen at creation (payload.wifi_encoded) editing the network
* afterwards updates the saved info but never re-encodes the printed code.
*/
public function encodedPayload(): string
{
if ($this->type === self::TYPE_WIFI) {
$frozen = $this->payload['wifi_encoded'] ?? null;
return is_string($frozen) && $frozen !== ''
? $frozen
: QrWifiPayload::encode($this->content());
}
return $this->publicUrl();
}
/** WiFi codes encode their join payload directly (auto-join), not a redirect link. */
public function encodesDirectPayload(): bool
{
return $this->type === self::TYPE_WIFI;
}
public function typeLabel(): string
{
return QrTypeCatalog::label($this->type);
}
/** @return array<string, mixed> */
public function content(): array
{
return (array) ($this->payload['content'] ?? []);
}
/** @return array<string, mixed> */
public function style(): array
{
return QrStyleDefaults::merge($this->payload['style'] ?? null);
}
public function isUrlType(): bool
{
return $this->type === self::TYPE_URL;
}
public function isDocumentType(): bool
{
return $this->type === self::TYPE_DOCUMENT;
}
public function isImageType(): bool
{
return $this->type === self::TYPE_IMAGE;
}
public function isMenuType(): bool
{
return $this->type === self::TYPE_MENU;
}
public function isShopType(): bool
{
return $this->type === self::TYPE_SHOP;
}
public function isBookType(): bool
{
return $this->type === self::TYPE_BOOK;
}
public function acceptsOrders(): bool
{
return in_array($this->type, [self::TYPE_MENU, self::TYPE_SHOP, self::TYPE_CHURCH, self::TYPE_EVENT], true);
}
public function usesLandingPage(): bool
{
return in_array($this->type, [
self::TYPE_DOCUMENT,
self::TYPE_LINK_LIST,
self::TYPE_VCARD,
self::TYPE_BUSINESS,
self::TYPE_CHURCH,
self::TYPE_IMAGE,
self::TYPE_MENU,
self::TYPE_SHOP,
self::TYPE_APP,
self::TYPE_BOOK,
self::TYPE_WIFI,
self::TYPE_EVENT,
self::TYPE_ITINERARY,
], true);
}
public function resolvesToRedirect(): bool
{
return $this->type === self::TYPE_URL;
}
public function redirectUrl(): ?string
{
if ($this->destination_url) {
return $this->destination_url;
}
return $this->content()['url'] ?? null;
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/** Stub — document/file QRs belong to Ladill Transfer, not QR Plus. */
class QrDocument extends Model
{
protected $guarded = [];
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class QrScanEvent extends Model
{
protected $fillable = [
'qr_code_id',
'scanned_at',
'ip_hash',
'user_agent',
'device_type',
'browser',
'os',
'country_code',
'referrer',
'is_unique',
];
protected $casts = [
'scanned_at' => 'datetime',
'is_unique' => 'boolean',
];
public function qrCode(): BelongsTo
{
return $this->belongsTo(QrCode::class);
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class QrTransaction extends Model
{
public const TYPE_CREDIT = 'credit';
public const TYPE_DEBIT = 'debit';
protected $fillable = [
'user_id',
'qr_wallet_id',
'qr_code_id',
'type',
'amount_ghs',
'balance_after_ghs',
'reference',
'status',
'description',
'metadata',
];
protected $casts = [
'amount_ghs' => 'decimal:4',
'balance_after_ghs' => 'decimal:4',
'metadata' => 'array',
];
public function wallet(): BelongsTo
{
return $this->belongsTo(QrWallet::class, 'qr_wallet_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function qrCode(): BelongsTo
{
return $this->belongsTo(QrCode::class);
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class QrWallet extends Model
{
public const STATUS_ACTIVE = 'active';
public const STATUS_SUSPENDED = 'suspended';
protected $fillable = [
'user_id',
'credit_balance',
'qr_codes_total',
'scans_total',
'status',
];
protected $casts = [
'credit_balance' => 'decimal:4',
'qr_codes_total' => 'integer',
'scans_total' => 'integer',
];
public static function pricePerQr(): float
{
return (float) config('qr.price_per_qr_ghs', 5.0);
}
public static function minTopupGhs(): float
{
return (float) config('qr.min_topup_ghs', 5.0);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function transactions(): HasMany
{
return $this->hasMany(QrTransaction::class)->latest();
}
/**
* Single-wallet (siloing step 2): QR spends from the one UserWallet (tagged
* 'qr'). Delegates to the billing service (lazily folds any legacy
* credit_balance in). `spendableBalance()` is the unified balance for display.
*/
public function spendableBalance(): float
{
return $this->user
? app(\App\Services\Qr\QrWalletBillingService::class)->balanceCedis($this->user)
: (float) $this->credit_balance;
}
public function canCreateQr(): bool
{
return $this->user
? app(\App\Services\Qr\QrWalletBillingService::class)->canCreate($this->user)
: false;
}
}
+47
View File
@@ -0,0 +1,47 @@
<?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 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 qrWallet(): HasOne
{
return $this->hasOne(QrWallet::class);
}
public function qrCodes(): HasMany
{
return $this->hasMany(QrCode::class);
}
public function getOrCreateQrWallet(): QrWallet
{
return $this->qrWallet()->firstOrCreate(
[],
['credit_balance' => 0, 'qr_codes_total' => 0, 'scans_total' => 0, 'status' => QrWallet::STATUS_ACTIVE],
);
}
}
@@ -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),
];
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Policies;
use App\Models\QrCode;
use App\Models\User;
class QrCodePolicy
{
public function view(User $user, QrCode $qrCode): bool
{
return $user->id === $qrCode->user_id;
}
public function update(User $user, QrCode $qrCode): bool
{
return $user->id === $qrCode->user_id;
}
public function delete(User $user, QrCode $qrCode): bool
{
return $user->id === $qrCode->user_id;
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace App\Providers;
use App\Models\QrCode;
use App\Policies\QrCodePolicy;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
//
}
public function boot(): void
{
Gate::policy(QrCode::class, QrCodePolicy::class);
}
}
+87
View File
@@ -0,0 +1,87 @@
<?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);
}
public function canAfford(string $publicId, int $amountMinor): bool
{
return (bool) ($this->get('/can-afford', ['user' => $publicId, 'amount_minor' => $amountMinor])['affordable'] ?? false);
}
public function serviceLedger(string $publicId, string $service): array
{
return $this->get('/service-ledger', ['user' => $publicId, 'service' => $service]);
}
/**
* Debit the wallet. Returns true on success, false on insufficient balance
* (HTTP 402). Idempotent by $reference.
*/
public function debit(string $publicId, int $amountMinor, string $service, string $source, string $reference, ?int $serviceId = null, ?string $description = null): bool
{
$res = Http::withToken($this->token())->acceptJson()->timeout(10)->post($this->base().'/debit', array_filter([
'user' => $publicId,
'amount_minor' => $amountMinor,
'service' => $service,
'source' => $source,
'reference' => $reference,
'service_id' => $serviceId,
'description' => $description,
], static fn ($v) => $v !== null));
if ($res->status() === 402) {
return false;
}
$res->throw();
return true;
}
public function credit(string $publicId, int $amountMinor, string $service, string $source, string $reference, ?int $serviceId = null, ?string $description = null): array
{
$res = Http::withToken($this->token())->acceptJson()->timeout(10)->post($this->base().'/credit', array_filter([
'user' => $publicId,
'amount_minor' => $amountMinor,
'service' => $service,
'source' => $source,
'reference' => $reference,
'service_id' => $serviceId,
'description' => $description,
], static fn ($v) => $v !== null));
$res->throw();
return (array) $res->json();
}
}
+164
View File
@@ -0,0 +1,164 @@
<?php
namespace App\Services\Billing;
use App\Models\PlatformSetting;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Schema;
use RuntimeException;
class PaystackService
{
public function publicKey(): string
{
return (string) $this->settingValue('paystack_public_key', config('services.paystack.public_key', ''));
}
public function initializeTransaction(array $payload): array
{
return $this->initializeTransactionWithCredentials($payload);
}
public function initializeTransactionWithCredentials(array $payload, ?string $secretKey = null, ?string $baseUrl = null): array
{
$response = $this->request(secretKey: $secretKey, baseUrl: $baseUrl)
->post('/transaction/initialize', $payload);
return $this->extractData($response, 'Failed to initialize Paystack transaction.');
}
public function verifyTransaction(string $reference): array
{
return $this->verifyTransactionWithCredentials($reference);
}
public function verifyTransactionWithCredentials(string $reference, ?string $secretKey = null, ?string $baseUrl = null): array
{
$response = $this->request(secretKey: $secretKey, baseUrl: $baseUrl)
->get('/transaction/verify/' . urlencode($reference));
return $this->extractData($response, 'Failed to verify Paystack transaction.');
}
public function verifyWebhookSignature(string $rawBody, ?string $signature): bool
{
$secret = (string) $this->settingValue('paystack_webhook_secret', config('services.paystack.webhook_secret'));
if ($secret === '') {
$secret = (string) $this->settingValue('paystack_secret_key', config('services.paystack.secret_key'));
}
if ($secret === '' || !$signature) {
return false;
}
$computed = hash_hmac('sha512', $rawBody, $secret);
return hash_equals($computed, $signature);
}
protected function request(?string $secretKey = null, ?string $baseUrl = null)
{
$resolvedBaseUrl = rtrim((string) ($baseUrl ?: $this->settingValue('paystack_base_url', config('services.paystack.base_url', 'https://api.paystack.co'))), '/');
$secret = $this->normalizeSecretKey(
(string) ($secretKey ?: $this->settingValue('paystack_secret_key', config('services.paystack.secret_key')))
);
return Http::baseUrl($resolvedBaseUrl)
->withToken($secret)
->acceptJson()
->asJson();
}
protected function settingValue(string $key, mixed $fallback = null): mixed
{
try {
if (!Schema::hasTable('platform_settings')) {
return $fallback;
}
$setting = PlatformSetting::query()
->where('key', $key)
->where('is_active', true)
->first();
return $setting ? ($setting->value['value'] ?? $fallback) : $fallback;
} catch (\Throwable) {
return $fallback;
}
}
protected function extractData(Response $response, string $message): array
{
if ($response->failed()) {
throw new RuntimeException($message);
}
$json = $response->json();
if (!is_array($json) || !($json['status'] ?? false)) {
throw new RuntimeException((string) ($json['message'] ?? $message));
}
$data = $json['data'] ?? null;
return is_array($data) ? $data : [];
}
/**
* List supported banks / mobile money providers.
* @param string $country e.g. 'ghana', 'nigeria'
* @param string $type 'nuban', 'mobile_money', or '' for all
*/
public function listBanks(string $country = 'ghana', string $type = '', ?string $currency = null): array
{
$query = ['perPage' => 200];
if ($currency !== null && $currency !== '') {
$query['currency'] = strtoupper($currency);
} else {
$query['country'] = $country;
}
if ($type !== '') {
$query['type'] = $type;
}
$response = $this->request()->get('/bank', $query);
$json = $response->json();
return is_array($json['data'] ?? null) ? $json['data'] : [];
}
/**
* Create a transfer recipient (bank account or mobile money).
* @param array{type: string, name: string, account_number: string, bank_code: string, currency?: string} $payload
*/
public function createTransferRecipient(array $payload): array
{
$response = $this->request()->post('/transferrecipient', $payload);
return $this->extractData($response, 'Failed to create transfer recipient.');
}
/**
* Initiate a transfer to a recipient.
* @param array{source: string, amount: int, recipient: string, reason?: string, reference?: string} $payload
*/
public function initiateTransfer(array $payload): array
{
$response = $this->request()->post('/transfer', $payload);
return $this->extractData($response, 'Failed to initiate transfer.');
}
/**
* Verify a transfer by reference.
*/
public function verifyTransfer(string $reference): array
{
$response = $this->request()->get('/transfer/verify/' . urlencode($reference));
return $this->extractData($response, 'Failed to verify transfer.');
}
protected function normalizeSecretKey(string $secret): string
{
$normalized = trim($secret);
if (str_starts_with(strtolower($normalized), 'bearer ')) {
$normalized = trim(substr($normalized, 7));
}
return $normalized;
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
namespace App\Services\Identity;
use Illuminate\Support\Facades\Http;
class IdentityClient
{
/** @return array<string, mixed> */
public function mailboxLinkStatus(string $publicId): array
{
$response = $this->request()->get($this->url('/identity/mailbox-link'), [
'user' => $publicId,
]);
$response->throw();
return (array) $response->json('data', []);
}
/** @return array<string, mixed> */
public function linkMailbox(string $publicId, string $mailboxAddress): array
{
$response = $this->request()->put($this->url('/identity/mailbox-link'), [
'user' => $publicId,
'mailbox_address' => $mailboxAddress,
]);
$response->throw();
return (array) $response->json('data', []);
}
/** @return array<string, mixed> */
public function unlinkMailbox(string $publicId): array
{
$response = $this->request()->delete($this->url('/identity/mailbox-link'), [
'user' => $publicId,
]);
$response->throw();
return (array) $response->json('data', []);
}
private function request()
{
return Http::withToken((string) config('identity.api_key'))
->acceptJson()
->timeout(15);
}
private function url(string $path): string
{
return rtrim((string) config('identity.api_url'), '/').$path;
}
}
+91
View File
@@ -0,0 +1,91 @@
<?php
namespace App\Services\Qr;
use App\Models\QrCode;
use App\Models\QrScanEvent;
use App\Models\QrWallet;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class QrAnalyticsService
{
/**
* @return array<string, mixed>
*/
public function summaryFor(QrCode $qrCode): array
{
$now = now();
$events = QrScanEvent::query()->where('qr_code_id', $qrCode->id);
return [
'total_scans' => (int) $qrCode->scans_total,
'unique_scans' => (int) $qrCode->unique_scans_total,
'scans_7d' => (clone $events)->where('scanned_at', '>=', $now->copy()->subDays(7))->count(),
'scans_30d' => (clone $events)->where('scanned_at', '>=', $now->copy()->subDays(30))->count(),
'last_scanned_at' => $qrCode->last_scanned_at,
];
}
/**
* @return Collection<int, object{date: string, total: int}>
*/
public function dailyScans(QrCode $qrCode, int $days = 30): Collection
{
$start = now()->subDays($days - 1)->startOfDay();
$rows = QrScanEvent::query()
->selectRaw('DATE(scanned_at) as scan_date, COUNT(*) as total')
->where('qr_code_id', $qrCode->id)
->where('scanned_at', '>=', $start)
->groupBy('scan_date')
->orderBy('scan_date')
->get()
->keyBy('scan_date');
$series = collect();
for ($i = 0; $i < $days; $i++) {
$date = $start->copy()->addDays($i)->toDateString();
$series->push((object) [
'date' => $date,
'total' => (int) ($rows->get($date)?->total ?? 0),
]);
}
return $series;
}
/**
* @return array<int, array{label: string, total: int}>
*/
public function breakdown(QrCode $qrCode, string $column, int $limit = 5): array
{
return QrScanEvent::query()
->select($column, DB::raw('COUNT(*) as total'))
->where('qr_code_id', $qrCode->id)
->whereNotNull($column)
->groupBy($column)
->orderByDesc('total')
->limit($limit)
->get()
->map(fn ($row) => [
'label' => (string) $row->{$column},
'total' => (int) $row->total,
])
->all();
}
/**
* @return \Illuminate\Database\Eloquent\Collection<int, QrScanEvent>
*/
public function recentScans(QrCode $qrCode, int $limit = 20)
{
return QrScanEvent::query()
->where('qr_code_id', $qrCode->id)
->latest('scanned_at')
->limit($limit)
->get();
}
}
+523
View File
@@ -0,0 +1,523 @@
<?php
namespace App\Services\Qr;
use App\Models\QrCode;
use App\Models\QrDocument;
use App\Models\QrWallet;
use App\Models\User;
use App\Support\Qr\QrStyleDefaults;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use RuntimeException;
class QrCodeManagerService
{
public function __construct(
private QrWalletBillingService $billing,
private QrImageGeneratorService $imageGenerator,
private QrPayloadValidator $payloadValidator,
) {}
public function walletFor(User $user): QrWallet
{
return $user->qrWallet()->firstOrCreate(
[],
['credit_balance' => 0, 'status' => QrWallet::STATUS_ACTIVE],
);
}
/**
* @param array<string, mixed> $data
*/
public function create(User $user, array $data): QrCode
{
$wallet = $this->walletFor($user);
if (! $wallet->canCreateQr()) {
throw new RuntimeException('Add at least GHS ' . number_format(QrWallet::pricePerQr(), 2) . ' to your QR wallet before creating codes.');
}
$type = (string) ($data['type'] ?? '');
$validated = $this->payloadValidator->validateForCreate($type, $data);
$style = QrStyleDefaults::merge($data['style'] ?? null);
return DB::transaction(function () use ($user, $wallet, $data, $type, $validated, $style) {
$documentId = null;
$content = $validated['content'];
if ($type === QrCode::TYPE_DOCUMENT) {
$file = $data['document'] ?? null;
if (! $file instanceof UploadedFile) {
throw new RuntimeException('A PDF document is required for PDF QR codes.');
}
$documentId = $this->storeDocument($user, $file, $data['label'] ?? 'Document')->id;
}
if ($type === QrCode::TYPE_IMAGE) {
$images = $this->storeImages($user, $data);
$content['images'] = $images;
}
if ($type === QrCode::TYPE_VCARD && ($data['avatar'] ?? null) instanceof UploadedFile) {
$content['avatar_path'] = $this->storeVcardAvatar($user, $data['avatar']);
}
if ($type === QrCode::TYPE_BOOK) {
$bookFile = $data['book_file'] ?? null;
if (! $bookFile instanceof UploadedFile) {
throw new RuntimeException('Upload the book file (PDF or EPUB).');
}
$bookData = $this->storeBookFile($user, $bookFile);
$content['file_path'] = $bookData['path'];
$content['file_type'] = $bookData['type'];
$content['file_size'] = $bookData['size'];
if (($data['cover'] ?? null) instanceof UploadedFile) {
$content['cover_path'] = $this->storeBookCover($user, $data['cover']);
}
}
if ($type === QrCode::TYPE_APP && ($data['app_icon'] ?? null) instanceof UploadedFile) {
$content['icon_path'] = $this->storeMenuBrandImage($user, $data['app_icon'], 'app-icons');
}
if (in_array($type, [QrCode::TYPE_MENU, QrCode::TYPE_SHOP], true)) {
$content['sections'] = $this->injectItemImages($user, $content['sections'] ?? [], $data, $type);
if (($data['menu_logo'] ?? null) instanceof UploadedFile) {
$content['logo_path'] = $this->storeMenuBrandImage($user, $data['menu_logo'], 'menu-logos');
}
if (($data['menu_cover'] ?? null) instanceof UploadedFile) {
$content['cover_path'] = $this->storeMenuBrandImage($user, $data['menu_cover'], 'menu-covers');
}
}
if ($type === QrCode::TYPE_BUSINESS) {
if (($data['business_logo'] ?? null) instanceof UploadedFile) {
$content['logo_path'] = $this->storeMenuBrandImage($user, $data['business_logo'], 'business-logos');
}
if (($data['business_cover'] ?? null) instanceof UploadedFile) {
$content['cover_path'] = $this->storeMenuBrandImage($user, $data['business_cover'], 'business-covers');
}
}
if ($type === QrCode::TYPE_CHURCH) {
if (($data['church_logo'] ?? null) instanceof UploadedFile) {
$content['logo_path'] = $this->storeMenuBrandImage($user, $data['church_logo'], 'church-logos');
}
if (($data['church_cover'] ?? null) instanceof UploadedFile) {
$content['cover_path'] = $this->storeMenuBrandImage($user, $data['church_cover'], 'church-covers');
}
}
if ($type === QrCode::TYPE_EVENT) {
if (($data['event_logo'] ?? null) instanceof UploadedFile) {
$content['logo_path'] = $this->storeMenuBrandImage($user, $data['event_logo'], 'event-logos');
}
if (($data['event_cover'] ?? null) instanceof UploadedFile) {
$content['cover_path'] = $this->storeMenuBrandImage($user, $data['event_cover'], 'event-covers');
}
}
if ($type === QrCode::TYPE_ITINERARY && ($data['itinerary_cover'] ?? null) instanceof UploadedFile) {
$content['cover_path'] = $this->storeMenuBrandImage($user, $data['itinerary_cover'], 'itinerary-covers');
}
if ($style['logo_path'] === null && ($data['logo'] ?? null) instanceof UploadedFile) {
$style['logo_path'] = $this->storeLogo($user, $data['logo']);
}
$shortCode = (isset($data['custom_short_code']) && $data['custom_short_code'] !== '')
? (string) $data['custom_short_code']
: $this->generateUniqueShortCode();
$payload = [
'content' => $content,
'style' => $style,
];
// Freeze the WiFi auto-join payload so the printed code never changes on edit.
if ($type === QrCode::TYPE_WIFI) {
$payload['wifi_encoded'] = \App\Support\Qr\QrWifiPayload::encode($content);
}
$qrCode = QrCode::create([
'user_id' => $user->id,
'short_code' => $shortCode,
'type' => $type,
'label' => trim((string) $data['label']),
'destination_url' => $validated['destination_url'],
'qr_document_id' => $documentId,
'payload' => $payload,
'is_active' => true,
'destination_updated_at' => now(),
]);
$this->billing->debitForQrCreation($wallet, $qrCode);
$this->imageGenerator->generateAndStore($qrCode);
return $qrCode->fresh(['document']);
});
}
/**
* @param array<string, mixed> $data
*/
public function update(QrCode $qrCode, array $data): QrCode
{
$content = $qrCode->content();
$style = $qrCode->style();
$destinationUrl = $qrCode->destination_url;
$regenerate = false;
if (isset($data['label']) && trim((string) $data['label']) !== '') {
$qrCode->label = trim((string) $data['label']);
}
if (array_key_exists('is_active', $data)) {
$qrCode->is_active = (bool) $data['is_active'];
}
$typeFields = array_merge($content, $data);
if ($this->hasContentChanges($qrCode, $data)) {
$validated = $this->payloadValidator->validateForUpdate($qrCode, $typeFields);
$content = $validated['content'];
$destinationUrl = $validated['destination_url'];
$qrCode->destination_updated_at = now();
}
if ($qrCode->isDocumentType() && isset($data['document']) && $data['document'] instanceof UploadedFile) {
$document = $this->storeDocument($qrCode->user, $data['document'], $data['label'] ?? $qrCode->label);
$qrCode->qr_document_id = $document->id;
$qrCode->destination_updated_at = now();
}
if ($qrCode->isImageType()) {
$newImages = $this->storeImages($qrCode->user, $data, false);
if ($newImages !== []) {
$content['images'] = array_merge($content['images'] ?? [], $newImages);
$qrCode->destination_updated_at = now();
}
}
if ($qrCode->type === QrCode::TYPE_VCARD && ($data['avatar'] ?? null) instanceof UploadedFile) {
if ($oldPath = $content['avatar_path'] ?? null) {
Storage::disk('qr')->delete($oldPath);
}
$content['avatar_path'] = $this->storeVcardAvatar($qrCode->user, $data['avatar']);
}
if ($qrCode->type === QrCode::TYPE_BOOK) {
if (($data['book_file'] ?? null) instanceof UploadedFile) {
$bookData = $this->storeBookFile($qrCode->user, $data['book_file']);
$content['file_path'] = $bookData['path'];
$content['file_type'] = $bookData['type'];
$content['file_size'] = $bookData['size'];
$qrCode->destination_updated_at = now();
}
if (($data['cover'] ?? null) instanceof UploadedFile) {
$content['cover_path'] = $this->storeBookCover($qrCode->user, $data['cover']);
}
}
if ($qrCode->type === QrCode::TYPE_APP && ($data['app_icon'] ?? null) instanceof UploadedFile) {
$content['icon_path'] = $this->storeMenuBrandImage($qrCode->user, $data['app_icon'], 'app-icons');
}
if (in_array($qrCode->type, [QrCode::TYPE_MENU, QrCode::TYPE_SHOP], true)) {
$content['sections'] = $this->injectItemImages($qrCode->user, $content['sections'] ?? [], $data, $qrCode->type);
if (($data['menu_logo'] ?? null) instanceof UploadedFile) {
$content['logo_path'] = $this->storeMenuBrandImage($qrCode->user, $data['menu_logo'], 'menu-logos');
}
if (($data['menu_cover'] ?? null) instanceof UploadedFile) {
$content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['menu_cover'], 'menu-covers');
}
}
if ($qrCode->type === QrCode::TYPE_BUSINESS) {
if (($data['business_logo'] ?? null) instanceof UploadedFile) {
$content['logo_path'] = $this->storeMenuBrandImage($qrCode->user, $data['business_logo'], 'business-logos');
}
if (($data['business_cover'] ?? null) instanceof UploadedFile) {
$content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['business_cover'], 'business-covers');
}
}
if ($qrCode->type === QrCode::TYPE_CHURCH) {
if (($data['church_logo'] ?? null) instanceof UploadedFile) {
$content['logo_path'] = $this->storeMenuBrandImage($qrCode->user, $data['church_logo'], 'church-logos');
}
if (($data['church_cover'] ?? null) instanceof UploadedFile) {
$content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['church_cover'], 'church-covers');
}
}
if ($qrCode->type === QrCode::TYPE_EVENT) {
if (($data['event_logo'] ?? null) instanceof UploadedFile) {
$content['logo_path'] = $this->storeMenuBrandImage($qrCode->user, $data['event_logo'], 'event-logos');
}
if (($data['event_cover'] ?? null) instanceof UploadedFile) {
$content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['event_cover'], 'event-covers');
}
}
if ($qrCode->type === QrCode::TYPE_ITINERARY && ($data['itinerary_cover'] ?? null) instanceof UploadedFile) {
$content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['itinerary_cover'], 'itinerary-covers');
}
if (isset($data['style']) && is_array($data['style'])) {
$style = QrStyleDefaults::merge(array_merge($style, $data['style']));
$regenerate = true;
}
if (($data['logo'] ?? null) instanceof UploadedFile) {
$style['logo_path'] = $this->storeLogo($qrCode->user, $data['logo']);
$regenerate = true;
}
if (($data['remove_logo'] ?? false) && $style['logo_path']) {
$style['logo_path'] = null;
$regenerate = true;
}
$qrCode->destination_url = $destinationUrl;
// Preserve frozen keys (e.g. wifi_encoded) so the printed WiFi code stays put.
$payload = (array) ($qrCode->payload ?? []);
$payload['content'] = $content;
$payload['style'] = $style;
$qrCode->payload = $payload;
$qrCode->save();
if ($regenerate) {
$this->imageGenerator->generateAndStore($qrCode);
}
return $qrCode->fresh(['document']);
}
/**
* Inject uploaded item images into the sections array.
* Form field: item_images[sectionIndex][itemIndex] (UploadedFile)
*
* @param array<int, mixed> $sections
* @param array<string, mixed> $data
* @return array<int, mixed>
*/
private function injectItemImages(User $user, array $sections, array $data, string $type): array
{
$uploads = $data['item_images'] ?? [];
if (! is_array($uploads) || $uploads === []) {
return $sections;
}
foreach ($uploads as $sIndex => $itemUploads) {
if (! is_array($itemUploads)) {
continue;
}
foreach ($itemUploads as $iIndex => $file) {
if (! ($file instanceof UploadedFile)) {
continue;
}
if (! isset($sections[$sIndex]['items'][$iIndex])) {
continue;
}
$mime = $file->getMimeType() ?: '';
if (! str_starts_with($mime, 'image/')) {
continue;
}
$subdir = $type === QrCode::TYPE_SHOP ? 'shop-items' : 'menu-items';
$ext = $file->getClientOriginalExtension() ?: 'jpg';
$path = $user->id . '/' . $subdir . '/' . Str::uuid()->toString() . '.' . $ext;
$file->storeAs('', $path, 'qr');
$sections[$sIndex]['items'][$iIndex]['image_path'] = $path;
}
}
return $sections;
}
/** @param array<string, mixed> $data */
private function hasContentChanges(QrCode $qrCode, array $data): bool
{
$keys = match ($qrCode->type) {
QrCode::TYPE_URL => ['destination_url'],
QrCode::TYPE_LINK_LIST => ['links'],
QrCode::TYPE_VCARD => ['first_name', 'last_name', 'phone', 'email', 'company', 'website', 'address', 'note', 'social'],
QrCode::TYPE_BUSINESS => ['name', 'tagline', 'phone', 'email', 'website', 'address', 'hours'],
QrCode::TYPE_CHURCH => ['name', 'denomination', 'description', 'phone', 'email', 'website', 'address', 'service_times', 'org_type', 'accepts_payment', 'collection_types', 'brand_color'],
QrCode::TYPE_EVENT => ['name', 'tagline', 'description', 'location', 'starts_at', 'ends_at', 'organizer', 'website', 'brand_color', 'tiers', 'badge_fields', 'badge_size', 'registration_open'],
QrCode::TYPE_ITINERARY => ['title', 'subtitle', 'description', 'event_date', 'location', 'brand_color', 'days'],
QrCode::TYPE_DOCUMENT => ['allow_download'],
QrCode::TYPE_MENU => ['menu_title', 'sections', 'accepts_payment', 'brand_color', 'shipping_type', 'shipping_fee', 'free_shipping_above'],
QrCode::TYPE_SHOP => ['shop_title', 'sections', 'currency', 'accepts_payment', 'brand_color', 'shipping_type', 'shipping_fee', 'free_shipping_above'],
QrCode::TYPE_APP => ['app_name', 'ios_url', 'android_url', 'web_url', 'app_icon'],
QrCode::TYPE_BOOK => ['book_title', 'author', 'description', 'price_ghs'],
QrCode::TYPE_WIFI => ['ssid', 'password', 'encryption', 'hidden'],
default => [],
};
foreach ($keys as $key) {
if (array_key_exists($key, $data)) {
return true;
}
}
return false;
}
public function storeDocument(User $user, UploadedFile $file, string $title): QrDocument
{
$maxBytes = (int) config('qr.max_pdf_bytes', 104857600);
if ($file->getSize() > $maxBytes) {
throw new RuntimeException('PDF must be 100 MB or smaller.');
}
$mime = $file->getMimeType() ?: '';
if (! in_array($mime, ['application/pdf', 'application/x-pdf'], true)) {
throw new RuntimeException('Only PDF documents are supported.');
}
$uuid = Str::uuid()->toString();
$path = $user->id . '/documents/' . $uuid . '.pdf';
$file->storeAs('', $path, 'qr');
return QrDocument::create([
'user_id' => $user->id,
'title' => $title,
'disk' => 'qr',
'path' => $path,
'mime_type' => 'application/pdf',
'size_bytes' => (int) $file->getSize(),
]);
}
/**
* @param array<string, mixed> $data
* @return list<array{path: string, title: string}>
*/
private function storeImages(User $user, array $data, bool $required = true): array
{
$files = [];
if (($data['image'] ?? null) instanceof UploadedFile) {
$files[] = $data['image'];
}
if (is_array($data['images'] ?? null)) {
foreach ($data['images'] as $file) {
if ($file instanceof UploadedFile) {
$files[] = $file;
}
}
}
if ($required && $files === []) {
$this->payloadValidator->validateImageUpload(null);
}
$stored = [];
foreach ($files as $index => $file) {
$this->payloadValidator->validateImageUpload($file);
$uuid = Str::uuid()->toString();
$ext = $file->getClientOriginalExtension() ?: 'jpg';
$path = $user->id . '/images/' . $uuid . '.' . $ext;
$file->storeAs('', $path, 'qr');
$stored[] = [
'path' => $path,
'title' => $file->getClientOriginalName() ?: ('Image ' . ($index + 1)),
];
}
return $stored;
}
private function storeVcardAvatar(User $user, UploadedFile $file): string
{
$mime = $file->getMimeType() ?: '';
if (! str_starts_with($mime, 'image/')) {
throw new RuntimeException('Avatar must be an image file.');
}
$ext = $file->getClientOriginalExtension() ?: 'jpg';
$path = $user->id . '/vcards/' . Str::uuid()->toString() . '.' . $ext;
$file->storeAs('', $path, 'qr');
return $path;
}
private function storeBookFile(User $user, UploadedFile $file): array
{
$ext = strtolower($file->getClientOriginalExtension() ?: '');
$mime = $file->getMimeType() ?: '';
if ($ext === 'pdf' || in_array($mime, ['application/pdf', 'application/x-pdf'], true)) {
$type = 'pdf';
} elseif ($ext === 'epub' || str_contains($mime, 'epub')) {
$type = 'epub';
} else {
throw new RuntimeException('Only PDF and EPUB files are supported for books.');
}
$path = $user->id . '/books/' . Str::uuid()->toString() . '.' . $type;
$file->storeAs('', $path, 'qr');
return ['path' => $path, 'type' => $type, 'size' => (int) $file->getSize()];
}
private function storeMenuBrandImage(User $user, UploadedFile $file, string $subdir): string
{
$mime = $file->getMimeType() ?: '';
if (! str_starts_with($mime, 'image/')) {
throw new RuntimeException('Only image files are supported.');
}
$ext = $file->getClientOriginalExtension() ?: 'jpg';
$path = $user->id . '/' . $subdir . '/' . Str::uuid()->toString() . '.' . $ext;
$file->storeAs('', $path, 'qr');
return $path;
}
private function storeBookCover(User $user, UploadedFile $file): string
{
$mime = $file->getMimeType() ?: '';
if (! str_starts_with($mime, 'image/')) {
throw new RuntimeException('Book cover must be an image file.');
}
$ext = $file->getClientOriginalExtension() ?: 'jpg';
$path = $user->id . '/book-covers/' . Str::uuid()->toString() . '.' . $ext;
$file->storeAs('', $path, 'qr');
return $path;
}
private function storeLogo(User $user, UploadedFile $file): string
{
$mime = $file->getMimeType() ?: '';
if (! str_starts_with($mime, 'image/')) {
throw new RuntimeException('Logo must be an image file.');
}
$path = $user->id . '/logos/' . Str::uuid()->toString() . '.' . ($file->getClientOriginalExtension() ?: 'png');
$file->storeAs('', $path, 'qr');
return $path;
}
private function generateUniqueShortCode(): string
{
$length = (int) config('qr.short_code_length', 8);
for ($attempt = 0; $attempt < 20; $attempt++) {
$code = Str::lower(Str::random($length));
if (! QrCode::query()->where('short_code', $code)->exists()) {
return $code;
}
}
throw new RuntimeException('Could not generate a unique QR short code.');
}
}
+488
View File
@@ -0,0 +1,488 @@
<?php
namespace App\Services\Qr;
use App\Models\QrCode as QrCodeModel;
use App\Support\Qr\QrFrameStyleCatalog;
use App\Support\Qr\QrModuleStyleCatalog;
use App\Support\Qr\QrStyleDefaults;
use chillerlan\QRCode\Common\EccLevel;
use chillerlan\QRCode\Data\QRMatrix;
use chillerlan\QRCode\QROptions;
use chillerlan\QRCode\QRCode;
use Illuminate\Support\Facades\Storage;
class QrImageGeneratorService
{
public function generateAndStore(QrCodeModel $qrCode): void
{
$url = $qrCode->encodedPayload();
$style = $qrCode->style();
$basePath = $qrCode->user_id . '/codes/' . $qrCode->id;
$pngBinary = $this->renderPng($url, $style);
$svgMarkup = $this->renderSvg($url, $style);
$pngPath = $basePath . '/qr.png';
$svgPath = $basePath . '/qr.svg';
Storage::disk('qr')->put($pngPath, $pngBinary);
Storage::disk('qr')->put($svgPath, $svgMarkup);
$qrCode->update([
'png_path' => $pngPath,
'svg_path' => $svgPath,
]);
}
public function ensureValidImages(QrCodeModel $qrCode): QrCodeModel
{
if ($this->pngIsValid($qrCode->png_path)) {
return $qrCode;
}
$this->generateAndStore($qrCode);
return $qrCode->fresh();
}
public function previewDataUri(QrCodeModel $qrCode): string
{
$qrCode = $this->ensureValidImages($qrCode);
if ($qrCode->png_path && Storage::disk('qr')->exists($qrCode->png_path)) {
$bytes = Storage::disk('qr')->get($qrCode->png_path);
if ($this->isValidPngBinary($bytes)) {
return 'data:image/png;base64,' . base64_encode($bytes);
}
}
$png = $this->renderPng($qrCode->encodedPayload(), $qrCode->style());
$this->generateAndStore($qrCode);
return 'data:image/png;base64,' . base64_encode($png);
}
public function logoDataUri(string $path): ?string
{
if (! Storage::disk('qr')->exists($path)) {
return null;
}
$content = Storage::disk('qr')->get($path);
if ($content === null) {
return null;
}
$mimeType = Storage::disk('qr')->mimeType($path);
if (! $mimeType) {
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
$mimeType = match ($ext) {
'png' => 'image/png',
'jpg', 'jpeg' => 'image/jpeg',
'svg' => 'image/svg+xml',
'gif' => 'image/gif',
'webp' => 'image/webp',
default => 'image/png',
};
}
return 'data:' . $mimeType . ';base64,' . base64_encode($content);
}
/**
* @param array<string, mixed> $style
*/
public function renderPng(string $content, array $style): string
{
$style = QrStyleDefaults::mergeForRender($style);
$options = $this->buildOptions($style, QRCode::OUTPUT_IMAGE_PNG);
$binary = (new QRCode($options))->render($content);
if ($style['logo_path'] && Storage::disk('qr')->exists($style['logo_path'])) {
$binary = $this->applyLogo($binary, Storage::disk('qr')->path($style['logo_path']));
}
return $this->applyFrame($binary, (string) ($style['frame_style'] ?? 'none'), $style);
}
/**
* @param array<string, mixed> $style
*/
public function renderSvg(string $content, array $style): string
{
$style = QrStyleDefaults::mergeForRender($style);
$options = $this->buildOptions($style, QRCode::OUTPUT_MARKUP_SVG);
return (new QRCode($options))->render($content);
}
/** @param array<string, mixed> $style */
private function buildOptions(array $style, string $outputType): QROptions
{
$fg = $this->hexToRgb((string) $style['foreground']);
$bg = $this->hexToRgb((string) $style['background']);
$ecc = match ($style['error_correction']) {
'L' => EccLevel::L,
'Q' => EccLevel::Q,
'H' => EccLevel::H,
default => EccLevel::M,
};
$hasLogo = ! empty($style['logo_path']);
$module = QrModuleStyleCatalog::optionsFor((string) $style['module_style']);
$moduleUsesCircular = (bool) $module['circular'];
$finderOuter = (string) ($style['finder_outer'] ?? 'square');
$finderInner = (string) ($style['finder_inner'] ?? 'square');
$finderUsesCircular = $finderOuter !== 'square' || $finderInner === 'dot';
$usesCircular = $moduleUsesCircular || $finderUsesCircular;
$connectPaths = $module['connect_paths'] && $outputType === QRCode::OUTPUT_MARKUP_SVG;
$circleRadius = (float) $module['circle_radius'];
if ($finderOuter === 'circle') {
$circleRadius = max($circleRadius, 0.5);
} elseif ($finderOuter === 'rounded') {
$circleRadius = max($circleRadius, 0.38);
}
$keepAsSquare = $this->resolveKeepAsSquare($moduleUsesCircular, $finderOuter, $finderInner);
return new QROptions([
'outputType' => $outputType,
'outputBase64' => false,
'scale' => (int) $style['scale'],
'eccLevel' => $ecc,
'addQuietzone' => true,
'quietzoneSize' => (int) $style['margin'],
'bgColor' => $bg,
'drawCircularModules' => $usesCircular,
'circleRadius' => $circleRadius,
'connectPaths' => $connectPaths,
'gdImageUseUpscale' => true,
'keepAsSquare' => $keepAsSquare,
'addLogoSpace' => $hasLogo,
'logoSpaceWidth' => $hasLogo ? 13 : null,
'logoSpaceHeight' => $hasLogo ? 13 : null,
'moduleValues' => [
QRMatrix::M_DATA_DARK => $fg,
QRMatrix::M_FINDER_DARK => $fg,
QRMatrix::M_ALIGNMENT_DARK => $fg,
QRMatrix::M_TIMING_DARK => $fg,
QRMatrix::M_FORMAT_DARK => $fg,
QRMatrix::M_VERSION_DARK => $fg,
QRMatrix::M_FINDER_DOT => $fg,
],
]);
}
/** @return list<int> */
private function resolveKeepAsSquare(bool $moduleUsesCircular, string $finderOuter, string $finderInner): array
{
$finderUsesCircular = ($finderOuter !== 'square') || ($finderInner === 'dot') || ($finderInner === 'rounded');
if (! $moduleUsesCircular && ! $finderUsesCircular) {
return [];
}
$keep = [
// Structural modules must always stay square for reliable scanning.
QRMatrix::M_ALIGNMENT_DARK,
QRMatrix::M_TIMING_DARK,
QRMatrix::M_FORMAT_DARK,
QRMatrix::M_VERSION_DARK,
// White separator (M_FINDER light modules) must always stay square.
// Removing it causes the circular dark-ring modules to lose their solid
// white backdrop, which produces a broken / empty-looking eye pattern.
QRMatrix::M_FINDER,
];
// Data modules stay square when only the finder style is circular.
if (! $moduleUsesCircular) {
$keep[] = QRMatrix::M_DATA_DARK;
}
// Outer finder frame: square keeps the ring solid; non-square renders it as dots.
if ($finderOuter === 'square') {
$keep[] = QRMatrix::M_FINDER_DARK;
}
// Inner finder dot: 'dot' and 'rounded' both get circular treatment.
if ($finderInner !== 'dot' && $finderInner !== 'rounded') {
$keep[] = QRMatrix::M_FINDER_DOT;
}
return array_values(array_unique($keep));
}
/** @param array<string, mixed> $style */
private function applyFrame(string $pngBinary, string $frameStyle, array $style = []): string
{
$frame = QrFrameStyleCatalog::all()[$frameStyle] ?? QrFrameStyleCatalog::all()['none'];
$borderPx = (int) $frame['border_px'];
$mode = (string) ($frame['mode'] ?? 'border');
if ($borderPx === 0 || ! extension_loaded('gd')) {
return $pngBinary;
}
$source = @imagecreatefromstring($pngBinary);
if (! $source) {
return $pngBinary;
}
$width = imagesx($source);
$height = imagesy($source);
$labelHeight = in_array($mode, ['label', 'pill'], true) ? max(44, (int) round($height * 0.18)) : 0;
$canvasWidth = $width + ($borderPx * 2);
$canvasHeight = $height + ($borderPx * 2) + $labelHeight;
$frameColorHex = trim((string) ($style['frame_color'] ?? '#000000'));
if (! preg_match('/^#[0-9a-fA-F]{6}$/', $frameColorHex)) {
$frameColorHex = '#000000';
}
$canvas = imagecreatetruecolor($canvasWidth, $canvasHeight);
$white = imagecolorallocate($canvas, 255, 255, 255);
// For border mode the padding area IS the frame — fill with frame colour.
// For label/pill modes the background stays white; only the CTA element uses frame colour.
if ($mode === 'border') {
[$fr, $fg, $fb] = $this->hexToRgb($frameColorHex);
$frameBg = imagecolorallocate($canvas, $fr, $fg, $fb);
imagefilledrectangle($canvas, 0, 0, $canvasWidth, $canvasHeight, $frameBg);
} else {
imagefilledrectangle($canvas, 0, 0, $canvasWidth, $canvasHeight, $white);
}
imagecopy($canvas, $source, $borderPx, $borderPx, 0, 0, $width, $height);
if ($labelHeight > 0) {
$customText = trim((string) ($style['frame_text'] ?? ''));
$ctaText = $customText !== '' ? $customText : (string) ($frame['cta'] ?? 'SCAN ME');
$this->drawFrameLabel($canvas, $ctaText, $borderPx, $width, $height, $labelHeight, $mode, $frameColorHex);
}
ob_start();
imagepng($canvas);
$result = (string) ob_get_clean();
imagedestroy($source);
imagedestroy($canvas);
return $result;
}
private function drawFrameLabel(\GdImage $canvas, string $text, int $borderPx, int $qrWidth, int $qrHeight, int $labelHeight, string $mode, string $frameColorHex = '#000000'): void
{
$canvasWidth = imagesx($canvas);
$canvasHeight = imagesy($canvas);
[$fr, $fg, $fb] = $this->hexToRgb($frameColorHex);
$frameColor = imagecolorallocate($canvas, $fr, $fg, $fb);
// Choose black or white text depending on frame colour luminance
$luminance = (0.2126 * $fr + 0.7152 * $fg + 0.0722 * $fb) / 255;
$textOnFrame = $luminance > 0.35
? imagecolorallocate($canvas, 0, 0, 0)
: imagecolorallocate($canvas, 255, 255, 255);
$dividerColor = imagecolorallocate($canvas, $fr, $fg, $fb);
$labelTop = $borderPx + $qrHeight;
$labelBottom = $canvasHeight - max(8, (int) round($borderPx * 0.6));
if ($mode === 'pill') {
$pillMargin = max(12, (int) round($borderPx * 0.9));
$pillTop = $labelTop + max(7, (int) round($labelHeight * 0.18));
$pillBottom = $labelBottom - max(5, (int) round($labelHeight * 0.12));
imagefilledrectangle($canvas, $pillMargin + 10, $pillTop, $canvasWidth - $pillMargin - 10, $pillBottom, $frameColor);
imagefilledellipse($canvas, $pillMargin + 10, (int) (($pillTop + $pillBottom) / 2), $pillBottom - $pillTop, $pillBottom - $pillTop, $frameColor);
imagefilledellipse($canvas, $canvasWidth - $pillMargin - 10, (int) (($pillTop + $pillBottom) / 2), $pillBottom - $pillTop, $pillBottom - $pillTop, $frameColor);
$this->drawCenteredString($canvas, $text, $textOnFrame, 5, $pillTop, $pillBottom);
return;
}
imageline($canvas, $borderPx + 8, $labelTop + 3, $borderPx + $qrWidth - 8, $labelTop + 3, $dividerColor);
$this->drawCenteredString($canvas, $text, $frameColor, 5, $labelTop + 7, $labelBottom);
}
private function drawCenteredString(\GdImage $canvas, string $text, int $color, int $font, int $top, int $bottom): void
{
$text = strtoupper($text);
$font = max(1, min(5, $font));
$textWidth = imagefontwidth($font) * strlen($text);
$textHeight = imagefontheight($font);
$x = max(0, (int) round((imagesx($canvas) - $textWidth) / 2));
$y = max($top, (int) round($top + (($bottom - $top - $textHeight) / 2)));
imagestring($canvas, $font, $x, $y, $text, $color);
}
/** @return array{0: int, 1: int, 2: int} */
private 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)),
];
}
private function applyLogo(string $pngBinary, string $logoPath): string
{
if (! extension_loaded('gd')) {
return $pngBinary;
}
$qr = imagecreatefromstring($pngBinary);
$logo = @imagecreatefromstring((string) file_get_contents($logoPath));
if (! $qr || ! $logo) {
return $pngBinary;
}
$qrW = imagesx($qr);
$qrH = imagesy($qr);
$logoW = imagesx($logo);
$logoH = imagesy($logo);
$target = (int) round(min($qrW, $qrH) * 0.22);
$resized = imagecreatetruecolor($target, $target);
imagealphablending($resized, false);
imagesavealpha($resized, true);
$transparent = imagecolorallocatealpha($resized, 255, 255, 255, 127);
imagefilledrectangle($resized, 0, 0, $target, $target, $transparent);
imagecopyresampled($resized, $logo, 0, 0, 0, 0, $target, $target, $logoW, $logoH);
$pad = (int) round($target * 0.12);
$bgSize = $target + ($pad * 2);
$bgX = (int) (($qrW - $bgSize) / 2);
$bgY = (int) (($qrH - $bgSize) / 2);
$white = imagecolorallocate($qr, 255, 255, 255);
imagefilledrectangle($qr, $bgX, $bgY, $bgX + $bgSize, $bgY + $bgSize, $white);
imagecopy($qr, $resized, $bgX + $pad, $bgY + $pad, 0, 0, $target, $target);
ob_start();
imagepng($qr);
$result = (string) ob_get_clean();
imagedestroy($qr);
imagedestroy($logo);
imagedestroy($resized);
return $result;
}
private function pngIsValid(?string $path): bool
{
if (! $path || ! Storage::disk('qr')->exists($path)) {
return false;
}
return $this->isValidPngBinary(Storage::disk('qr')->get($path));
}
/**
* Sanitize a client-supplied QR SVG before storing it. QR SVGs are paths,
* rects, gradients, clip-paths and an embedded data: logo never scripts.
* Strips script/foreignObject, inline event handlers, javascript: URIs and
* any non-data external image/href references. Returns null if it isn't an
* SVG. Downloads are served as attachments, so this is defence-in-depth.
*/
public function sanitizeSvg(string $svg): ?string
{
$svg = trim($svg);
if (! preg_match('/<svg[\s>]/i', $svg)) {
return null;
}
// Drop XML declaration / doctype.
$svg = preg_replace('/<\?xml.*?\?>/is', '', $svg);
$svg = preg_replace('/<!DOCTYPE.*?>/is', '', $svg);
// Remove dangerous elements entirely (with or without content).
$svg = preg_replace('#<\s*(script|foreignObject|iframe|style)\b[^>]*>.*?<\s*/\s*\1\s*>#is', '', $svg);
$svg = preg_replace('#<\s*(script|foreignObject|iframe|style)\b[^>]*/?>#is', '', $svg);
// Strip inline event handlers (onload, onclick, …).
$svg = preg_replace('/\son[a-z]+\s*=\s*"[^"]*"/i', '', $svg);
$svg = preg_replace("/\son[a-z]+\s*=\s*'[^']*'/i", '', $svg);
// Neutralise javascript: in any href / xlink:href.
$svg = preg_replace('/((?:xlink:)?href)\s*=\s*"\s*javascript:[^"]*"/i', '$1="#"', $svg);
$svg = preg_replace("/((?:xlink:)?href)\s*=\s*'\s*javascript:[^']*'/i", '$1="#"', $svg);
// Remove external (non-data:) image references; keep embedded data: logos.
$svg = preg_replace('/<image\b(?:(?!href)[^>])*(?:xlink:)?href\s*=\s*"(?!data:)[^"]*"[^>]*>/is', '', $svg);
$svg = trim($svg);
if (! str_contains($svg, '<svg')) {
return null;
}
// Guarantee namespaces + an XML prolog so the file renders in strict SVG
// viewers (Illustrator, Inkscape, macOS Preview, print pipelines), not just
// browsers. qr-code-styling's DOM serialization can omit xmlns:xlink, which
// browsers tolerate but standalone viewers reject (logo/refs fail to render).
if (preg_match('/<svg\b[^>]*>/i', $svg, $m)) {
$open = $m[0];
$fixed = $open;
if (! preg_match('/\sxmlns\s*=/i', $fixed)) {
$fixed = preg_replace('/<svg\b/i', '<svg xmlns="http://www.w3.org/2000/svg"', $fixed, 1);
}
if (str_contains($svg, 'xlink:') && ! preg_match('/\sxmlns:xlink\s*=/i', $fixed)) {
$fixed = preg_replace('/<svg\b/i', '<svg xmlns:xlink="http://www.w3.org/1999/xlink"', $fixed, 1);
}
if ($fixed !== $open) {
$svg = substr_replace($svg, $fixed, strpos($svg, $open), strlen($open));
}
}
if (! preg_match('/^\s*<\?xml/i', $svg)) {
$svg = '<?xml version="1.0" encoding="UTF-8"?>' . "\n" . $svg;
}
return $svg;
}
public function isValidPngBinary(string $bytes): bool
{
if ($bytes === '') {
return false;
}
if (str_starts_with($bytes, "\x89PNG\r\n\x1a\n")) {
return true;
}
// Legacy bug: PNG was stored as a base64 string instead of raw bytes.
if (str_starts_with($bytes, 'iVBORw0KGgo')) {
return false;
}
return false;
}
public function normalizeStoredPng(?string $path): ?string
{
if (! $path || ! Storage::disk('qr')->exists($path)) {
return null;
}
$bytes = Storage::disk('qr')->get($path);
if ($this->isValidPngBinary($bytes)) {
return $bytes;
}
if (str_starts_with($bytes, 'iVBORw0KGgo')) {
$decoded = base64_decode($bytes, true);
if ($decoded !== false && $this->isValidPngBinary($decoded)) {
Storage::disk('qr')->put($path, $decoded);
return $decoded;
}
}
return null;
}
}
+644
View File
@@ -0,0 +1,644 @@
<?php
namespace App\Services\Qr;
use App\Models\QrCode;
use App\Support\Qr\QrDateFormatter;
use App\Support\Qr\QrTypeCatalog;
use Illuminate\Http\UploadedFile;
use RuntimeException;
class QrPayloadValidator
{
/**
* @param array<string, mixed> $input
* @return array{content: array<string, mixed>, destination_url: ?string}
*/
public function validateForCreate(string $type, array $input): array
{
if (! QrTypeCatalog::isValid($type)) {
throw new RuntimeException('Invalid QR type selected.');
}
return match ($type) {
QrCode::TYPE_URL => $this->validateUrl($input),
QrCode::TYPE_DOCUMENT => $this->validateDocument($input),
QrCode::TYPE_LINK_LIST => $this->validateLinkList($input),
QrCode::TYPE_VCARD => $this->validateVcard($input),
QrCode::TYPE_BUSINESS => $this->validateBusiness($input),
QrCode::TYPE_CHURCH => $this->validateChurch($input),
QrCode::TYPE_EVENT => $this->validateEvent($input),
QrCode::TYPE_ITINERARY => $this->validateItinerary($input),
QrCode::TYPE_IMAGE => ['content' => [], 'destination_url' => null],
QrCode::TYPE_MENU => $this->validateMenu($input),
QrCode::TYPE_SHOP => $this->validateShop($input),
QrCode::TYPE_APP => $this->validateApp($input),
QrCode::TYPE_BOOK => $this->validateBook($input),
QrCode::TYPE_WIFI => $this->validateWifi($input),
default => throw new RuntimeException('Unsupported QR type.'),
};
}
/**
* @param array<string, mixed> $input
* @return array{content: array<string, mixed>, destination_url: ?string}
*/
public function validateForUpdate(QrCode $qrCode, array $input): array
{
$merged = array_merge($qrCode->content(), $input);
if ($qrCode->isUrlType() && empty($merged['destination_url']) && ! empty($merged['url'])) {
$merged['destination_url'] = $merged['url'];
}
return $this->validateForCreate($qrCode->type, $merged);
}
/** @param array<string, mixed> $input */
private function validateDocument(array $input): array
{
$allowDownload = filter_var($input['allow_download'] ?? true, FILTER_VALIDATE_BOOL);
return ['content' => ['allow_download' => $allowDownload], 'destination_url' => null];
}
/** @param array<string, mixed> $input */
private function validateUrl(array $input): array
{
$url = $this->requireUrl($input['destination_url'] ?? null, 'Enter a valid destination URL.');
return ['content' => ['url' => $url], 'destination_url' => $url];
}
/** @param array<string, mixed> $input */
private function validateLinkList(array $input): array
{
$links = $this->normalizeLinks($input['links'] ?? []);
if ($links === []) {
throw new RuntimeException('Add at least one link.');
}
return ['content' => ['links' => $links], 'destination_url' => null];
}
/** @param array<string, mixed> $input */
private function validateVcard(array $input): array
{
$first = trim((string) ($input['first_name'] ?? ''));
$last = trim((string) ($input['last_name'] ?? ''));
if ($first === '' && $last === '') {
throw new RuntimeException('Enter a first or last name for the vCard.');
}
$social = [];
foreach (['linkedin', 'twitter', 'instagram', 'facebook', 'tiktok', 'youtube', 'whatsapp', 'snapchat'] as $platform) {
$raw = trim((string) (($input['social'] ?? [])[$platform] ?? ''));
if ($raw !== '') {
$social[$platform] = static::normalizeSocialUrl($platform, $raw);
}
}
return [
'content' => [
'first_name' => $first,
'last_name' => $last,
'phone' => trim((string) ($input['phone'] ?? '')),
'email' => trim((string) ($input['email'] ?? '')),
'company' => trim((string) ($input['company'] ?? '')),
'website' => trim((string) ($input['website'] ?? '')),
'address' => trim((string) ($input['address'] ?? '')),
'note' => trim((string) ($input['note'] ?? '')),
'avatar_path' => $input['avatar_path'] ?? null,
'social' => $social,
],
'destination_url' => null,
];
}
/** @param array<string, mixed> $input */
private function validateBusiness(array $input): array
{
$name = trim((string) ($input['name'] ?? ''));
if ($name === '') {
throw new RuntimeException('Enter a business name.');
}
$social = [];
foreach (['linkedin', 'twitter', 'instagram', 'facebook', 'tiktok', 'youtube', 'whatsapp', 'snapchat'] as $platform) {
$raw = trim((string) (($input['social'] ?? [])[$platform] ?? ''));
if ($raw !== '') {
$social[$platform] = static::normalizeSocialUrl($platform, $raw);
}
}
return [
'content' => [
'name' => $name,
'tagline' => trim((string) ($input['tagline'] ?? '')),
'phone' => trim((string) ($input['phone'] ?? '')),
'email' => trim((string) ($input['email'] ?? '')),
'website' => trim((string) ($input['website'] ?? '')),
'address' => trim((string) ($input['address'] ?? '')),
'hours' => trim((string) ($input['hours'] ?? '')),
'brand_color' => $this->normalizeBrandColor($input['brand_color'] ?? null),
'logo_path' => $input['logo_path'] ?? null,
'cover_path' => $input['cover_path'] ?? null,
'social' => $social,
],
'destination_url' => null,
];
}
/** @param array<string, mixed> $input */
private function validateChurch(array $input): array
{
$name = trim((string) ($input['name'] ?? ''));
if ($name === '') {
throw new RuntimeException('Enter the church name.');
}
$orgTypes = ['church', 'school', 'mosque', 'ngo', 'club'];
$orgType = in_array($input['org_type'] ?? 'church', $orgTypes) ? $input['org_type'] : 'church';
// Normalise legacy lowercase slugs → display strings
$legacyMap = ['offering' => 'Offering', 'tithe' => 'Tithe', 'donation' => 'Donation', 'harvest' => 'Harvest'];
$collectionTypes = array_values(array_unique(array_filter(
array_map(fn ($t) => $legacyMap[strtolower(trim((string) $t))] ?? ucwords(trim((string) $t)), (array) ($input['collection_types'] ?? [])),
fn ($t) => $t !== '' && strlen($t) <= 60
)));
if (empty($collectionTypes)) {
$collectionTypes = ['Offering', 'Tithe', 'Donation', 'Harvest'];
}
return [
'content' => [
'name' => $name,
'denomination' => trim((string) ($input['denomination'] ?? '')),
'description' => trim((string) ($input['description'] ?? '')),
'phone' => trim((string) ($input['phone'] ?? '')),
'email' => trim((string) ($input['email'] ?? '')),
'website' => trim((string) ($input['website'] ?? '')),
'address' => trim((string) ($input['address'] ?? '')),
'service_times' => trim((string) ($input['service_times'] ?? '')),
'brand_color' => $this->normalizeBrandColor($input['brand_color'] ?? null) ?? '#1a3a5c',
'logo_path' => $input['logo_path'] ?? null,
'cover_path' => $input['cover_path'] ?? null,
'org_type' => $orgType,
'accepts_payment' => filter_var($input['accepts_payment'] ?? false, FILTER_VALIDATE_BOOL),
'currency' => 'GHS',
'collection_types' => $collectionTypes,
],
'destination_url' => null,
];
}
/** @param array<string, mixed> $input */
private function validateEvent(array $input): array
{
$name = trim((string) ($input['name'] ?? ''));
if ($name === '') {
throw new RuntimeException('Enter the event name.');
}
// Ticket tiers: [{ name, price, capacity }]
$tiers = [];
foreach ((array) ($input['tiers'] ?? []) as $tier) {
if (! is_array($tier)) {
continue;
}
$tierName = trim((string) ($tier['name'] ?? ''));
if ($tierName === '') {
continue;
}
$price = round((float) ($tier['price'] ?? 0), 2);
$capacity = (int) ($tier['capacity'] ?? 0);
$tiers[] = [
'name' => mb_substr($tierName, 0, 80),
'price' => max(0, $price),
'capacity' => max(0, $capacity), // 0 = unlimited
];
}
if (empty($tiers)) {
$tiers = [['name' => 'General Admission', 'price' => 0.0, 'capacity' => 0]];
}
// Extra registration/badge fields the organiser wants captured.
$badgeFields = [];
foreach ((array) ($input['badge_fields'] ?? []) as $field) {
$label = trim((string) (is_array($field) ? ($field['label'] ?? '') : $field));
if ($label !== '' && strlen($label) <= 60) {
$badgeFields[] = mb_substr($label, 0, 60);
}
}
// Mode: sell tickets, collect cash contributions (weddings, non-profits),
// or a free event (registration only — no tickets, no contributions).
$mode = in_array($input['mode'] ?? 'ticketing', ['ticketing', 'contributions', 'free'], true)
? ($input['mode'] ?? 'ticketing')
: 'ticketing';
// Contribution categories: ['Wedding Gift', 'Donation', …]
$categories = [];
foreach ((array) ($input['contribution_categories'] ?? []) as $cat) {
$label = trim((string) (is_array($cat) ? ($cat['name'] ?? '') : $cat));
if ($label !== '') {
$categories[] = mb_substr($label, 0, 80);
}
}
if ($mode === 'contributions' && empty($categories)) {
$categories = ['Contribution'];
}
// Free events collect neither tickets nor contributions — a single free
// registration. Forced deterministically so switching from a paid setup
// can never leave a chargeable tier behind.
if ($mode === 'free') {
$tiers = [['name' => 'Registration', 'price' => 0.0, 'capacity' => 0]];
$categories = [];
}
// Contributions always take payment; ticketing only for paid tiers; free never.
$acceptsPayment = match ($mode) {
'contributions' => true,
'free' => false,
default => $this->eventHasPaidTier($tiers),
};
return [
'content' => [
'name' => mb_substr($name, 0, 120),
'tagline' => trim((string) ($input['tagline'] ?? '')),
'description' => trim((string) ($input['description'] ?? '')),
'location' => trim((string) ($input['location'] ?? '')),
'starts_at' => QrDateFormatter::normalize((string) ($input['starts_at'] ?? '')),
'ends_at' => QrDateFormatter::normalize((string) ($input['ends_at'] ?? '')),
'organizer' => trim((string) ($input['organizer'] ?? '')),
'website' => trim((string) ($input['website'] ?? '')),
'brand_color' => $this->normalizeBrandColor($input['brand_color'] ?? null) ?? '#4f46e5',
'logo_path' => $input['logo_path'] ?? null,
'cover_path' => $input['cover_path'] ?? null,
'currency' => 'GHS',
'mode' => $mode,
'tiers' => array_values($tiers),
'contribution_categories' => array_values($categories),
'badge_fields' => array_values($badgeFields),
'badge_size' => in_array($input['badge_size'] ?? '4x3', ['4x3', '4x6', 'cr80'], true) ? ($input['badge_size'] ?? '4x3') : '4x3',
'accepts_payment' => $acceptsPayment,
'registration_open' => filter_var($input['registration_open'] ?? true, FILTER_VALIDATE_BOOL),
'programme_qr_id' => ($pid = (int) ($input['programme_qr_id'] ?? 0)) > 0 ? $pid : null,
],
'destination_url' => null,
];
}
/** @param array<int, array{price: float}> $tiers */
private function eventHasPaidTier(array $tiers): bool
{
foreach ($tiers as $tier) {
if ((float) ($tier['price'] ?? 0) > 0) {
return true;
}
}
return false;
}
/** @param array<string, mixed> $input */
private function validateItinerary(array $input): array
{
$title = trim((string) ($input['title'] ?? ''));
if ($title === '') {
throw new RuntimeException('Enter the itinerary title.');
}
// Days: [{ label, date, items: [{ time, title, description, location, host }] }]
$days = [];
foreach ((array) ($input['days'] ?? []) as $day) {
if (! is_array($day)) {
continue;
}
$items = [];
foreach ((array) ($day['items'] ?? []) as $item) {
if (! is_array($item)) {
continue;
}
$itemTitle = trim((string) ($item['title'] ?? ''));
if ($itemTitle === '') {
continue;
}
$items[] = [
'time' => mb_substr(trim((string) ($item['time'] ?? '')), 0, 40),
'title' => mb_substr($itemTitle, 0, 140),
'description' => mb_substr(trim((string) ($item['description'] ?? '')), 0, 400),
'location' => mb_substr(trim((string) ($item['location'] ?? '')), 0, 120),
'host' => mb_substr(trim((string) ($item['host'] ?? '')), 0, 120),
];
}
if (empty($items) && trim((string) ($day['label'] ?? '')) === '') {
continue;
}
$days[] = [
'label' => mb_substr(trim((string) ($day['label'] ?? '')), 0, 80),
'date' => QrDateFormatter::normalize((string) ($day['date'] ?? '')),
'items' => array_values($items),
];
}
if (empty($days)) {
throw new RuntimeException('Add at least one programme item.');
}
return [
'content' => [
'title' => mb_substr($title, 0, 120),
'subtitle' => trim((string) ($input['subtitle'] ?? '')),
'description' => trim((string) ($input['description'] ?? '')),
'event_date' => QrDateFormatter::normalize((string) ($input['event_date'] ?? '')),
'location' => trim((string) ($input['location'] ?? '')),
'brand_color' => $this->normalizeBrandColor($input['brand_color'] ?? null) ?? '#b45309',
'cover_path' => $input['cover_path'] ?? null,
'days' => array_values($days),
],
'destination_url' => null,
];
}
/** @param array<string, mixed> $input */
private function validateMenu(array $input): array
{
$title = trim((string) ($input['menu_title'] ?? 'Menu'));
$sections = $this->normalizeMenuSections($input['sections'] ?? []);
$acceptsPayment = filter_var($input['accepts_payment'] ?? false, FILTER_VALIDATE_BOOL);
$shippingType = in_array($input['shipping_type'] ?? 'none', ['none', 'flat'], true)
? (string) ($input['shipping_type'] ?? 'none')
: 'none';
$shippingFee = $shippingType === 'flat' ? round(max(0, (float) ($input['shipping_fee'] ?? 0)), 2) : 0.0;
$freeShippingAbove = round(max(0, (float) ($input['free_shipping_above'] ?? 0)), 2);
if ($sections === []) {
throw new RuntimeException('Add at least one menu section with items.');
}
return [
'content' => [
'title' => $title,
'sections' => $sections,
'accepts_payment' => $acceptsPayment,
'shipping_type' => $shippingType,
'shipping_fee' => $shippingFee,
'free_shipping_above' => $freeShippingAbove,
'brand_color' => $this->normalizeBrandColor($input['brand_color'] ?? null),
'logo_path' => $input['logo_path'] ?? null,
'cover_path' => $input['cover_path'] ?? null,
],
'destination_url' => null,
];
}
/** @param array<string, mixed> $input */
private function validateShop(array $input): array
{
$title = trim((string) ($input['shop_title'] ?? $input['menu_title'] ?? 'Shop'));
$currency = trim((string) ($input['currency'] ?? 'GHS'));
$sections = $this->normalizeMenuSections($input['sections'] ?? []);
$acceptsPayment = filter_var($input['accepts_payment'] ?? false, FILTER_VALIDATE_BOOL);
$shippingType = in_array($input['shipping_type'] ?? 'none', ['none', 'flat'], true)
? (string) ($input['shipping_type'] ?? 'none')
: 'none';
$shippingFee = $shippingType === 'flat' ? round(max(0, (float) ($input['shipping_fee'] ?? 0)), 2) : 0.0;
$freeShippingAbove = round(max(0, (float) ($input['free_shipping_above'] ?? 0)), 2);
if ($sections === []) {
throw new RuntimeException('Add at least one category with products.');
}
return [
'content' => [
'title' => $title,
'currency' => $currency,
'sections' => $sections,
'accepts_payment' => $acceptsPayment,
'shipping_type' => $shippingType,
'shipping_fee' => $shippingFee,
'free_shipping_above' => $freeShippingAbove,
'brand_color' => $this->normalizeBrandColor($input['brand_color'] ?? null),
'logo_path' => $input['logo_path'] ?? null,
'cover_path' => $input['cover_path'] ?? null,
],
'destination_url' => null,
];
}
private function normalizeBrandColor(mixed $value): ?string
{
if ($value === null) {
return null;
}
$hex = trim((string) $value);
return preg_match('/^#[0-9A-Fa-f]{6}$/', $hex) ? $hex : null;
}
/** @param array<string, mixed> $input */
private function validateApp(array $input): array
{
$name = trim((string) ($input['app_name'] ?? ''));
$ios = trim((string) ($input['ios_url'] ?? ''));
$android = trim((string) ($input['android_url'] ?? ''));
$web = trim((string) ($input['web_url'] ?? ''));
if ($name === '') {
throw new RuntimeException('Enter an app name.');
}
if ($ios === '' && $android === '' && $web === '') {
throw new RuntimeException('Add at least one app store or website link.');
}
foreach (['ios' => $ios, 'android' => $android, 'web' => $web] as $label => $url) {
if ($url !== '' && ! filter_var($url, FILTER_VALIDATE_URL)) {
throw new RuntimeException("Enter a valid {$label} URL.");
}
}
return [
'content' => [
'name' => $name,
'ios_url' => $ios,
'android_url' => $android,
'web_url' => $web,
'icon_path' => $input['icon_path'] ?? null,
],
'destination_url' => $web ?: ($ios ?: $android),
];
}
/** @param array<string, mixed> $input */
private function validateBook(array $input): array
{
$title = trim((string) ($input['book_title'] ?? ''));
$author = trim((string) ($input['author'] ?? ''));
$price = (float) ($input['price_ghs'] ?? 0);
if ($title === '') {
throw new RuntimeException('Enter the book title.');
}
if ($author === '') {
throw new RuntimeException('Enter the author name.');
}
if ($price <= 0) {
throw new RuntimeException('Enter a price greater than zero.');
}
return [
'content' => [
'book_title' => $title,
'author' => $author,
'description' => trim((string) ($input['description'] ?? '')),
'price_ghs' => round($price, 2),
'cover_path' => $input['cover_path'] ?? null,
'file_path' => $input['file_path'] ?? null,
'file_type' => $input['file_type'] ?? null,
'file_size' => (int) ($input['file_size'] ?? 0),
],
'destination_url' => null,
];
}
/** @param array<string, mixed> $input */
private function validateWifi(array $input): array
{
$ssid = trim((string) ($input['ssid'] ?? ''));
if ($ssid === '') {
throw new RuntimeException('Enter a WiFi network name (SSID).');
}
$encryption = strtoupper(trim((string) ($input['encryption'] ?? 'WPA')));
if (! in_array($encryption, ['WPA', 'WEP', 'NOPASS'], true)) {
$encryption = 'WPA';
}
return [
'content' => [
'ssid' => $ssid,
'password' => (string) ($input['password'] ?? ''),
'encryption' => $encryption,
'hidden' => filter_var($input['hidden'] ?? false, FILTER_VALIDATE_BOOL),
],
'destination_url' => null,
];
}
public static function normalizeSocialUrl(string $platform, string $value): string
{
// Already a full URL — leave as-is
if (str_starts_with($value, 'http://') || str_starts_with($value, 'https://')) {
return $value;
}
$handle = ltrim($value, '@');
return match ($platform) {
'linkedin' => 'https://linkedin.com/in/' . $handle,
'twitter' => 'https://x.com/' . $handle,
'instagram' => 'https://instagram.com/' . $handle,
'facebook' => 'https://facebook.com/' . $handle,
'tiktok' => 'https://tiktok.com/@' . $handle,
'youtube' => 'https://youtube.com/@' . $handle,
'snapchat' => 'https://snapchat.com/add/' . $handle,
'whatsapp' => 'https://wa.me/' . preg_replace('/\D+/', '', $value),
default => $value,
};
}
private function requireUrl(mixed $value, string $message): string
{
$url = trim((string) $value);
if ($url === '' || ! filter_var($url, FILTER_VALIDATE_URL)) {
throw new RuntimeException($message);
}
return $url;
}
/** @return list<array{title: string, url: string}> */
private function normalizeLinks(mixed $links): array
{
if (! is_array($links)) {
return [];
}
$normalized = [];
foreach ($links as $link) {
if (! is_array($link)) {
continue;
}
$title = trim((string) ($link['title'] ?? ''));
$url = trim((string) ($link['url'] ?? ''));
if ($title === '' || $url === '' || ! filter_var($url, FILTER_VALIDATE_URL)) {
continue;
}
$normalized[] = ['title' => $title, 'url' => $url];
}
return $normalized;
}
/**
* Normalize menu/shop sections. Preserves existing image_path on items so that
* the manager can inject freshly uploaded paths after validation.
*
* @return list<array{name: string, items: list<array{name: string, description: string, price: string, image_path: ?string}>}>
*/
private function normalizeMenuSections(mixed $sections): array
{
if (! is_array($sections)) {
return [];
}
$normalized = [];
foreach ($sections as $section) {
if (! is_array($section)) {
continue;
}
$name = trim((string) ($section['name'] ?? ''));
$items = [];
foreach (($section['items'] ?? []) as $item) {
if (! is_array($item)) {
continue;
}
$itemName = trim((string) ($item['name'] ?? ''));
if ($itemName === '') {
continue;
}
$items[] = [
'name' => $itemName,
'description' => trim((string) ($item['description'] ?? '')),
'price' => trim((string) ($item['price'] ?? '')),
'image_path' => ($item['image_path'] ?? null) ?: null,
];
}
if ($name !== '' && $items !== []) {
$normalized[] = ['name' => $name, 'items' => $items];
}
}
return $normalized;
}
public function validateImageUpload(?UploadedFile $file): void
{
if (! $file instanceof UploadedFile) {
throw new RuntimeException('Upload at least one image.');
}
$mime = $file->getMimeType() ?: '';
if (! str_starts_with($mime, 'image/')) {
throw new RuntimeException('Only image files are supported.');
}
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
namespace App\Services\Qr;
class QrPdfExporter
{
public function fromPng(string $pngBinary, string $title): string
{
if (! extension_loaded('gd')) {
throw new \RuntimeException('PDF export requires the GD extension.');
}
$image = imagecreatefromstring($pngBinary);
if ($image === false) {
throw new \RuntimeException('Could not read QR image for PDF export.');
}
$width = imagesx($image);
$height = imagesy($image);
$canvas = imagecreatetruecolor($width, $height);
$white = imagecolorallocate($canvas, 255, 255, 255);
imagefilledrectangle($canvas, 0, 0, $width, $height, $white);
imagecopy($canvas, $image, 0, 0, 0, 0, $width, $height);
imagedestroy($image);
ob_start();
imagejpeg($canvas, null, 95);
$jpeg = (string) ob_get_clean();
imagedestroy($canvas);
return $this->buildPdf($jpeg, $title, $width, $height);
}
private function buildPdf(string $jpeg, string $title, int $imgW, int $imgH): string
{
$pageW = 595.28;
$pageH = 841.89;
$margin = 48;
$maxQr = min($pageW - ($margin * 2), 320);
$scale = min($maxQr / max(1, $imgW), $maxQr / max(1, $imgH));
$drawW = $imgW * $scale;
$drawH = $imgH * $scale;
$x = ($pageW - $drawW) / 2;
$y = 120;
$safeTitle = $this->pdfEscape(substr($title, 0, 80));
$objects = [];
$objects[] = "1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n";
$objects[] = "2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n";
$objects[] = sprintf(
"3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 %.2F %.2F] /Resources << /Font << /F1 4 0 R >> /XObject << /Im1 5 0 R >> >> /Contents 6 0 R >> endobj\n",
$pageW,
$pageH,
);
$objects[] = "4 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >> endobj\n";
$objects[] = sprintf(
"5 0 obj << /Type /XObject /Subtype /Image /Width %d /Height %d /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length %d >> stream\n%s\nendstream\nendobj\n",
$imgW,
$imgH,
strlen($jpeg),
$jpeg,
);
$content = "BT /F1 16 Tf 48 " . ($pageH - 72) . " Td ({$safeTitle}) Tj ET\n";
$content .= sprintf("q %.4F 0 0 %.4F %.2F %.2F cm /Im1 Do Q\n", $drawW, $drawH, $x, $pageH - $y - $drawH);
$content .= "BT /F1 10 Tf 48 48 Td (Generated by Ladill QR Codes) Tj ET\n";
$objects[] = sprintf("6 0 obj << /Length %d >> stream\n%s\nendstream\nendobj\n", strlen($content), $content);
$pdf = "%PDF-1.4\n";
$offsets = [0];
foreach ($objects as $object) {
$offsets[] = strlen($pdf);
$pdf .= $object;
}
$xrefPos = strlen($pdf);
$pdf .= "xref\n0 " . count($offsets) . "\n";
$pdf .= "0000000000 65535 f \n";
for ($i = 1; $i < count($offsets); $i++) {
$pdf .= sprintf("%010d 00000 n \n", $offsets[$i]);
}
$pdf .= "trailer << /Size " . count($offsets) . " /Root 1 0 R >>\n";
$pdf .= "startxref\n{$xrefPos}\n%%EOF";
return $pdf;
}
private function pdfEscape(string $text): string
{
return str_replace(['\\', '(', ')'], ['\\\\', '\\(', '\\)'], $text);
}
}
+89
View File
@@ -0,0 +1,89 @@
<?php
namespace App\Services\Qr;
use App\Models\QrCode;
use App\Models\QrScanEvent;
use App\Models\QrWallet;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class QrScanRecorder
{
public function record(QrCode $qrCode, Request $request): QrScanEvent
{
$parsed = $this->parseUserAgent((string) $request->userAgent());
$ipHash = $this->hashIp((string) $request->ip());
$windowHours = (int) config('qr.scan_unique_window_hours', 24);
$isUnique = ! QrScanEvent::query()
->where('qr_code_id', $qrCode->id)
->where('ip_hash', $ipHash)
->where('scanned_at', '>=', now()->subHours($windowHours))
->exists();
$event = QrScanEvent::create([
'qr_code_id' => $qrCode->id,
'scanned_at' => now(),
'ip_hash' => $ipHash,
'user_agent' => Str::limit((string) $request->userAgent(), 500, ''),
'device_type' => $parsed['device_type'],
'browser' => $parsed['browser'],
'os' => $parsed['os'],
'referrer' => Str::limit((string) $request->headers->get('referer'), 255, ''),
'is_unique' => $isUnique,
]);
$qrCode->increment('scans_total');
if ($isUnique) {
$qrCode->increment('unique_scans_total');
}
$qrCode->update(['last_scanned_at' => now()]);
QrWallet::query()
->where('user_id', $qrCode->user_id)
->increment('scans_total');
return $event;
}
private function hashIp(string $ip): string
{
return hash('sha256', $ip . '|' . (string) config('app.key'));
}
/**
* @return array{device_type: string, browser: string, os: string}
*/
private function parseUserAgent(string $ua): array
{
$uaLower = strtolower($ua);
$device = str_contains($uaLower, 'mobile') || str_contains($uaLower, 'android') || str_contains($uaLower, 'iphone')
? 'mobile'
: (str_contains($uaLower, 'tablet') || str_contains($uaLower, 'ipad') ? 'tablet' : 'desktop');
$browser = match (true) {
str_contains($uaLower, 'edg/') => 'Edge',
str_contains($uaLower, 'chrome/') && ! str_contains($uaLower, 'edg/') => 'Chrome',
str_contains($uaLower, 'safari/') && ! str_contains($uaLower, 'chrome/') => 'Safari',
str_contains($uaLower, 'firefox/') => 'Firefox',
default => 'Other',
};
$os = match (true) {
str_contains($uaLower, 'iphone') || str_contains($uaLower, 'ipad') => 'iOS',
str_contains($uaLower, 'android') => 'Android',
str_contains($uaLower, 'windows') => 'Windows',
str_contains($uaLower, 'mac os') || str_contains($uaLower, 'macintosh') => 'macOS',
str_contains($uaLower, 'linux') => 'Linux',
default => 'Other',
};
return [
'device_type' => $device,
'browser' => $browser,
'os' => $os,
];
}
}
@@ -0,0 +1,74 @@
<?php
namespace App\Services\Qr;
use App\Models\QrCode;
use App\Models\QrTransaction;
use App\Models\QrWallet;
use App\Models\User;
use App\Services\Billing\BillingClient;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use RuntimeException;
/**
* QR Plus billing consumes the platform UserWallet via the Billing API.
*/
class QrWalletBillingService
{
public function __construct(
private BillingClient $billing,
) {}
public function balanceCedis(User $user): float
{
return $this->billing->balanceMinor($user->public_id) / 100;
}
public function canCreate(User $user): bool
{
$priceMinor = (int) round(QrWallet::pricePerQr() * 100);
return $this->billing->canAfford($user->public_id, $priceMinor);
}
public function debitForQrCreation(QrWallet $wallet, QrCode $qrCode): QrTransaction
{
$price = QrWallet::pricePerQr();
$priceMinor = (int) round($price * 100);
$user = $wallet->user;
$reference = 'QR-DEBIT-'.strtoupper(Str::random(12));
return DB::transaction(function () use ($wallet, $user, $qrCode, $price, $priceMinor, $reference) {
$ok = $this->billing->debit(
$user->public_id,
$priceMinor,
'qr',
'qr_create',
$reference,
$qrCode->id,
sprintf('Created QR code: %s', $qrCode->label),
);
if (! $ok) {
throw new RuntimeException('Insufficient wallet balance.');
}
$wallet->increment('qr_codes_total');
$balanceAfter = $this->billing->balanceMinor($user->public_id) / 100;
return QrTransaction::create([
'user_id' => $wallet->user_id,
'qr_wallet_id' => $wallet->id,
'qr_code_id' => $qrCode->id,
'type' => QrTransaction::TYPE_DEBIT,
'amount_ghs' => round($price, 4),
'balance_after_ghs' => $balanceAfter,
'reference' => $reference,
'status' => 'completed',
'description' => sprintf('Created QR code: %s', $qrCode->label),
'metadata' => ['qr_code_id' => $qrCode->id],
]);
});
}
}
+369
View File
@@ -0,0 +1,369 @@
<?php
namespace App\Support;
use App\Services\Domain\DomainPricingService;
class DomainConfig
{
private const PRIORITY_TLDS = [
'com', 'org', 'net', 'ai', 'io', 'co', 'app', 'dev',
'store', 'shop', 'online', 'site', 'tech', 'cloud', 'pro', 'me',
'xyz', 'club', 'live', 'com.ng', 'com.gh', 'co.za', 'co.uk', 'info', 'biz',
];
private const DEFAULT_PAGE_SIZE = 20;
/**
* Get featured TLDs for initial display (lightweight).
*
* @return list<string>
*/
public static function featuredTlds(): array
{
$allTlds = self::allTlds();
$featured = array_values(array_intersect(self::PRIORITY_TLDS, $allTlds));
if ($featured !== []) {
return $featured;
}
return array_slice($allTlds, 0, self::DEFAULT_PAGE_SIZE);
}
/**
* Get all available TLDs sorted by priority.
*
* @return list<string>
*/
public static function allTlds(): array
{
$current = self::normalizeTlds(config('domain.search_tlds', []));
$legacy = self::normalizeTlds(config('mailinfra.domain_search_tlds', []));
$tlds = $legacy !== [] ? $legacy : $current;
$livePricedTlds = self::livePricedTlds();
if ($livePricedTlds === []) {
return [];
}
$livePricedLookup = array_flip($livePricedTlds);
$tlds = array_values(array_filter(
$tlds,
static fn (string $tld): bool => isset($livePricedLookup[$tld])
));
return self::sortWithPriority($tlds);
}
/**
* Get paginated TLDs (excluding featured ones).
*
* @return array{tlds: list<string>, hasMore: bool, total: int}
*/
public static function paginatedTlds(int $page = 1, int $perPage = self::DEFAULT_PAGE_SIZE): array
{
$allTlds = self::allTlds();
$featured = self::featuredTlds();
$remaining = array_values(array_filter($allTlds, fn ($tld) => !in_array($tld, $featured, true)));
$total = count($remaining);
$offset = ($page - 1) * $perPage;
$tlds = array_slice($remaining, $offset, $perPage);
return [
'tlds' => $tlds,
'hasMore' => ($offset + $perPage) < $total,
'total' => $total,
];
}
/**
* @deprecated Use featuredTlds() or allTlds() instead
* @return list<string>
*/
public static function searchTlds(): array
{
return self::featuredTlds();
}
/**
* Build the TLD list and query term for a search page request.
*
* @return array{query: string, tlds: list<string>, hasMore: bool, exact_domain: string|null}
*/
public static function searchPageConfig(string $query, int $page = 1, int $perPage = self::DEFAULT_PAGE_SIZE): array
{
$query = strtolower(trim($query));
$allTlds = self::allTlds();
if ($allTlds === []) {
return [
'query' => $query,
'tlds' => [],
'hasMore' => false,
'exact_domain' => null,
];
}
$exactDomain = self::normalizeExactDomain($query);
if ($exactDomain === null) {
if ($page === 1) {
$featured = self::featuredTlds();
$paginatedData = self::paginatedTlds(1, $perPage);
return [
'query' => $query,
'tlds' => $featured,
'hasMore' => $paginatedData['hasMore'] || count($paginatedData['tlds']) > 0,
'exact_domain' => null,
];
}
$paginatedData = self::paginatedTlds($page - 1, $perPage);
return [
'query' => $query,
'tlds' => $paginatedData['tlds'],
'hasMore' => $paginatedData['hasMore'],
'exact_domain' => null,
];
}
$exactTld = self::extractTld($exactDomain);
$keyword = explode('.', $exactDomain, 2)[0] ?? $exactDomain;
$firstPageTlds = array_values(array_unique(array_filter([
$exactTld,
...self::featuredTlds(),
])));
$remainingTlds = array_values(array_filter(
$allTlds,
static fn (string $tld): bool => ! in_array($tld, $firstPageTlds, true)
));
if ($page === 1) {
return [
'query' => $keyword,
'tlds' => $firstPageTlds,
'hasMore' => $remainingTlds !== [],
'exact_domain' => $exactDomain,
];
}
$offset = max(0, ($page - 2) * $perPage);
return [
'query' => $keyword,
'tlds' => array_slice($remainingTlds, $offset, $perPage),
'hasMore' => ($offset + $perPage) < count($remainingTlds),
'exact_domain' => $exactDomain,
];
}
/**
* Sort domain search results by TLD priority.
*
* @param array<int, array{domain: string, ...}> $results
* @return array<int, array{domain: string, ...}>
*/
public static function sortResultsByTldPriority(array $results, ?string $query = null): array
{
$priorityMap = array_flip(self::PRIORITY_TLDS);
$exactDomain = self::normalizeExactDomain((string) $query);
usort($results, static function (array $a, array $b) use ($priorityMap, $exactDomain): int {
$aDomain = strtolower((string) ($a['domain'] ?? ''));
$bDomain = strtolower((string) ($b['domain'] ?? ''));
$aExact = $exactDomain !== null && $aDomain === $exactDomain ? 0 : 1;
$bExact = $exactDomain !== null && $bDomain === $exactDomain ? 0 : 1;
if ($aExact !== $bExact) {
return $aExact <=> $bExact;
}
$aPremium = self::isPremiumResult($a) ? 0 : 1;
$bPremium = self::isPremiumResult($b) ? 0 : 1;
if ($aPremium !== $bPremium) {
return $aPremium <=> $bPremium;
}
$aAvailable = ! empty($a['available']) ? 0 : 1;
$bAvailable = ! empty($b['available']) ? 0 : 1;
if ($aAvailable !== $bAvailable) {
return $aAvailable <=> $bAvailable;
}
$aTld = self::extractTld($a['domain'] ?? '');
$bTld = self::extractTld($b['domain'] ?? '');
$aPriority = $priorityMap[$aTld] ?? PHP_INT_MAX;
$bPriority = $priorityMap[$bTld] ?? PHP_INT_MAX;
if ($aPriority !== $bPriority) {
return $aPriority <=> $bPriority;
}
return $aDomain <=> $bDomain;
});
return $results;
}
/**
* Extract TLD from a domain name.
*/
private static function extractTld(string $domain): string
{
$parts = explode('.', strtolower($domain), 2);
return $parts[1] ?? '';
}
private static function normalizeExactDomain(string $query): ?string
{
$query = strtolower(trim($query));
if ($query === '' || str_starts_with($query, '.') || str_ends_with($query, '.') || ! str_contains($query, '.')) {
return null;
}
return preg_match('/^(?=.{1,253}$)(?!-)(?:[a-z0-9-]{1,63}\.)+[a-z]{2,63}$/', $query) === 1
? $query
: null;
}
/**
* @param array<string, mixed> $result
*/
private static function isPremiumResult(array $result): bool
{
$premium = $result['premium'] ?? false;
if (is_bool($premium)) {
return $premium;
}
return in_array(strtolower(trim((string) $premium)), ['1', 'yes', 'true', 'premium'], true);
}
/**
* @param list<string> $tlds
* @return list<string>
*/
private static function sortWithPriority(array $tlds): array
{
$priorityMap = array_flip(self::PRIORITY_TLDS);
$pricingMap = self::pricingMapForTlds($tlds);
usort($tlds, static function (string $a, string $b) use ($priorityMap, $pricingMap): int {
$aPriority = $priorityMap[$a] ?? PHP_INT_MAX;
$bPriority = $priorityMap[$b] ?? PHP_INT_MAX;
if ($aPriority !== $bPriority) {
return $aPriority <=> $bPriority;
}
$aPrice = (int) ($pricingMap[$a]['register'] ?? 0);
$bPrice = (int) ($pricingMap[$b]['register'] ?? 0);
if ($aPrice !== $bPrice) {
return $bPrice <=> $aPrice;
}
return $a <=> $b;
});
return $tlds;
}
/**
* @return array<string, string>
*/
public static function resellerClubTldProductKeys(): array
{
return array_replace(
self::normalizeProductKeyMap(config('domain.resellerclub.tld_product_keys', [])),
self::normalizeProductKeyMap(config('mailinfra.resellerclub_tld_product_keys', []))
);
}
public static function resellerClubProductKeyCacheTtlSeconds(): int
{
$ttl = (int) config('domain.resellerclub.product_key_cache_ttl_seconds', 86400);
return $ttl > 0 ? $ttl : 86400;
}
/**
* @param mixed $value
* @return list<string>
*/
private static function normalizeTlds(mixed $value): array
{
if (! is_array($value)) {
return [];
}
return array_values(array_unique(array_filter(array_map(
static fn ($tld): string => ltrim(strtolower(trim((string) $tld)), '.'),
$value
))));
}
/**
* @param mixed $value
* @return array<string, string>
*/
private static function normalizeProductKeyMap(mixed $value): array
{
if (! is_array($value)) {
return [];
}
$map = [];
foreach ($value as $tld => $productKey) {
$normalizedTld = ltrim(strtolower(trim((string) $tld)), '.');
$normalizedProductKey = trim((string) $productKey);
if ($normalizedTld === '' || $normalizedProductKey === '') {
continue;
}
$map[$normalizedTld] = $normalizedProductKey;
}
return $map;
}
/**
* @return list<string>
*/
private static function livePricedTlds(): array
{
try {
return app(DomainPricingService::class)->getLivePricedTlds();
} catch (\Throwable) {
return [];
}
}
/**
* @param list<string> $tlds
* @return array<string, array{register: int, renew: int, transfer: int, currency: string}>
*/
private static function pricingMapForTlds(array $tlds): array
{
try {
return app(DomainPricingService::class)->getPricingForTlds($tlds);
} catch (\Throwable) {
return [];
}
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace App\Support;
final class DomainGlobeIcon
{
public const VIEW_BOX = '0 0 14 14';
/** 14×14 globe for UI icons (inherits color via currentColor). */
public const ICON_ASSET = 'images/ladill-icons/domain.svg';
public static function paths(): string
{
return '<path d="M7 13.5C10.5899 13.5 13.5 10.5899 13.5 7C13.5 3.41015 10.5899 0.5 7 0.5C3.41015 0.5 0.5 3.41015 0.5 7C0.5 10.5899 3.41015 13.5 7 13.5Z" stroke-linecap="round" stroke-linejoin="round"/>'
.'<path d="M0.5 7H13.5" stroke-linecap="round" stroke-linejoin="round"/>'
.'<path d="M9.5 7C9.3772 9.37699 8.50168 11.6533 7 13.5C5.49832 11.6533 4.6228 9.37699 4.5 7C4.6228 4.62301 5.49832 2.34665 7 0.5C8.50168 2.34665 9.3772 4.62301 9.5 7V7Z" stroke-linecap="round" stroke-linejoin="round"/>';
}
public static function svg(string $class = 'h-5 w-5'): string
{
return sprintf(
'<svg class="%s" viewBox="%s" fill="none" stroke="currentColor" stroke-width="1" aria-hidden="true">%s</svg>',
e($class),
self::VIEW_BOX,
self::paths()
);
}
}
+69
View File
@@ -0,0 +1,69 @@
<?php
namespace App\Support;
/** Storage-tier pricing for mailboxes (config/email.php quota_tiers). */
class MailboxPricing
{
/** @return array<int,array{mb:int,price_minor:int,label:string}> */
public static function tiers(): array
{
return array_map(fn ($t) => [
'mb' => (int) $t['mb'],
'price_minor' => (int) $t['price_minor'],
'label' => self::label((int) $t['mb']),
], (array) config('email.quota_tiers', []));
}
public static function isValidQuota(int $mb): bool
{
foreach (self::tiers() as $t) {
if ($t['mb'] === $mb) {
return true;
}
}
return false;
}
public static function priceMinorFor(int $mb): int
{
foreach (self::tiers() as $t) {
if ($t['mb'] === $mb) {
return $t['price_minor'];
}
}
return 0;
}
/** A tier is free when its monthly price is zero (the 1 GB plan). */
public static function isFree(int $mb): bool
{
return self::priceMinorFor($mb) === 0;
}
/** The smallest free tier's quota (the default for new mailboxes). */
public static function freeQuotaMb(): int
{
foreach (self::tiers() as $t) {
if ($t['price_minor'] === 0) {
return $t['mb'];
}
}
return self::tiers()[0]['mb'] ?? 1024;
}
public static function defaultQuotaMb(): int
{
$default = (int) config('email.default_quota_mb', 1024);
return self::isValidQuota($default) ? $default : self::freeQuotaMb();
}
public static function label(int $mb): string
{
return $mb % 1024 === 0 ? ($mb / 1024).' GB' : $mb.' MB';
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace App\Support\Qr;
use App\Models\QrCode;
use Illuminate\Support\Collection;
/**
* Generates ZPL II label code for Zebra-style badge/label printers.
* One ^XA…^XZ label block per attendee, sized to the chosen badge size at 203 dpi.
*/
class EventBadgeZpl
{
/** @param Collection<int, \App\Models\QrEventRegistration> $registrations */
public static function forRegistrations(QrCode $qrCode, Collection $registrations): string
{
$content = $qrCode->content();
$eventName = self::sanitize($content['name'] ?? $qrCode->label);
$size = $content['badge_size'] ?? '4x3';
// Label dimensions in dots @ 203 dpi.
[$widthDots, $heightDots] = match ($size) {
'4x6' => [812, 1218],
'cr80' => [685, 431],
default => [812, 609], // 4x3
};
$labels = [];
foreach ($registrations as $reg) {
$name = self::sanitize($reg->attendee_name);
$tier = self::sanitize($reg->tier_name);
$extra = collect($reg->badge_fields ?? [])
->map(fn ($v, $k) => self::sanitize($k . ': ' . $v))
->implode(' | ');
$zpl = "^XA\n";
$zpl .= "^PW{$widthDots}\n";
$zpl .= "^LL{$heightDots}\n";
$zpl .= "^CI28\n"; // UTF-8
// Event name (top)
$zpl .= "^FO40,40^A0N,40,40^FB" . ($widthDots - 80) . ",1,0,C^FD{$eventName}^FS\n";
// Attendee name (large, centered)
$zpl .= "^FO40,150^A0N,80,80^FB" . ($widthDots - 80) . ",2,0,C^FD{$name}^FS\n";
// Tier
$zpl .= "^FO40,320^A0N,40,40^FB" . ($widthDots - 80) . ",1,0,C^FD{$tier}^FS\n";
// Extra fields
if ($extra !== '') {
$zpl .= "^FO40,375^A0N,28,28^FB" . ($widthDots - 80) . ",2,0,C^FD{$extra}^FS\n";
}
// QR of the badge code (bottom)
$qrX = (int) (($widthDots / 2) - 70);
$zpl .= "^FO{$qrX}," . ($heightDots - 200) . "^BQN,2,5^FDLA,{$reg->badge_code}^FS\n";
// Badge code text
$zpl .= "^FO40," . ($heightDots - 60) . "^A0N,34,34^FB" . ($widthDots - 80) . ",1,0,C^FD{$reg->badge_code}^FS\n";
$zpl .= "^XZ\n";
$labels[] = $zpl;
}
return implode("\n", $labels);
}
private static function sanitize(string $value): string
{
// Escape ZPL control chars (^ and ~) and collapse whitespace.
$value = str_replace(['^', '~'], [' ', ' '], $value);
return trim(preg_replace('/\s+/', ' ', $value) ?? '');
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace App\Support\Qr;
class QrCornerStyleCatalog
{
/** @return array<string, array{label: string}> */
public static function outerStyles(): array
{
return [
'square' => ['label' => 'Square eye'],
'rounded' => ['label' => 'Rounded'],
'circle' => ['label' => 'Circular'],
];
}
/** @return array<string, array{label: string}> */
public static function innerStyles(): array
{
return [
'square' => ['label' => 'Square'],
'rounded' => ['label' => 'Rounded'],
'dot' => ['label' => 'Dot'],
];
}
public static function isValidOuter(string $style): bool
{
return isset(self::outerStyles()[$style]);
}
public static function isValidInner(string $style): bool
{
return isset(self::innerStyles()[$style]);
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Support\Qr;
class QrCoverImageSpec
{
/** Wide hero banners (business, church, event, itinerary, menu, shop). */
public const BANNER = '1920×1080 px (16:9 landscape)';
/** Book product cover on the landing page. */
public const BOOK = '800×1067 px (3:4 portrait)';
public static function label(string $variant = 'banner'): string
{
return match ($variant) {
'book' => self::BOOK,
default => self::BANNER,
};
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace App\Support\Qr;
use Carbon\Carbon;
class QrDateFormatter
{
public static function forInput(?string $value): string
{
if ($value === null || trim($value) === '') {
return '';
}
try {
return Carbon::parse($value)->format('Y-m-d');
} catch (\Throwable) {
return '';
}
}
public static function forDisplay(?string $value): string
{
if ($value === null || trim($value) === '') {
return '';
}
$value = trim($value);
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) {
try {
return Carbon::createFromFormat('Y-m-d', $value)->format('D, j M Y');
} catch (\Throwable) {
return $value;
}
}
return $value;
}
public static function normalize(?string $value, int $maxLength = 60): string
{
if ($value === null || trim($value) === '') {
return '';
}
$value = trim($value);
try {
return Carbon::parse($value)->format('Y-m-d');
} catch (\Throwable) {
return mb_substr($value, 0, $maxLength);
}
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace App\Support\Qr;
class QrFrameStyleCatalog
{
/** @return array<string, array{label: string, description: string, border_px: int, mode: string, cta?: string, visible?: bool}> */
public static function all(): array
{
return [
'none' => [
'label' => 'None',
'description' => 'Code only',
'border_px' => 0,
'mode' => 'none',
],
'thin' => [
'label' => 'Border',
'description' => 'Simple white sticker edge',
'border_px' => 8,
'mode' => 'border',
],
'bold' => [
'label' => 'Wide border',
'description' => 'Legacy wide border',
'border_px' => 16,
'mode' => 'border',
'visible' => false,
],
'scan_me' => [
'label' => 'Scan me',
'description' => 'CTA sticker below code',
'border_px' => 14,
'mode' => 'label',
'cta' => 'SCAN ME',
],
'tap_to_scan' => [
'label' => 'Tap to scan',
'description' => 'Rounded CTA sticker',
'border_px' => 14,
'mode' => 'pill',
'cta' => 'TAP TO SCAN',
],
];
}
/** @return array<string, array{label: string, description: string, border_px: int, mode: string, cta?: string, visible?: bool}> */
public static function visible(): array
{
return array_filter(self::all(), fn (array $style): bool => ($style['visible'] ?? true) === true);
}
/** @return list<string> */
public static function keys(): array
{
return array_keys(self::all());
}
public static function isValid(string $style): bool
{
return isset(self::all()[$style]);
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
namespace App\Support\Qr;
class QrModuleStyleCatalog
{
/** @return array<string, array{label: string, description: string, circular: bool, circle_radius: float, connect_paths: bool, scan_risk: string, visible?: bool}> */
public static function all(): array
{
return [
'square' => [
'label' => 'Standard',
'description' => 'Traditional square QR modules',
'circular' => false,
'circle_radius' => 0.4,
'connect_paths' => false,
'scan_risk' => 'low',
],
'soft' => [
'label' => 'Rounded',
'description' => 'Soft rounded modules',
'circular' => true,
'circle_radius' => 0.36,
'connect_paths' => false,
'scan_risk' => 'medium',
'visible' => false,
],
'dots' => [
'label' => 'Dots',
'description' => 'Round dot modules',
'circular' => true,
'circle_radius' => 0.45,
'connect_paths' => false,
'scan_risk' => 'medium',
],
'bubble' => [
'label' => 'Large dots',
'description' => 'Bolder round dot modules',
'circular' => true,
'circle_radius' => 0.52,
'connect_paths' => false,
'scan_risk' => 'high',
'visible' => false,
],
'fluid' => [
'label' => 'Fluid',
'description' => 'Legacy decorative style',
'circular' => true,
'circle_radius' => 0.38,
'connect_paths' => true,
'scan_risk' => 'high',
'visible' => false,
],
'bold' => [
'label' => 'Bold',
'description' => 'Legacy thick square modules',
'circular' => false,
'circle_radius' => 0.4,
'connect_paths' => false,
'scan_risk' => 'low',
'visible' => false,
],
];
}
/** @return array<string, array{label: string, description: string, circular: bool, circle_radius: float, connect_paths: bool, scan_risk: string, visible?: bool}> */
public static function visible(): array
{
return array_filter(self::all(), fn (array $style): bool => ($style['visible'] ?? true) === true);
}
/** @return list<string> */
public static function keys(): array
{
return array_keys(self::all());
}
public static function isValid(string $style): bool
{
return isset(self::all()[$style]);
}
/** @return array<string, mixed> */
public static function optionsFor(string $style): array
{
return self::all()[$style] ?? self::all()['square'];
}
public static function isDecorative(string $style): bool
{
return in_array(self::optionsFor($style)['scan_risk'], ['medium', 'high'], true);
}
}
+102
View File
@@ -0,0 +1,102 @@
<?php
namespace App\Support\Qr;
class QrScanReliability
{
/** @param array<string, mixed> $style */
public static function contrastRatio(array $style): float
{
$fg = self::relativeLuminance((string) ($style['foreground'] ?? '#000000'));
$bg = self::relativeLuminance((string) ($style['background'] ?? '#ffffff'));
$lighter = max($fg, $bg);
$darker = min($fg, $bg);
return ($lighter + 0.05) / ($darker + 0.05);
}
/**
* @param array<string, mixed> $style
* @return array{level: string, messages: list<string>}
*/
public static function assess(array $style): array
{
$style = QrStyleDefaults::merge($style);
$messages = [];
$level = 'good';
$contrast = self::contrastRatio($style);
$isClassic = self::isClassicBlackOnWhite($style);
$moduleStyle = (string) ($style['module_style'] ?? 'square');
$moduleMeta = QrModuleStyleCatalog::optionsFor($moduleStyle);
if ($moduleMeta['scan_risk'] === 'medium') {
$messages[] = 'This module style may not scan on every phone camera. Square is the safest choice.';
$level = 'fair';
}
if ($moduleMeta['scan_risk'] === 'high') {
$messages[] = 'Decorative styles like ' . $moduleMeta['label'] . ' often fail on Samsung Camera. Use square for print and signage.';
$level = 'poor';
}
if (! $isClassic) {
$messages[] = 'Colored QR codes often fail on Samsung Camera and other basic scanners. Black on white works everywhere.';
$level = $level === 'good' ? 'fair' : 'poor';
}
if ($contrast < 4.5) {
$messages[] = sprintf(
'Contrast is low (%.1f:1). Aim for at least 4.5:1 — dark foreground, white background.',
$contrast,
);
$level = 'poor';
}
if ($moduleMeta['scan_risk'] !== 'low' && ! $isClassic) {
$level = 'poor';
}
if ($messages === []) {
$messages[] = 'Black square modules on white give the best compatibility with Samsung, iPhone, and printed codes.';
}
return ['level' => $level, 'messages' => $messages];
}
/** @param array<string, mixed> $style */
public static function isClassicBlackOnWhite(array $style): bool
{
$fg = strtolower(ltrim((string) ($style['foreground'] ?? ''), '#'));
$bg = strtolower(ltrim((string) ($style['background'] ?? ''), '#'));
$fg = strlen($fg) === 3 ? $fg[0] . $fg[0] . $fg[1] . $fg[1] . $fg[2] . $fg[2] : $fg;
$bg = strlen($bg) === 3 ? $bg[0] . $bg[0] . $bg[1] . $bg[1] . $bg[2] . $bg[2] : $bg;
return in_array($fg, ['000000', '0f172a', '111111', '1a1a1a'], true)
&& in_array($bg, ['ffffff', 'fff'], true);
}
private static function relativeLuminance(string $hex): float
{
$hex = ltrim(trim($hex), '#');
if (strlen($hex) === 3) {
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
}
if (strlen($hex) !== 6 || ! ctype_xdigit($hex)) {
return 0.0;
}
$channels = [];
foreach ([0, 2, 4] as $offset) {
$value = hexdec(substr($hex, $offset, 2)) / 255;
$channels[] = $value <= 0.03928
? $value / 12.92
: (($value + 0.055) / 1.055) ** 2.4;
}
return (0.2126 * $channels[0]) + (0.7152 * $channels[1]) + (0.0722 * $channels[2]);
}
}
+114
View File
@@ -0,0 +1,114 @@
<?php
namespace App\Support\Qr;
class QrStyleDefaults
{
/** @return array<string, mixed> */
public static function defaults(): array
{
return [
'foreground' => '#000000',
'background' => '#ffffff',
'error_correction' => 'M',
'margin' => 4,
'module_style' => 'square',
'finder_outer' => 'square',
'finder_inner' => 'square',
'frame_style' => 'none',
'frame_text' => '',
'frame_color' => '#000000',
'scale' => 8,
'logo_path' => null,
'gradient_type' => 'none',
'gradient_color1' => '#000000',
'gradient_color2' => '#7c3aed',
'gradient_rotation' => 45,
'logo_size' => 0.3,
'logo_margin' => 5,
'logo_white_bg' => false,
'logo_shape' => 'none',
];
}
/** @param array<string, mixed>|null $style */
public static function merge(?array $style): array
{
$merged = array_merge(self::defaults(), $style ?? []);
if (! in_array($merged['error_correction'], ['L', 'M', 'Q', 'H'], true)) {
$merged['error_correction'] = 'M';
}
if (! in_array($merged['module_style'], QrModuleStyleCatalog::keys(), true)) {
$merged['module_style'] = 'square';
}
if (! QrCornerStyleCatalog::isValidOuter((string) ($merged['finder_outer'] ?? 'square'))) {
$merged['finder_outer'] = 'square';
}
if (! QrCornerStyleCatalog::isValidInner((string) ($merged['finder_inner'] ?? 'square'))) {
$merged['finder_inner'] = 'square';
}
if (! QrFrameStyleCatalog::isValid((string) ($merged['frame_style'] ?? 'none'))) {
$merged['frame_style'] = 'none';
}
$merged['frame_text'] = substr(trim((string) ($merged['frame_text'] ?? '')), 0, 100);
$frameColor = trim((string) ($merged['frame_color'] ?? '#000000'));
$merged['frame_color'] = preg_match('/^#[0-9a-fA-F]{6}$/', $frameColor) ? $frameColor : '#000000';
if (! in_array($merged['gradient_type'], ['none', 'linear', 'radial'], true)) {
$merged['gradient_type'] = 'none';
}
$merged['logo_size'] = max(0.1, min(0.4, (float) ($merged['logo_size'] ?? 0.3)));
$merged['logo_margin'] = max(0, min(15, (int) ($merged['logo_margin'] ?? 5)));
if (! in_array($merged['logo_shape'], ['none', 'rounded', 'circle'], true)) {
$merged['logo_shape'] = 'none';
}
$merged['logo_white_bg'] = (bool) ($merged['logo_white_bg'] ?? false);
if ($merged['module_style'] === 'bold') {
$merged['scale'] = min(16, (int) $merged['scale'] + 2);
}
$merged['margin'] = max(0, min(10, (int) $merged['margin']));
$merged['scale'] = max(4, min(16, (int) $merged['scale']));
return $merged;
}
/** @param array<string, mixed>|null $style */
public static function mergeForRender(?array $style): array
{
return QrStyleNormalizer::normalize(self::merge($style));
}
/** @param array<string, mixed> $style */
public static function recommendedEcc(array $style): string
{
$ecc = (string) ($style['error_correction'] ?? 'M');
$order = ['L' => 0, 'M' => 1, 'Q' => 2, 'H' => 3];
$minimum = 'M';
$moduleStyle = (string) ($style['module_style'] ?? 'square');
if (in_array($moduleStyle, ['bubble', 'fluid'], true)) {
$minimum = 'H';
} elseif (QrModuleStyleCatalog::isDecorative($moduleStyle)) {
$minimum = 'Q';
}
if (! empty($style['logo_path'])) {
$minimum = 'H';
}
if (QrScanReliability::contrastRatio($style) < 4.5) {
$minimum = 'H';
}
return ($order[$minimum] ?? 1) > ($order[$ecc] ?? 1) ? $minimum : $ecc;
}
}
+138
View File
@@ -0,0 +1,138 @@
<?php
namespace App\Support\Qr;
class QrStyleNormalizer
{
/**
* Silently tune styles so more phone cameras (including Samsung) can read the code.
*
* @param array<string, mixed> $style
* @return array<string, mixed>
*/
public static function normalize(array $style): array
{
$style = self::ensureContrast($style);
$style = self::ensureQuietZone($style);
$style = self::ensureRenderScale($style);
$style['error_correction'] = QrStyleDefaults::recommendedEcc($style);
return $style;
}
/** @param array<string, mixed> $style */
private static function ensureContrast(array $style): array
{
$target = 4.5;
$attempts = 0;
while (QrScanReliability::contrastRatio($style) < $target && $attempts < 16) {
$fg = self::hexToRgb((string) $style['foreground']);
$bg = self::hexToRgb((string) $style['background']);
if (self::relativeLuminance($fg) > self::relativeLuminance($bg)) {
$style['foreground'] = self::rgbToHex(self::darkenToward($fg, 0.18));
} else {
$style['foreground'] = self::rgbToHex(self::darkenToward($fg, 0.15));
$style['background'] = self::rgbToHex(self::lightenToward($bg, 0.15));
}
$attempts++;
}
if (QrScanReliability::contrastRatio($style) < $target) {
$style['foreground'] = '#000000';
$style['background'] = '#ffffff';
}
return $style;
}
/** @param array<string, mixed> $style */
private static function ensureQuietZone(array $style): array
{
$margin = (int) ($style['margin'] ?? 4);
$moduleStyle = (string) ($style['module_style'] ?? 'square');
if (QrModuleStyleCatalog::isDecorative($moduleStyle)) {
$margin = max($margin, 5);
}
$style['margin'] = max(4, min(10, $margin));
return $style;
}
/** @param array<string, mixed> $style */
private static function ensureRenderScale(array $style): array
{
$scale = (int) ($style['scale'] ?? 8);
$module = QrModuleStyleCatalog::optionsFor((string) ($style['module_style'] ?? 'square'));
if ($module['circular'] ?? false) {
$scale = max($scale, 10);
}
if (! empty($style['logo_path'])) {
$scale = max($scale, 10);
}
$style['scale'] = max(6, min(16, $scale));
return $style;
}
/** @return array{0: int, 1: int, 2: int} */
private static function hexToRgb(string $hex): array
{
$hex = ltrim(trim($hex), '#');
if (strlen($hex) === 3) {
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
}
if (strlen($hex) !== 6 || ! ctype_xdigit($hex)) {
return [0, 0, 0];
}
return [hexdec(substr($hex, 0, 2)), hexdec(substr($hex, 2, 2)), hexdec(substr($hex, 4, 2))];
}
/** @param array{0: int, 1: int, 2: int} $rgb */
private static function rgbToHex(array $rgb): string
{
return sprintf('#%02x%02x%02x', $rgb[0], $rgb[1], $rgb[2]);
}
/** @param array{0: int, 1: int, 2: int} $rgb */
private static function darkenToward(array $rgb, float $amount): array
{
return [
(int) max(0, $rgb[0] * (1 - $amount)),
(int) max(0, $rgb[1] * (1 - $amount)),
(int) max(0, $rgb[2] * (1 - $amount)),
];
}
/** @param array{0: int, 1: int, 2: int} $rgb */
private static function lightenToward(array $rgb, float $amount): array
{
return [
(int) min(255, $rgb[0] + ((255 - $rgb[0]) * $amount)),
(int) min(255, $rgb[1] + ((255 - $rgb[1]) * $amount)),
(int) min(255, $rgb[2] + ((255 - $rgb[2]) * $amount)),
];
}
/** @param array{0: int, 1: int, 2: int} $rgb */
private static function relativeLuminance(array $rgb): float
{
$channels = [];
foreach ($rgb as $value) {
$v = $value / 255;
$channels[] = $v <= 0.03928 ? $v / 12.92 : (($v + 0.055) / 1.055) ** 2.4;
}
return (0.2126 * $channels[0]) + (0.7152 * $channels[1]) + (0.0722 * $channels[2]);
}
}
+90
View File
@@ -0,0 +1,90 @@
<?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_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_LINK_LIST => [
'label' => 'List of Links',
'description' => 'Landing page with multiple links',
'category' => 'basic',
'icon' => 'list.svg',
],
QrCode::TYPE_BUSINESS => [
'label' => 'Business',
'description' => 'Business profile page',
'category' => 'contact',
'icon' => 'business-profile.svg',
],
QrCode::TYPE_WIFI => [
'label' => 'WiFi',
'description' => 'Network name and password',
'category' => 'contact',
'icon' => 'wifi.svg',
],
QrCode::TYPE_APP => [
'label' => 'App',
'description' => 'App Store and Play Store links',
'category' => 'business',
'icon' => 'app.svg',
],
];
return array_intersect_key($catalog, array_flip(self::plusTypes()));
}
/** @return list<string> */
public static function keys(): array
{
return array_keys(self::all());
}
public static function label(string $type): string
{
return self::all()[$type]['label'] ?? ucfirst(str_replace('_', ' ', $type));
}
public static function isValid(string $type): bool
{
return isset(self::all()[$type]);
}
public static function iconUrl(string $icon): string
{
$path = public_path('images/qr-icons/'.$icon);
$url = '/images/qr-icons/'.$icon;
if (is_file($path)) {
return $url.'?v='.filemtime($path);
}
return $url;
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace App\Support\Qr;
class QrWifiPayload
{
/**
* Build a WiFi QR payload (ZXing MECARD format) for one-tap network join on scan.
*
* @param array<string, mixed> $content
*/
public static function encode(array $content): string
{
$encryption = strtoupper(trim((string) ($content['encryption'] ?? 'WPA')));
$auth = $encryption === 'NOPASS' ? 'nopass' : $encryption;
if (! in_array($auth, ['WPA', 'WEP', 'nopass'], true)) {
$auth = 'WPA';
}
$ssid = self::escape(trim((string) ($content['ssid'] ?? '')));
$payload = 'WIFI:T:' . $auth . ';S:' . $ssid;
if ($auth !== 'nopass') {
$password = trim((string) ($content['password'] ?? ''));
if ($password !== '') {
$payload .= ';P:' . self::escape($password);
}
}
if (filter_var($content['hidden'] ?? false, FILTER_VALIDATE_BOOL)) {
$payload .= ';H:true';
}
return $payload . ';;';
}
private static function escape(string $value): string
{
return str_replace(
['\\', ';', ',', '"', ':'],
['\\\\', '\\;', '\\,', '\\"', '\\:'],
$value
);
}
}
+122
View File
@@ -0,0 +1,122 @@
<?php
namespace App\Support;
use App\Models\DomainOrder;
use App\Models\RcServiceOrder;
use Illuminate\Support\Collection;
use RuntimeException;
/**
* ResellerClub is legacy-only for new sales. Renewals and management remain available
* for customers with existing ResellerClub services.
*/
final class ResellerClubLegacy
{
public static function integrationEnabled(): bool
{
return (bool) config('hosting.legacy.rc_enabled', true);
}
public static function newOrdersEnabled(): bool
{
return self::integrationEnabled()
&& (bool) config('hosting.legacy.rc_new_orders_enabled', false);
}
public static function renewalsEnabled(): bool
{
return self::integrationEnabled()
&& (bool) config('hosting.legacy.rc_renewals_enabled', true);
}
public static function isRenewalRcServiceOrder(RcServiceOrder $order): bool
{
if ($order->order_type === RcServiceOrder::TYPE_RENEWAL) {
return true;
}
return (bool) data_get($order->meta, 'is_renewal', false);
}
public static function isRenewalDomainOrder(DomainOrder $order): bool
{
return $order->order_type === DomainOrder::TYPE_RENEWAL;
}
public static function canAutomateFulfillment(RcServiceOrder $order): bool
{
if (! self::integrationEnabled()) {
return false;
}
if (self::newOrdersEnabled()) {
return true;
}
return self::renewalsEnabled() && self::isRenewalRcServiceOrder($order);
}
/**
* @param Collection<int, RcServiceOrder>|iterable<int, RcServiceOrder> $items
*/
public static function assertCartCheckoutAllowed(iterable $items): void
{
$items = $items instanceof Collection ? $items : collect($items);
if ($items->isEmpty()) {
throw new RuntimeException('Your cart is empty.');
}
if (self::newOrdersEnabled()) {
return;
}
if (! self::renewalsEnabled()) {
throw new RuntimeException(self::renewalsDisabledMessage());
}
if ($items->contains(fn (RcServiceOrder $item) => ! self::isRenewalRcServiceOrder($item))) {
throw new RuntimeException(
'ResellerClub checkout is limited to renewals for existing services. '
.'New product purchases use our current domain and hosting products instead.'
);
}
}
public static function assertNewSaleAllowed(): void
{
if (self::newOrdersEnabled()) {
return;
}
throw new RuntimeException(
'ResellerClub is no longer available for new product purchases. '
.'Use our domain and hosting products instead, or renew an existing ResellerClub service from your dashboard.'
);
}
public static function assertRenewalsAllowed(): void
{
if (! self::renewalsEnabled()) {
throw new RuntimeException(self::renewalsDisabledMessage());
}
}
/** @deprecated Use assertNewSaleAllowed() */
public static function assertNewOrdersAllowed(): void
{
self::assertNewSaleAllowed();
}
public static function renewalsDisabledMessage(): string
{
return 'ResellerClub renewals are not available at this time. Please contact support if you need assistance.';
}
public static function fulfillmentDisabledMessage(): string
{
return 'Automated ResellerClub fulfillment for new purchases is no longer available. '
.'Open a support ticket if you need help with a legacy order.';
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
use App\Models\User;
if (! function_exists('ladill_account')) {
/**
* The account the current request acts within the owner User. Defaults to
* the authenticated user; a team member who switched accounts gets the owner.
* Set by the SetActingAccount middleware.
*/
function ladill_account(): ?User
{
$request = request();
return $request->attributes->get('actingAccount') ?? $request->user();
}
}
if (! function_exists('ladill_domains_url')) {
function ladill_domains_url(string $path = ''): string
{
return 'https://'.config('app.domains_domain').($path !== '' ? '/'.ltrim($path, '/') : '');
}
}
if (! function_exists('ladill_account_url')) {
function ladill_account_url(string $path = ''): string
{
return 'https://'.config('app.account_domain').($path !== '' ? '/'.ltrim($path, '/') : '');
}
}
if (! function_exists('ladill_servers_url')) {
function ladill_servers_url(string $path = ''): string
{
return 'https://'.config('app.servers_domain').($path !== '' ? '/'.ltrim($path, '/') : '');
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace App\View\Components;
use Illuminate\View\Component;
use Illuminate\View\View;
class UserLayout extends Component
{
public function render(): View
{
return view('layouts.user');
}
}