manager->walletFor($account); $qrCodes = $account->qrCodes() ->whereIn('type', QrTypeCatalog::storefrontTypes()) ->latest() ->get(); $totalRegistrations = 0; $ladillWalletBalance = 0.0; try { $ladillWalletBalance = $this->platformBilling->balanceMinor($account->public_id) / 100; } catch (Throwable $e) { Log::warning('Events index could not load Ladill wallet balance', [ 'user' => $account->public_id, 'error' => $e->getMessage(), ]); } return view('qr-codes.index', [ 'wallet' => $wallet, 'qrCodes' => $qrCodes, 'totalRegistrations' => $totalRegistrations, 'pricePerQr' => QrWallet::pricePerQr(), 'minTopup' => QrWallet::minTopupGhs(), 'ladillWalletBalance' => $ladillWalletBalance, 'topupUrl' => 'https://'.config('app.account_domain').'/wallet', ]); } public function create(Request $request): View { $account = ladill_account(); $wallet = $this->manager->walletFor($account); $qrSettings = $account->getOrCreateQrSetting(); $requestedType = $request->query('type', QrCode::TYPE_SHOP); if (! QrTypeCatalog::isValid($requestedType)) { $requestedType = QrCode::TYPE_SHOP; } return view('qr-codes.create', [ 'wallet' => $wallet, 'requestedType' => $requestedType, 'moduleStyles' => QrModuleStyleCatalog::visible(), 'cornerOuterStyles' => QrCornerStyleCatalog::outerStyles(), 'cornerInnerStyles' => QrCornerStyleCatalog::innerStyles(), 'frameStyles' => QrFrameStyleCatalog::visible(), 'pricePerQr' => QrWallet::pricePerQr(), 'minTopup' => QrWallet::minTopupGhs(), 'ladillWalletBalance' => $this->platformBilling->balanceMinor($account->public_id) / 100, 'topupUrl' => 'https://'.config('app.account_domain').'/wallet', 'accountDefaultStyle' => $qrSettings->resolvedDefaultStyle(), 'accountEventDefaults' => $qrSettings->resolvedEventDefaults(), ]); } 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(ladill_account(), 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('merchant.storefronts.show', $qrCode) ->with('success', QrTypeCatalog::label($qrCode->type).' created.'); } public function show(Request $request, QrCode $storefront): View { $this->authorize('view', $storefront); $previewDataUri = $this->imageGenerator->previewDataUri($storefront); $qrCode = $storefront->fresh(); $qrStyle = $qrCode->style(); $logoDataUri = ! empty($qrStyle['logo_path']) ? $this->imageGenerator->logoDataUri($qrStyle['logo_path']) : null; $account = ladill_account(); $wallet = $this->manager->walletFor($account); $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); $ladillWalletBalance = 0.0; try { $ladillWalletBalance = $this->platformBilling->balanceMinor($account->public_id) / 100; } catch (Throwable $e) { Log::warning('QR Plus show could not load Ladill wallet balance', [ 'user' => $account->public_id, 'error' => $e->getMessage(), ]); } 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, 'orders' => null, 'pricePerQr' => QrWallet::pricePerQr(), 'minTopup' => QrWallet::minTopupGhs(), 'ladillWalletBalance' => $ladillWalletBalance, 'topupUrl' => 'https://'.config('app.account_domain').'/wallet', ]); } public function update(Request $request, QrCode $storefront): RedirectResponse { $this->authorize('update', $storefront); $qrCode = $storefront; $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', QrTypeCatalog::label($qrCode->type).' 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 = ladill_account()->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 */ 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 $storefront): Response { $this->authorize('view', $storefront); $qrCode = $this->imageGenerator->ensureValidImages($storefront); $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 $storefront, string $format): StreamedResponse { $this->authorize('view', $storefront); $qrCode = $this->imageGenerator->ensureValidImages($storefront); $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 $storefront): JsonResponse { $this->authorize('update', $storefront); $qrCode = $storefront; $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]); } }