From 46b5091b1e6786bd9ebd5e62e14ca48c824aab11 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Wed, 24 Jun 2026 00:08:16 +0000 Subject: [PATCH] Prune the upstream Mini/QR mobile subsystem from POS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POS was forked from Ladill Mini and carried Mini's entire mobile/QR subsystem (API, QR codes, wallet, payments, push, Afia) which polluted the ladill_pos schema with 8 unused tables and left ~half the test suite red. Remove the dead subsystem: Api/Mini/Qr/Public/Search/WellKnown controllers, Mini/Qr/Afia/Notifications services, the 8 unused models, QrCodePolicy, Support/Qr + Support/Events, the two mini: scheduled commands, the Mini/QR view trees, and their (failing) tests. Empty routes/api.php (POS is web-only) and strip dead schedules from routes/console.php. Keep QrTeamMember — it is the platform team-membership model that POS's SetActingAccount middleware and SSO login depend on for multi-account access. Also keep notifications + personal_access_tokens (used by POS). Drops 7 migrations (qr product/settings, mini_payments, push tokens); the 8 orphan tables are dropped from the live ladill_pos DB separately. Test suite is green (8 passed) and all routes resolve. Co-Authored-By: Claude Opus 4.8 --- app/Console/Commands/CancelStalePayments.php | 34 - .../Commands/ProcessAutoWithdrawals.php | 21 - app/Http/Controllers/Api/AuthController.php | 196 --- .../Api/Concerns/CallsIdentityApi.php | 47 - app/Http/Controllers/Api/MeController.php | 32 - .../Api/Mini/AccountController.php | 156 -- .../Controllers/Api/Mini/AfiaController.php | 82 - .../Api/Mini/NotificationController.php | 76 - .../Api/Mini/OverviewController.php | 59 - .../Controllers/Api/Mini/PaymentPresenter.php | 56 - .../Api/Mini/PaymentQrController.php | 131 -- .../Api/Mini/PaymentsController.php | 49 - .../Api/Mini/PayoutsController.php | 49 - .../Api/Mini/PushTokenController.php | 51 - .../Api/Mini/SupportController.php | 56 - .../Controllers/Api/Mini/WalletController.php | 161 -- app/Http/Controllers/Api/QrCodeController.php | 153 -- .../Controllers/Mini/OverviewController.php | 64 - .../Controllers/Mini/PaymentQrController.php | 180 -- .../Controllers/Mini/PaymentsController.php | 45 - .../Controllers/Mini/PayoutsController.php | 48 - .../Controllers/Public/PaymentController.php | 66 - .../Controllers/Public/QrScanController.php | 345 ---- app/Http/Controllers/Qr/AccountController.php | 94 -- app/Http/Controllers/Qr/AfiaController.php | 87 - .../Controllers/Qr/DeveloperController.php | 45 - app/Http/Controllers/Qr/QrCodeController.php | 454 ----- app/Http/Controllers/Qr/TeamController.php | 108 -- app/Http/Controllers/SearchController.php | 71 - .../WellKnown/AssetLinksController.php | 27 - app/Models/MiniPayment.php | 62 - app/Models/QrCode.php | 212 --- app/Models/QrDocument.php | 35 - app/Models/QrScanEvent.php | 32 - app/Models/QrSetting.php | 174 -- app/Models/QrTransaction.php | 46 - app/Models/QrWallet.php | 66 - app/Models/User.php | 34 - app/Models/UserPushToken.php | 26 - .../EventProgrammeSharedNotification.php | 38 - .../Mini/MiniAlertNotification.php | 42 - app/Policies/QrCodePolicy.php | 24 - app/Providers/AppServiceProvider.php | 7 +- app/Services/Afia/AfiaService.php | 117 -- app/Services/Mini/AutoWithdrawService.php | 148 -- app/Services/Mini/MiniPaymentService.php | 198 --- app/Services/Notifications/FcmService.php | 151 -- .../Notifications/MiniNotificationService.php | 177 -- app/Services/Qr/QrAnalyticsService.php | 91 - app/Services/Qr/QrCodeManagerService.php | 556 ------ app/Services/Qr/QrImageGeneratorService.php | 488 ------ app/Services/Qr/QrPayloadValidator.php | 663 -------- app/Services/Qr/QrPdfExporter.php | 93 - app/Services/Qr/QrScanRecorder.php | 89 - app/Services/Qr/QrWalletBillingService.php | 74 - app/Support/Events/EventBadgeZpl.php | 70 - app/Support/Qr/EventBadgeZpl.php | 70 - app/Support/Qr/QrCornerStyleCatalog.php | 36 - app/Support/Qr/QrCoverImageSpec.php | 20 - app/Support/Qr/QrDateFormatter.php | 55 - app/Support/Qr/QrFrameStyleCatalog.php | 63 - app/Support/Qr/QrModuleStyleCatalog.php | 93 - app/Support/Qr/QrScanReliability.php | 102 -- app/Support/Qr/QrStyleDefaults.php | 114 -- app/Support/Qr/QrStyleNormalizer.php | 138 -- app/Support/Qr/QrTypeCatalog.php | 58 - app/Support/Qr/QrWifiPayload.php | 45 - ..._05_28_200000_create_qr_product_tables.php | 101 -- ...05_31_000001_widen_qr_codes_short_code.php | 22 - ..._06_06_220000_create_qr_settings_table.php | 27 - ...6_07_200000_create_mini_payments_table.php | 39 - ...dd_pay_order_id_to_mini_payments_table.php | 22 - ...1_120000_create_user_push_tokens_table.php | 36 - ...add_auto_withdraw_to_qr_settings_table.php | 22 - .../components/qr/cover-image-hint.blade.php | 5 - .../qr/customization-section.blade.php | 30 - resources/views/mini/dashboard.blade.php | 68 - .../views/mini/payment-qrs/create.blade.php | 48 - .../views/mini/payment-qrs/index.blade.php | 40 - .../partials/delete-modal.blade.php | 38 - .../partials/header-actions.blade.php | 37 - .../partials/preview-card.blade.php | 35 - .../views/mini/payment-qrs/show.blade.php | 81 - resources/views/mini/payments.blade.php | 54 - resources/views/mini/payouts.blade.php | 27 - resources/views/mini/settings.blade.php | 69 - resources/views/mini/signed-out.blade.php | 1 - .../views/public/qr/document-viewer.blade.php | 178 -- .../views/public/qr/event-confirmed.blade.php | 73 - resources/views/public/qr/inactive.blade.php | 18 - resources/views/public/qr/landing.blade.php | 1491 ----------------- .../public/qr/payment-confirmed.blade.php | 26 - .../views/public/qr/payment-landing.blade.php | 114 -- resources/views/qr-codes/attendees.blade.php | 176 -- resources/views/qr-codes/badges.blade.php | 85 - resources/views/qr-codes/create.blade.php | 303 ---- resources/views/qr-codes/index.blade.php | 86 - .../partials/customization-fields.blade.php | 530 ------ .../partials/qr-customizer-script.blade.php | 669 -------- .../partials/qr-preview-card.blade.php | 188 --- .../partials/type-fields-create.blade.php | 1049 ------------ .../partials/type-fields-edit.blade.php | 1157 ------------- resources/views/qr-codes/show.blade.php | 343 ---- resources/views/qr/account/billing.blade.php | 13 - .../views/qr/account/developers.blade.php | 87 - resources/views/qr/account/settings.blade.php | 260 --- resources/views/qr/account/team.blade.php | 92 - resources/views/qr/account/wallet.blade.php | 23 - resources/views/qr/dashboard.blade.php | 45 - resources/views/qr/signed-out.blade.php | 15 - resources/views/search/index.blade.php | 13 - routes/api.php | 76 +- routes/console.php | 12 - tests/Feature/AfiaTest.php | 54 - tests/Feature/AssetLinksTest.php | 41 - tests/Feature/ImportEventsCommandTest.php | 82 - tests/Feature/QrSettingsTest.php | 108 -- tests/Feature/SearchTest.php | 64 - .../Unit/Services/Qr/PaymentQrUpdateTest.php | 57 - 119 files changed, 3 insertions(+), 15877 deletions(-) delete mode 100644 app/Console/Commands/CancelStalePayments.php delete mode 100644 app/Console/Commands/ProcessAutoWithdrawals.php delete mode 100644 app/Http/Controllers/Api/AuthController.php delete mode 100644 app/Http/Controllers/Api/Concerns/CallsIdentityApi.php delete mode 100644 app/Http/Controllers/Api/MeController.php delete mode 100644 app/Http/Controllers/Api/Mini/AccountController.php delete mode 100644 app/Http/Controllers/Api/Mini/AfiaController.php delete mode 100644 app/Http/Controllers/Api/Mini/NotificationController.php delete mode 100644 app/Http/Controllers/Api/Mini/OverviewController.php delete mode 100644 app/Http/Controllers/Api/Mini/PaymentPresenter.php delete mode 100644 app/Http/Controllers/Api/Mini/PaymentQrController.php delete mode 100644 app/Http/Controllers/Api/Mini/PaymentsController.php delete mode 100644 app/Http/Controllers/Api/Mini/PayoutsController.php delete mode 100644 app/Http/Controllers/Api/Mini/PushTokenController.php delete mode 100644 app/Http/Controllers/Api/Mini/SupportController.php delete mode 100644 app/Http/Controllers/Api/Mini/WalletController.php delete mode 100644 app/Http/Controllers/Api/QrCodeController.php delete mode 100644 app/Http/Controllers/Mini/OverviewController.php delete mode 100644 app/Http/Controllers/Mini/PaymentQrController.php delete mode 100644 app/Http/Controllers/Mini/PaymentsController.php delete mode 100644 app/Http/Controllers/Mini/PayoutsController.php delete mode 100644 app/Http/Controllers/Public/PaymentController.php delete mode 100644 app/Http/Controllers/Public/QrScanController.php delete mode 100644 app/Http/Controllers/Qr/AccountController.php delete mode 100644 app/Http/Controllers/Qr/AfiaController.php delete mode 100644 app/Http/Controllers/Qr/DeveloperController.php delete mode 100644 app/Http/Controllers/Qr/QrCodeController.php delete mode 100644 app/Http/Controllers/Qr/TeamController.php delete mode 100644 app/Http/Controllers/SearchController.php delete mode 100644 app/Http/Controllers/WellKnown/AssetLinksController.php delete mode 100644 app/Models/MiniPayment.php delete mode 100644 app/Models/QrCode.php delete mode 100644 app/Models/QrDocument.php delete mode 100644 app/Models/QrScanEvent.php delete mode 100644 app/Models/QrSetting.php delete mode 100644 app/Models/QrTransaction.php delete mode 100644 app/Models/QrWallet.php delete mode 100644 app/Models/UserPushToken.php delete mode 100644 app/Notifications/EventProgrammeSharedNotification.php delete mode 100644 app/Notifications/Mini/MiniAlertNotification.php delete mode 100644 app/Policies/QrCodePolicy.php delete mode 100644 app/Services/Afia/AfiaService.php delete mode 100644 app/Services/Mini/AutoWithdrawService.php delete mode 100644 app/Services/Mini/MiniPaymentService.php delete mode 100644 app/Services/Notifications/FcmService.php delete mode 100644 app/Services/Notifications/MiniNotificationService.php delete mode 100644 app/Services/Qr/QrAnalyticsService.php delete mode 100644 app/Services/Qr/QrCodeManagerService.php delete mode 100644 app/Services/Qr/QrImageGeneratorService.php delete mode 100644 app/Services/Qr/QrPayloadValidator.php delete mode 100644 app/Services/Qr/QrPdfExporter.php delete mode 100644 app/Services/Qr/QrScanRecorder.php delete mode 100644 app/Services/Qr/QrWalletBillingService.php delete mode 100644 app/Support/Events/EventBadgeZpl.php delete mode 100644 app/Support/Qr/EventBadgeZpl.php delete mode 100644 app/Support/Qr/QrCornerStyleCatalog.php delete mode 100644 app/Support/Qr/QrCoverImageSpec.php delete mode 100644 app/Support/Qr/QrDateFormatter.php delete mode 100644 app/Support/Qr/QrFrameStyleCatalog.php delete mode 100644 app/Support/Qr/QrModuleStyleCatalog.php delete mode 100644 app/Support/Qr/QrScanReliability.php delete mode 100644 app/Support/Qr/QrStyleDefaults.php delete mode 100644 app/Support/Qr/QrStyleNormalizer.php delete mode 100644 app/Support/Qr/QrTypeCatalog.php delete mode 100644 app/Support/Qr/QrWifiPayload.php delete mode 100644 database/migrations/2026_05_28_200000_create_qr_product_tables.php delete mode 100644 database/migrations/2026_05_31_000001_widen_qr_codes_short_code.php delete mode 100644 database/migrations/2026_06_06_220000_create_qr_settings_table.php delete mode 100644 database/migrations/2026_06_07_200000_create_mini_payments_table.php delete mode 100644 database/migrations/2026_06_08_130000_add_pay_order_id_to_mini_payments_table.php delete mode 100644 database/migrations/2026_06_11_120000_create_user_push_tokens_table.php delete mode 100644 database/migrations/2026_06_12_120000_add_auto_withdraw_to_qr_settings_table.php delete mode 100644 resources/views/components/qr/cover-image-hint.blade.php delete mode 100644 resources/views/components/qr/customization-section.blade.php delete mode 100644 resources/views/mini/dashboard.blade.php delete mode 100644 resources/views/mini/payment-qrs/create.blade.php delete mode 100644 resources/views/mini/payment-qrs/index.blade.php delete mode 100644 resources/views/mini/payment-qrs/partials/delete-modal.blade.php delete mode 100644 resources/views/mini/payment-qrs/partials/header-actions.blade.php delete mode 100644 resources/views/mini/payment-qrs/partials/preview-card.blade.php delete mode 100644 resources/views/mini/payment-qrs/show.blade.php delete mode 100644 resources/views/mini/payments.blade.php delete mode 100644 resources/views/mini/payouts.blade.php delete mode 100644 resources/views/mini/settings.blade.php delete mode 100644 resources/views/mini/signed-out.blade.php delete mode 100644 resources/views/public/qr/document-viewer.blade.php delete mode 100644 resources/views/public/qr/event-confirmed.blade.php delete mode 100644 resources/views/public/qr/inactive.blade.php delete mode 100644 resources/views/public/qr/landing.blade.php delete mode 100644 resources/views/public/qr/payment-confirmed.blade.php delete mode 100644 resources/views/public/qr/payment-landing.blade.php delete mode 100644 resources/views/qr-codes/attendees.blade.php delete mode 100644 resources/views/qr-codes/badges.blade.php delete mode 100644 resources/views/qr-codes/create.blade.php delete mode 100644 resources/views/qr-codes/index.blade.php delete mode 100644 resources/views/qr-codes/partials/customization-fields.blade.php delete mode 100644 resources/views/qr-codes/partials/qr-customizer-script.blade.php delete mode 100644 resources/views/qr-codes/partials/qr-preview-card.blade.php delete mode 100644 resources/views/qr-codes/partials/type-fields-create.blade.php delete mode 100644 resources/views/qr-codes/partials/type-fields-edit.blade.php delete mode 100644 resources/views/qr-codes/show.blade.php delete mode 100644 resources/views/qr/account/billing.blade.php delete mode 100644 resources/views/qr/account/developers.blade.php delete mode 100644 resources/views/qr/account/settings.blade.php delete mode 100644 resources/views/qr/account/team.blade.php delete mode 100644 resources/views/qr/account/wallet.blade.php delete mode 100644 resources/views/qr/dashboard.blade.php delete mode 100644 resources/views/qr/signed-out.blade.php delete mode 100644 resources/views/search/index.blade.php delete mode 100644 tests/Feature/AfiaTest.php delete mode 100644 tests/Feature/AssetLinksTest.php delete mode 100644 tests/Feature/ImportEventsCommandTest.php delete mode 100644 tests/Feature/QrSettingsTest.php delete mode 100644 tests/Feature/SearchTest.php delete mode 100644 tests/Unit/Services/Qr/PaymentQrUpdateTest.php diff --git a/app/Console/Commands/CancelStalePayments.php b/app/Console/Commands/CancelStalePayments.php deleted file mode 100644 index 00dfb00..0000000 --- a/app/Console/Commands/CancelStalePayments.php +++ /dev/null @@ -1,34 +0,0 @@ -subHours(MiniPayment::STALE_PENDING_HOURS); - - $count = MiniPayment::query() - ->where('status', MiniPayment::STATUS_PENDING) - ->where('created_at', '<', $cutoff) - ->update([ - 'status' => MiniPayment::STATUS_CANCELED, - 'updated_at' => now(), - ]); - - $this->info("Canceled {$count} stale pending payment(s)."); - - return self::SUCCESS; - } -} diff --git a/app/Console/Commands/ProcessAutoWithdrawals.php b/app/Console/Commands/ProcessAutoWithdrawals.php deleted file mode 100644 index c686913..0000000 --- a/app/Console/Commands/ProcessAutoWithdrawals.php +++ /dev/null @@ -1,21 +0,0 @@ -processAll(); - $this->info("Processed {$count} auto-withdrawal(s)."); - - return self::SUCCESS; - } -} diff --git a/app/Http/Controllers/Api/AuthController.php b/app/Http/Controllers/Api/AuthController.php deleted file mode 100644 index 74cc874..0000000 --- a/app/Http/Controllers/Api/AuthController.php +++ /dev/null @@ -1,196 +0,0 @@ -validate([ - 'email' => ['required', 'email'], - 'password' => ['required', 'string'], - 'device_name' => ['nullable', 'string', 'max:120'], - ]); - - $response = $this->identityPost('/api/identity/auth/login', [ - 'email' => $credentials['email'], - 'password' => $credentials['password'], - ]); - - if ($response->status() === 422) { - throw ValidationException::withMessages([ - 'email' => ['The email or password is incorrect.'], - ]); - } - - $user = $this->provisionFromResponse($response); - - return $this->issueToken($user, $credentials['device_name'] ?? null); - } - - public function register(Request $request): JsonResponse - { - // Mirror the web signup form; the monolith validates authoritatively, but - // we pre-validate so the app gets fast, field-level feedback. - $data = $request->validate([ - 'name' => ['required', 'string', 'max:255'], - 'email' => ['required', 'email', 'max:255'], - 'password' => ['required', 'string', 'min:8', 'confirmed'], - 'company' => ['nullable', 'string', 'max:255'], - 'address' => ['required', 'string', 'max:255'], - 'city' => ['required', 'string', 'max:255'], - 'state' => ['required', 'string', 'max:255'], - 'country' => ['required', 'string', 'size:2'], - 'zipcode' => ['required', 'string', 'max:32'], - 'phone_cc' => ['required', 'string', 'max:8'], - 'phone' => ['required', 'string', 'max:32'], - 'mobile_cc' => ['nullable', 'string', 'max:8'], - 'mobile' => ['nullable', 'string', 'max:32'], - 'terms' => ['accepted'], - 'device_name' => ['nullable', 'string', 'max:120'], - ]); - - $response = $this->identityPost('/api/identity/auth/register', array_merge( - $request->only([ - 'name', 'email', 'password', 'password_confirmation', 'company', - 'address', 'city', 'state', 'country', 'zipcode', - 'phone_cc', 'phone', 'mobile_cc', 'mobile', - ]), - ['terms' => $request->boolean('terms')], - )); - - // Surface the monolith's validation errors (e.g. email already taken). - if ($response->status() === 422) { - throw ValidationException::withMessages( - $response->json('errors') ?: ['email' => [$response->json('message') ?: 'Registration failed.']], - ); - } - - $user = $this->provisionFromResponse($response); - - return $this->issueToken($user, $data['device_name'] ?? null); - } - - public function logout(Request $request): JsonResponse - { - $request->user()?->currentAccessToken()?->delete(); - - return response()->json(['data' => ['message' => 'Signed out.']]); - } - - public function me(Request $request): JsonResponse - { - return response()->json(['data' => $this->presentUser($request->user())]); - } - - private function identity(): \Illuminate\Http\Client\PendingRequest - { - return Http::baseUrl(rtrim((string) config('services.ladill_identity.url'), '/')) - ->withToken((string) config('services.ladill_identity.key')) - ->connectTimeout(10) - ->timeout(20) - ->acceptJson() - ->asJson(); - } - - /** POST to the identity API, turning connection failures into a clean message. */ - private function identityPost(string $path, array $payload): HttpResponse - { - try { - return $this->identity()->post($path, $payload); - } catch (ConnectionException $e) { - throw ValidationException::withMessages([ - 'email' => ['Could not reach Ladill sign-in. Please try again in a moment.'], - ]); - } - } - - /** Upsert the local mirror from the identity API's OIDC claims. */ - private function provisionFromResponse(HttpResponse $response): User - { - if ($response->failed()) { - throw ValidationException::withMessages([ - 'email' => ['We could not reach Ladill sign-in. Please try again.'], - ]); - } - - $claims = (array) $response->json('data.user', []); - $sub = (string) ($claims['sub'] ?? ''); - - if ($sub === '') { - throw ValidationException::withMessages([ - 'email' => ['We could not verify your Ladill account. Please try again.'], - ]); - } - - $email = (string) ($claims['email'] ?? ''); - - return User::updateOrCreate( - ['public_id' => $sub], - [ - 'name' => $claims['name'] ?? null, - 'email' => $email !== '' ? $email : $sub.'@users.ladill.com', - 'avatar_url' => $claims['picture'] ?? null, - ], - ); - } - - private function issueToken(User $user, ?string $deviceName): JsonResponse - { - QrTeamMember::linkPendingInvitesFor($user); - $user->update(['last_app_active_at' => now()]); - - $token = $user->createToken($deviceName ?: 'Ladill Mini Android', ['mini:read', 'mini:write']); - - return response()->json([ - 'data' => [ - 'token' => $token->plainTextToken, - 'user' => $this->presentUser($user), - ], - ]); - } - - /** @return array */ - private function presentUser(User $user): array - { - // On the public login/register routes SetActingAccount hasn't run and - // there's no authenticated guard yet, so ladill_account() is null — - // the acting account at sign-in is simply the user themselves. - $account = ladill_account() ?? $user; - - return [ - 'id' => $user->id, - 'public_id' => $user->public_id, - 'name' => $user->name, - 'email' => $user->email, - 'avatar_url' => $user->avatarUrl(), - 'acting_account' => [ - 'id' => $account->id, - 'public_id' => $account->public_id, - 'name' => $account->name, - 'email' => $account->email, - ], - 'accessible_account_ids' => $user->accessibleAccounts()->pluck('id')->values(), - ]; - } -} diff --git a/app/Http/Controllers/Api/Concerns/CallsIdentityApi.php b/app/Http/Controllers/Api/Concerns/CallsIdentityApi.php deleted file mode 100644 index d7689fa..0000000 --- a/app/Http/Controllers/Api/Concerns/CallsIdentityApi.php +++ /dev/null @@ -1,47 +0,0 @@ -withToken((string) config('services.ladill_identity.key')) - ->connectTimeout(10) - ->timeout(20) - ->acceptJson() - ->asJson(); - } - - protected function identitySend(string $method, string $path, array $payload): HttpResponse - { - try { - return $this->identity()->send($method, $path, ['json' => $payload]); - } catch (ConnectionException) { - throw ValidationException::withMessages([ - 'base' => ['Could not reach Ladill. Please try again in a moment.'], - ]); - } - } - - /** Re-throw a 422 from the identity API as local validation errors. */ - protected function rethrowValidation(HttpResponse $response, string $fallbackField = 'base'): void - { - if ($response->status() === 422) { - throw ValidationException::withMessages( - $response->json('errors') ?: [$fallbackField => [$response->json('message') ?: 'Request failed.']], - ); - } - } -} diff --git a/app/Http/Controllers/Api/MeController.php b/app/Http/Controllers/Api/MeController.php deleted file mode 100644 index 7791803..0000000 --- a/app/Http/Controllers/Api/MeController.php +++ /dev/null @@ -1,32 +0,0 @@ -user(); - $account = ladill_account(); - - return response()->json([ - 'data' => [ - 'id' => $user->id, - 'public_id' => $user->public_id, - 'name' => $user->name, - 'email' => $user->email, - 'acting_account' => [ - 'id' => $account->id, - 'public_id' => $account->public_id, - 'name' => $account->name, - 'email' => $account->email, - ], - 'accessible_account_ids' => $user->accessibleAccounts()->pluck('id')->values(), - ], - ]); - } -} diff --git a/app/Http/Controllers/Api/Mini/AccountController.php b/app/Http/Controllers/Api/Mini/AccountController.php deleted file mode 100644 index f690f7e..0000000 --- a/app/Http/Controllers/Api/Mini/AccountController.php +++ /dev/null @@ -1,156 +0,0 @@ -getOrCreateQrSetting(); - - // Phone lives on the central account, not the local mirror — fetch it, - // but never let a transient identity-API hiccup break settings loading. - $phone = ''; - $phoneCc = ''; - try { - $profile = $this->identitySend('GET', '/api/identity/profile?user='.urlencode((string) $account->public_id), []); - if ($profile->successful()) { - $phone = (string) $profile->json('data.phone', ''); - $phoneCc = (string) $profile->json('data.phone_cc', ''); - } - } catch (\Throwable) { - // leave phone blank - } - - return response()->json([ - 'data' => [ - 'profile' => [ - 'name' => $account->name, - 'email' => $account->email, - 'phone' => $phone, - 'phone_cc' => $phoneCc, - ], - 'notifications' => [ - 'notify_email' => $settings->notify_email ?: $account->email, - 'product_updates' => (bool) ($settings->product_updates ?? true), - 'notify_registrations' => (bool) ($settings->notify_registrations ?? true), - 'notify_payouts' => (bool) ($settings->notify_payouts ?? true), - ], - ], - ]); - } - - public function updateSettings(Request $request): JsonResponse - { - $account = ladill_account(); - - $data = $request->validate([ - 'notify_email' => ['nullable', 'email', 'max:255'], - 'product_updates' => ['sometimes', 'boolean'], - 'notify_registrations' => ['sometimes', 'boolean'], - 'notify_payouts' => ['sometimes', 'boolean'], - ]); - - $settings = QrSetting::updateOrCreate( - ['user_id' => $account->id], - [ - 'notify_email' => $data['notify_email'] ?? $account->email, - 'product_updates' => $request->boolean('product_updates'), - 'notify_registrations' => $request->boolean('notify_registrations'), - 'notify_payouts' => $request->boolean('notify_payouts'), - ], - ); - - return response()->json([ - 'data' => [ - 'notify_email' => $settings->notify_email, - 'product_updates' => (bool) $settings->product_updates, - 'notify_registrations' => (bool) $settings->notify_registrations, - 'notify_payouts' => (bool) $settings->notify_payouts, - ], - ]); - } - - public function updateProfile(Request $request): JsonResponse - { - $account = ladill_account(); - - $data = $request->validate([ - 'name' => ['required', 'string', 'max:255'], - 'phone_cc' => ['nullable', 'string', 'max:8'], - 'phone' => ['nullable', 'string', 'max:32'], - ]); - - $response = $this->identitySend('PUT', '/api/identity/profile', array_merge( - ['user' => $account->public_id], - $data, - )); - $this->rethrowValidation($response, 'name'); - - if ($response->successful()) { - $account->update(['name' => $data['name']]); - } - - return response()->json([ - 'data' => ['name' => $account->fresh()->name, 'email' => $account->email], - ]); - } - - public function uploadAvatar(Request $request): JsonResponse - { - $account = ladill_account(); - - $request->validate([ - 'avatar' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:4096'], - ]); - - $file = $request->file('avatar'); - - $response = Http::baseUrl(rtrim((string) config('services.ladill_identity.url'), '/')) - ->withToken((string) config('services.ladill_identity.key')) - ->acceptJson() - ->attach('avatar', (string) file_get_contents($file->getRealPath()), $file->getClientOriginalName()) - ->post('/api/identity/avatar', ['user' => $account->public_id]); - - $this->rethrowValidation($response, 'avatar'); - - if ($response->successful()) { - $picture = (string) $response->json('data.user.picture', ''); - if ($picture !== '') { - $account->update(['avatar_url' => $picture]); - } - } - - return response()->json(['data' => ['avatar_url' => $account->fresh()->avatarUrl()]]); - } - - public function changePassword(Request $request): JsonResponse - { - $account = ladill_account(); - - $request->validate([ - 'current_password' => ['required', 'string'], - 'password' => ['required', 'string', 'min:8', 'confirmed'], - ]); - - $response = $this->identitySend('POST', '/api/identity/auth/change-password', [ - 'user' => $account->public_id, - 'current_password' => $request->string('current_password'), - 'password' => $request->string('password'), - 'password_confirmation' => $request->string('password_confirmation'), - ]); - $this->rethrowValidation($response, 'current_password'); - - return response()->json(['data' => ['message' => 'Password updated.']]); - } -} diff --git a/app/Http/Controllers/Api/Mini/AfiaController.php b/app/Http/Controllers/Api/Mini/AfiaController.php deleted file mode 100644 index 51ad2f1..0000000 --- a/app/Http/Controllers/Api/Mini/AfiaController.php +++ /dev/null @@ -1,82 +0,0 @@ -validate([ - 'message' => ['required', 'string', 'max:2000'], - 'history' => ['nullable', 'array', 'max:20'], - 'history.*.role' => ['nullable', 'string'], - 'history.*.text' => ['nullable', 'string'], - ]); - - if (! $afia->enabled()) { - return response()->json(['message' => 'Afia is not available right now.'], 503); - } - - try { - $reply = $afia->chat(trim($validated['message']), $validated['history'] ?? [], $this->context()); - } catch (\Throwable $e) { - report($e); - - return response()->json(['message' => 'Afia could not respond right now. Please try again.'], 502); - } - - return response()->json(['reply' => $reply]); - } - - /** @return array */ - private function context(): array - { - $account = ladill_account(); - $types = QrTypeCatalog::paymentTypes(); - $codes = $account->qrCodes()->whereIn('type', $types); - - $ctx = [ - 'signed_in' => 'yes', - 'qr_codes_total' => (clone $codes)->count(), - 'qr_codes_active' => (clone $codes)->where('is_active', true)->count(), - 'scans_total' => (int) (clone $codes)->sum('scans_total'), - 'top_code_type' => (clone $codes) - ->selectRaw('type, count(*) as total') - ->groupBy('type') - ->orderByDesc('total') - ->value('type') ?: 'none yet', - ]; - - if ($account->public_id) { - try { - $ctx['wallet_balance_ghs'] = number_format(app(BillingClient::class)->balanceMinor((string) $account->public_id) / 100, 2); - } catch (\Throwable) { - } - } - - $recent = $account->qrCodes() - ->whereIn('type', $types) - ->latest('updated_at') - ->limit(3) - ->get(['type', 'label', 'short_code', 'scans_total']); - - if ($recent->isNotEmpty()) { - $ctx['recent_codes'] = $recent->map(fn (QrCode $code): string => sprintf( - '%s (%s, %d scans)', - $code->label ?: $code->short_code, - QrTypeCatalog::label($code->type), - $code->scans_total, - ))->implode('; '); - } - - return $ctx; - } -} diff --git a/app/Http/Controllers/Api/Mini/NotificationController.php b/app/Http/Controllers/Api/Mini/NotificationController.php deleted file mode 100644 index d8c29db..0000000 --- a/app/Http/Controllers/Api/Mini/NotificationController.php +++ /dev/null @@ -1,76 +0,0 @@ -notifications() - ->latest() - ->limit(100) - ->get() - ->map(fn ($n) => $this->present($n)); - - $unread = $account->unreadNotifications()->count(); - - return response()->json([ - 'data' => $notifications, - 'unread_count' => $unread, - ]); - } - - public function unreadCount(Request $request): JsonResponse - { - $account = ladill_account(); - - return response()->json([ - 'data' => [ - 'unread_count' => $account->unreadNotifications()->count(), - ], - ]); - } - - public function markAsRead(Request $request, string $id): JsonResponse - { - $notification = ladill_account() - ->notifications() - ->where('id', $id) - ->first(); - - if ($notification) { - $notification->markAsRead(); - } - - return response()->json(['data' => ['success' => true]]); - } - - public function markAllAsRead(Request $request): JsonResponse - { - ladill_account()->unreadNotifications->markAsRead(); - - return response()->json(['data' => ['success' => true]]); - } - - /** @return array */ - private function present(mixed $notification): array - { - return [ - 'id' => $notification->id, - 'type' => class_basename($notification->type), - 'title' => $notification->data['title'] ?? 'Notification', - 'message' => $notification->data['message'] ?? '', - 'icon' => $notification->data['icon'] ?? 'bell', - 'milestone' => $notification->data['milestone'] ?? null, - 'url' => $notification->data['url'] ?? null, - 'read_at' => $notification->read_at?->toIso8601String(), - 'created_at' => $notification->created_at?->toIso8601String(), - ]; - } -} diff --git a/app/Http/Controllers/Api/Mini/OverviewController.php b/app/Http/Controllers/Api/Mini/OverviewController.php deleted file mode 100644 index af105c9..0000000 --- a/app/Http/Controllers/Api/Mini/OverviewController.php +++ /dev/null @@ -1,59 +0,0 @@ -qrCodes() - ->where('type', QrCode::TYPE_PAYMENT) - ->pluck('id'); - - $todayPayments = MiniPayment::query() - ->whereIn('qr_code_id', $qrIds) - ->where('status', MiniPayment::STATUS_PAID) - ->where('paid_at', '>=', now()->startOfDay()); - - $recentPayments = MiniPayment::query() - ->whereIn('qr_code_id', $qrIds) - ->where('status', MiniPayment::STATUS_PAID) - ->with('qrCode') - ->latest('paid_at') - ->limit(8) - ->get(); - - $balanceMinor = 0; - try { - $balanceMinor = $this->billing->balanceMinor($account->public_id); - } catch (Throwable $e) { - Log::warning('Mini API overview could not load wallet balance', [ - 'user' => $account->public_id, - 'error' => $e->getMessage(), - ]); - } - - return response()->json([ - 'data' => [ - 'currency' => 'GHS', - 'today_takings_minor' => (int) (clone $todayPayments)->sum('merchant_amount_minor'), - 'today_count' => (clone $todayPayments)->count(), - 'payment_qr_count' => $qrIds->count(), - 'wallet_balance_minor' => $balanceMinor, - 'recent_payments' => $recentPayments->map(fn (MiniPayment $p) => PaymentPresenter::present($p))->values(), - ], - ]); - } -} diff --git a/app/Http/Controllers/Api/Mini/PaymentPresenter.php b/app/Http/Controllers/Api/Mini/PaymentPresenter.php deleted file mode 100644 index b05c1d0..0000000 --- a/app/Http/Controllers/Api/Mini/PaymentPresenter.php +++ /dev/null @@ -1,56 +0,0 @@ - */ - public static function present(MiniPayment $payment): array - { - return [ - 'id' => $payment->id, - 'reference' => $payment->reference, - 'amount_minor' => $payment->amount_minor, - 'merchant_amount_minor' => $payment->merchant_amount_minor, - 'platform_fee_minor' => $payment->platform_fee_minor, - 'currency' => $payment->currency, - 'status' => $payment->status, - 'payer_name' => $payment->payer_name, - 'payer_email' => $payment->payer_email, - 'payer_phone' => $payment->payer_phone, - 'payer_note' => $payment->payer_note, - 'qr_code_id' => $payment->qr_code_id, - 'qr_label' => $payment->qrCode?->label, - 'paid_at' => $payment->paid_at?->toIso8601String(), - 'created_at' => $payment->created_at?->toIso8601String(), - ]; - } - - /** @return array */ - public static function presentQr(QrCode $qr, ?string $previewUrl = null): array - { - $content = $qr->content(); - - return [ - 'id' => $qr->id, - 'label' => $qr->label, - 'business_name' => $content['business_name'] ?? null, - 'branch_label' => $content['branch_label'] ?? null, - 'currency' => $content['currency'] ?? 'GHS', - 'short_code' => $qr->short_code, - 'public_url' => $qr->publicUrl(), - 'is_active' => $qr->is_active, - 'scans_total' => $qr->scans_total, - 'preview_url' => $previewUrl, - 'created_at' => $qr->created_at?->toIso8601String(), - 'updated_at' => $qr->updated_at?->toIso8601String(), - ]; - } -} diff --git a/app/Http/Controllers/Api/Mini/PaymentQrController.php b/app/Http/Controllers/Api/Mini/PaymentQrController.php deleted file mode 100644 index b310fb0..0000000 --- a/app/Http/Controllers/Api/Mini/PaymentQrController.php +++ /dev/null @@ -1,131 +0,0 @@ -qrCodes() - ->where('type', QrCode::TYPE_PAYMENT) - ->latest() - ->get(); - - return response()->json([ - 'data' => $qrCodes->map(fn (QrCode $qr) => PaymentPresenter::presentQr($qr, $this->previewUrl($qr)))->values(), - ]); - } - - public function store(Request $request): JsonResponse - { - abort_unless($request->user()->tokenCan('mini:write'), 403, 'Token requires mini:write ability.'); - - $data = $request->validate([ - 'label' => ['required', 'string', 'max:120'], - 'business_name' => ['required', 'string', 'max:120'], - 'branch_label' => ['nullable', 'string', 'max:80'], - ]); - - $data['type'] = QrCode::TYPE_PAYMENT; - $data['currency'] = 'GHS'; - - try { - $qrCode = $this->manager->create(ladill_account(), $data); - } catch (RuntimeException $e) { - return response()->json(['message' => $e->getMessage()], 422); - } - - return response()->json([ - 'data' => PaymentPresenter::presentQr($qrCode->fresh(), $this->previewUrl($qrCode)), - ], 201); - } - - public function show(QrCode $paymentQr): JsonResponse - { - $this->authorizePaymentQr($paymentQr); - - return response()->json([ - 'data' => PaymentPresenter::presentQr($paymentQr, $this->previewUrl($paymentQr)), - ]); - } - - public function update(Request $request, QrCode $paymentQr): JsonResponse - { - abort_unless($request->user()->tokenCan('mini:write'), 403, 'Token requires mini:write ability.'); - $this->authorizePaymentQr($paymentQr); - - $data = $request->validate([ - 'label' => ['sometimes', 'string', 'max:120'], - 'business_name' => ['sometimes', 'string', 'max:120'], - 'branch_label' => ['nullable', 'string', 'max:80'], - 'is_active' => ['sometimes', 'boolean'], - ]); - - if ($request->has('is_active')) { - $data['is_active'] = $request->boolean('is_active'); - } - - try { - $this->manager->update($paymentQr, $data); - } catch (RuntimeException $e) { - return response()->json(['message' => $e->getMessage()], 422); - } - - return response()->json([ - 'data' => PaymentPresenter::presentQr($paymentQr->fresh(), $this->previewUrl($paymentQr)), - ]); - } - - public function destroy(QrCode $paymentQr): JsonResponse - { - abort_unless(request()->user()->tokenCan('mini:write'), 403, 'Token requires mini:write ability.'); - $this->authorizePaymentQr($paymentQr); - - $this->manager->delete($paymentQr); - - return response()->json(['data' => ['message' => 'Payment QR deleted.']]); - } - - public function preview(QrCode $paymentQr): Response - { - $this->authorizePaymentQr($paymentQr); - - $qrCode = $this->imageGenerator->ensureValidImages($paymentQr); - $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', - ]); - } - - private function previewUrl(QrCode $qr): string - { - return route('api.mini.payment-qrs.preview', $qr); - } - - private function authorizePaymentQr(QrCode $paymentQr): void - { - abort_unless($paymentQr->type === QrCode::TYPE_PAYMENT, 404); - abort_unless($paymentQr->user_id === ladill_account()->id, 403); - } -} diff --git a/app/Http/Controllers/Api/Mini/PaymentsController.php b/app/Http/Controllers/Api/Mini/PaymentsController.php deleted file mode 100644 index 1bec5a4..0000000 --- a/app/Http/Controllers/Api/Mini/PaymentsController.php +++ /dev/null @@ -1,49 +0,0 @@ -query('q', '')); - - $qrIds = ladill_account()->qrCodes() - ->where('type', QrCode::TYPE_PAYMENT) - ->pluck('id'); - - $payments = MiniPayment::query() - ->whereIn('qr_code_id', $qrIds) - ->when($search !== '', function ($query) use ($search) { - $like = '%'.$search.'%'; - $query->where(function ($inner) use ($like) { - $inner->where('payer_name', 'like', $like) - ->orWhere('payer_email', 'like', $like) - ->orWhere('payer_note', 'like', $like) - ->orWhere('reference', 'like', $like) - ->orWhere('payment_reference', 'like', $like) - ->orWhereHas('qrCode', fn ($qr) => $qr->where('label', 'like', $like)); - }); - }) - ->with('qrCode') - ->latest('created_at') - ->paginate(25) - ->withQueryString(); - - return response()->json([ - 'data' => collect($payments->items())->map(fn (MiniPayment $p) => PaymentPresenter::present($p))->values(), - 'meta' => [ - 'current_page' => $payments->currentPage(), - 'last_page' => $payments->lastPage(), - 'per_page' => $payments->perPage(), - 'total' => $payments->total(), - ], - ]); - } -} diff --git a/app/Http/Controllers/Api/Mini/PayoutsController.php b/app/Http/Controllers/Api/Mini/PayoutsController.php deleted file mode 100644 index 7903784..0000000 --- a/app/Http/Controllers/Api/Mini/PayoutsController.php +++ /dev/null @@ -1,49 +0,0 @@ -qrCodes() - ->where('type', QrCode::TYPE_PAYMENT) - ->pluck('id'); - - $revenueMinor = (int) MiniPayment::query() - ->whereIn('qr_code_id', $qrIds) - ->where('status', MiniPayment::STATUS_PAID) - ->sum('merchant_amount_minor'); - - $balanceMinor = 0; - try { - $balanceMinor = $this->billing->balanceMinor($account->public_id); - } catch (Throwable $e) { - Log::warning('Mini API payouts could not load wallet balance', [ - 'user' => $account->public_id, - 'error' => $e->getMessage(), - ]); - } - - return response()->json([ - 'data' => [ - 'currency' => 'GHS', - 'total_revenue_minor' => $revenueMinor, - 'wallet_balance_minor' => $balanceMinor, - 'account_wallet_url' => 'https://'.config('app.account_domain').'/wallet', - ], - ]); - } -} diff --git a/app/Http/Controllers/Api/Mini/PushTokenController.php b/app/Http/Controllers/Api/Mini/PushTokenController.php deleted file mode 100644 index e29ab85..0000000 --- a/app/Http/Controllers/Api/Mini/PushTokenController.php +++ /dev/null @@ -1,51 +0,0 @@ -validate([ - 'token' => ['required', 'string', 'max:512'], - 'platform' => ['nullable', 'string', 'max:32'], - 'device_name' => ['nullable', 'string', 'max:120'], - ]); - - $user = $request->user(); - $now = now(); - - UserPushToken::updateOrCreate( - ['token' => $data['token']], - [ - 'user_id' => $user->id, - 'platform' => $data['platform'] ?? 'android', - 'device_name' => $data['device_name'] ?? $user->currentAccessToken()?->name, - 'last_seen_at' => $now, - ], - ); - - $user->update(['last_app_active_at' => $now]); - - return response()->json(['data' => ['registered' => true]]); - } - - public function destroy(Request $request): JsonResponse - { - $data = $request->validate([ - 'token' => ['required', 'string', 'max:512'], - ]); - - $request->user() - ->pushTokens() - ->where('token', $data['token']) - ->delete(); - - return response()->json(['data' => ['removed' => true]]); - } -} diff --git a/app/Http/Controllers/Api/Mini/SupportController.php b/app/Http/Controllers/Api/Mini/SupportController.php deleted file mode 100644 index b200a7c..0000000 --- a/app/Http/Controllers/Api/Mini/SupportController.php +++ /dev/null @@ -1,56 +0,0 @@ -identitySend('GET', '/api/identity/support/tickets?user='.urlencode((string) ladill_account()->public_id), []); - - return response()->json(['data' => $response->json('data', [])]); - } - - public function ticket(int $ticket): JsonResponse - { - $response = $this->identitySend( - 'GET', - '/api/identity/support/tickets/'.$ticket.'?user='.urlencode((string) ladill_account()->public_id), - [], - ); - - if ($response->status() === 404) { - return response()->json(['message' => 'Ticket not found.'], 404); - } - - return response()->json(['data' => $response->json('data')]); - } - - public function store(Request $request): JsonResponse - { - $data = $request->validate([ - 'subject' => ['required', 'string', 'max:255'], - 'message' => ['required', 'string', 'max:5000'], - 'priority' => ['sometimes', 'in:low,normal,high'], - ]); - - $response = $this->identitySend('POST', '/api/identity/support/tickets', array_merge( - ['user' => ladill_account()->public_id], - $data, - )); - $this->rethrowValidation($response, 'subject'); - - return response()->json(['data' => $response->json('data')], 201); - } -} diff --git a/app/Http/Controllers/Api/Mini/WalletController.php b/app/Http/Controllers/Api/Mini/WalletController.php deleted file mode 100644 index 876f4c4..0000000 --- a/app/Http/Controllers/Api/Mini/WalletController.php +++ /dev/null @@ -1,161 +0,0 @@ -billing->balanceMinor($account->public_id); - $ledger = $this->billing->serviceLedger($account->public_id, config('billing.service', 'mini')); - } catch (Throwable $e) { - Log::warning('Mini API wallet load failed', ['user' => $account->public_id, 'error' => $e->getMessage()]); - } - - $settings = $account->getOrCreateQrSetting(); - - return response()->json([ - 'data' => [ - 'currency' => 'GHS', - 'balance_minor' => $balanceMinor, - 'spent_minor' => (int) ($ledger['spent_minor'] ?? 0), - 'credited_minor' => (int) ($ledger['credited_minor'] ?? 0), - 'auto_withdraw_amount_minor' => $settings->auto_withdraw_amount_minor, - ], - ]); - } - - public function updateAutoWithdraw(Request $request): JsonResponse - { - $account = ladill_account(); - - $data = $request->validate([ - 'amount' => ['nullable', 'numeric', 'min:1', 'max:50000'], - ]); - - $amountMinor = isset($data['amount']) ? (int) round((float) $data['amount'] * 100) : null; - - $settings = $account->getOrCreateQrSetting(); - $settings->update(['auto_withdraw_amount_minor' => $amountMinor]); - - if ($amountMinor !== null) { - $this->autoWithdraw->attemptForUser($account); - } - - return response()->json([ - 'data' => [ - 'auto_withdraw_amount_minor' => $settings->fresh()->auto_withdraw_amount_minor, - ], - ]); - } - - public function topup(Request $request): JsonResponse - { - $account = ladill_account(); - - $data = $request->validate([ - 'amount' => ['required', 'numeric', 'min:1', 'max:10000'], - ]); - - $response = $this->identitySend('POST', '/api/identity/wallet-topup-account', [ - 'user' => $account->public_id, - 'amount' => (float) $data['amount'], - // Paystack returns the customer here after payment; the app catches the deep link. - 'return_url' => 'https://'.config('app.platform_domain').'/mini/wallet/topup-complete', - ]); - $this->rethrowValidation($response, 'amount'); - - $checkoutUrl = (string) $response->json('data.checkout_url', ''); - if ($checkoutUrl === '') { - return response()->json(['message' => 'Could not start top-up. Please try again.'], 422); - } - - return response()->json(['data' => ['checkout_url' => $checkoutUrl]]); - } - - public function banks(Request $request): JsonResponse - { - $type = $request->query('type') === 'mobile_money' ? 'mobile_money' : 'bank'; - $response = $this->identitySend('GET', '/api/identity/banks?type='.urlencode($type), []); - - return response()->json(['data' => $response->json('data', [])]); - } - - public function payoutAccount(): JsonResponse - { - $response = $this->identitySend('GET', '/api/identity/payout-account?user='.urlencode((string) ladill_account()->public_id), []); - - return response()->json(['data' => ['payout_account' => $response->json('data.payout_account')]]); - } - - public function updatePayoutAccount(Request $request): JsonResponse - { - $data = $request->validate([ - 'account_type' => ['required', 'in:mobile_money,bank_account'], - 'account_name' => ['required', 'string', 'max:200'], - 'account_number' => ['required', 'string', 'max:30'], - 'bank_code' => ['required', 'string', 'max:30'], - 'bank_name' => ['required', 'string', 'max:200'], - 'currency' => ['sometimes', 'string', 'size:3'], - ]); - $data['currency'] = $data['currency'] ?? 'GHS'; - - $response = $this->identitySend('PUT', '/api/identity/payout-account', array_merge( - ['user' => ladill_account()->public_id], - $data, - )); - $this->rethrowValidation($response, 'account_number'); - - return response()->json(['data' => ['payout_account' => $response->json('data.payout_account')]]); - } - - public function withdrawals(): JsonResponse - { - $response = $this->identitySend('GET', '/api/identity/wallet/withdrawals?user='.urlencode((string) ladill_account()->public_id), []); - - return response()->json(['data' => $response->json('data', [])]); - } - - public function withdraw(Request $request): JsonResponse - { - $data = $request->validate([ - 'amount' => ['required', 'numeric', 'min:1', 'max:50000'], - ]); - - $response = $this->identitySend('POST', '/api/identity/wallet/withdraw', [ - 'user' => ladill_account()->public_id, - 'amount' => (float) $data['amount'], - ]); - $this->rethrowValidation($response, 'amount'); - - $this->notifications->withdrawalSubmitted( - ladill_account(), - (float) $data['amount'], - ); - - return response()->json(['data' => $response->json('data', [])]); - } -} diff --git a/app/Http/Controllers/Api/QrCodeController.php b/app/Http/Controllers/Api/QrCodeController.php deleted file mode 100644 index ac4fbab..0000000 --- a/app/Http/Controllers/Api/QrCodeController.php +++ /dev/null @@ -1,153 +0,0 @@ -accountQuery() - ->latest() - ->get() - ->map(fn (QrCode $code) => $this->present($code)); - - return response()->json(['data' => $codes]); - } - - public function show(QrCode $qrCode): JsonResponse - { - $this->ensurePlusCode($qrCode); - $this->authorize('view', $qrCode); - - return response()->json(['data' => $this->present($qrCode, detailed: true)]); - } - - public function analytics(QrCode $qrCode): JsonResponse - { - $this->ensurePlusCode($qrCode); - $this->authorize('view', $qrCode); - - return response()->json([ - 'data' => [ - 'summary' => $this->analytics->summaryFor($qrCode), - 'daily_scans' => $this->analytics->dailyScans($qrCode, 30)->values(), - 'devices' => $this->analytics->breakdown($qrCode, 'device_type'), - 'browsers' => $this->analytics->breakdown($qrCode, 'browser'), - ], - ]); - } - - public function store(Request $request): JsonResponse - { - abort_unless($request->user()->tokenCan('qr:write'), 403, 'Token requires qr:write ability.'); - - $validated = $request->validate([ - 'label' => ['required', 'string', 'max:120'], - 'type' => ['required', 'in:'.implode(',', QrTypeCatalog::keys())], - 'destination_url' => ['nullable', 'url', 'max:2048'], - 'custom_short_code' => ['nullable', 'string', 'regex:/^[a-z0-9][a-z0-9-]{1,18}[a-z0-9]$/', 'unique:qr_codes,short_code'], - 'is_active' => ['sometimes', 'boolean'], - ]); - - if ($validated['type'] === QrCode::TYPE_DOCUMENT) { - return response()->json([ - 'message' => 'PDF QR codes must be created in the QR Plus app (file upload required).', - ], 422); - } - - try { - $qrCode = $this->manager->create(ladill_account(), array_merge( - $request->all(), - $validated, - )); - } catch (RuntimeException $e) { - return response()->json(['message' => $e->getMessage()], 422); - } - - return response()->json(['data' => $this->present($qrCode->fresh(), detailed: true)], 201); - } - - public function update(Request $request, QrCode $qrCode): JsonResponse - { - abort_unless($request->user()->tokenCan('qr:write'), 403, 'Token requires qr:write ability.'); - - $this->ensurePlusCode($qrCode); - $this->authorize('update', $qrCode); - - $request->validate([ - 'label' => ['sometimes', 'string', 'max:120'], - 'destination_url' => ['nullable', 'url', 'max:2048'], - 'is_active' => ['sometimes', 'boolean'], - ]); - - if ($qrCode->isDocumentType() && $request->hasFile('document')) { - return response()->json([ - 'message' => 'Replace PDF files in the QR Plus app.', - ], 422); - } - - try { - $updated = $this->manager->update($qrCode, $request->all()); - } catch (RuntimeException $e) { - return response()->json(['message' => $e->getMessage()], 422); - } - - return response()->json(['data' => $this->present($updated->fresh(), detailed: true)]); - } - - /** @return \Illuminate\Database\Eloquent\Builder */ - private function accountQuery() - { - return QrCode::query() - ->where('user_id', ladill_account()->id) - ->whereIn('type', QrTypeCatalog::eventTypes()); - } - - private function ensurePlusCode(QrCode $qrCode): void - { - abort_unless( - $qrCode->user_id === ladill_account()->id && QrTypeCatalog::isValid($qrCode->type), - 404, - ); - } - - /** @return array */ - private function present(QrCode $qrCode, bool $detailed = false): array - { - $data = [ - 'id' => $qrCode->id, - 'label' => $qrCode->label, - 'type' => $qrCode->type, - 'type_label' => $qrCode->typeLabel(), - 'short_code' => $qrCode->short_code, - 'public_url' => $qrCode->publicUrl(), - 'is_active' => $qrCode->is_active, - 'scans_total' => $qrCode->scans_total, - 'unique_scans_total' => $qrCode->unique_scans_total, - 'last_scanned_at' => $qrCode->last_scanned_at?->toIso8601String(), - 'created_at' => $qrCode->created_at?->toIso8601String(), - 'updated_at' => $qrCode->updated_at?->toIso8601String(), - ]; - - if ($detailed) { - $data['destination_url'] = $qrCode->destination_url; - $data['content'] = $qrCode->content(); - } - - return $data; - } -} diff --git a/app/Http/Controllers/Mini/OverviewController.php b/app/Http/Controllers/Mini/OverviewController.php deleted file mode 100644 index 3a03bd1..0000000 --- a/app/Http/Controllers/Mini/OverviewController.php +++ /dev/null @@ -1,64 +0,0 @@ -qrCodes() - ->where('type', QrCode::TYPE_PAYMENT) - ->pluck('id'); - - $todayStart = now()->startOfDay(); - - $todayPayments = MiniPayment::query() - ->whereIn('qr_code_id', $qrIds) - ->where('status', MiniPayment::STATUS_PAID) - ->where('paid_at', '>=', $todayStart); - - $todayCount = (clone $todayPayments)->count(); - $todayMinor = (int) (clone $todayPayments)->sum('merchant_amount_minor'); - - $recentPayments = MiniPayment::query() - ->whereIn('qr_code_id', $qrIds) - ->where('status', MiniPayment::STATUS_PAID) - ->with('qrCode') - ->latest('paid_at') - ->limit(8) - ->get(); - - $paymentQrCount = $qrIds->count(); - - $balanceMinor = 0; - try { - $balanceMinor = $this->billing->balanceMinor($account->public_id); - } catch (Throwable $e) { - Log::warning('Mini dashboard could not load wallet balance', [ - 'user' => $account->public_id, - 'error' => $e->getMessage(), - ]); - } - - return view('mini.dashboard', [ - 'todayCount' => $todayCount, - 'todayMinor' => $todayMinor, - 'paymentQrCount' => $paymentQrCount, - 'recentPayments' => $recentPayments, - 'balanceMinor' => $balanceMinor, - ]); - } -} diff --git a/app/Http/Controllers/Mini/PaymentQrController.php b/app/Http/Controllers/Mini/PaymentQrController.php deleted file mode 100644 index fab5e86..0000000 --- a/app/Http/Controllers/Mini/PaymentQrController.php +++ /dev/null @@ -1,180 +0,0 @@ -qrCodes() - ->where('type', QrCode::TYPE_PAYMENT) - ->latest() - ->get(); - - $previewDataUris = $qrCodes->mapWithKeys(function (QrCode $qr) { - return [$qr->id => $this->imageGenerator->previewDataUri($qr)]; - }); - - return view('mini.payment-qrs.index', [ - 'qrCodes' => $qrCodes, - 'previewDataUris' => $previewDataUris, - ]); - } - - public function create(): View - { - return view('mini.payment-qrs.create'); - } - - public function store(Request $request): RedirectResponse - { - $account = ladill_account(); - - $data = $request->validate([ - 'label' => 'required|string|max:120', - 'business_name' => 'required|string|max:120', - 'branch_label' => 'nullable|string|max:80', - ]); - - $data['type'] = QrCode::TYPE_PAYMENT; - $data['currency'] = 'GHS'; - - try { - $qrCode = $this->manager->create($account, $data); - } catch (RuntimeException $e) { - return back()->withInput()->with('error', $e->getMessage()); - } - - return redirect()->route('mini.payment-qrs.show', $qrCode)->with('success', 'Payment QR created.'); - } - - public function show(QrCode $paymentQr): View - { - $this->authorizePaymentQr($paymentQr); - - $previewDataUri = $this->imageGenerator->previewDataUri($paymentQr); - - return view('mini.payment-qrs.show', [ - 'qrCode' => $paymentQr->fresh(), - 'previewDataUri' => $previewDataUri, - ]); - } - - public function update(Request $request, QrCode $paymentQr): RedirectResponse - { - $this->authorizePaymentQr($paymentQr); - - $data = $request->validate([ - 'label' => 'sometimes|string|max:120', - 'business_name' => 'sometimes|string|max:120', - 'branch_label' => 'nullable|string|max:80', - 'is_active' => 'sometimes|boolean', - ]); - - $data['is_active'] = $request->boolean('is_active', $paymentQr->is_active); - - try { - $this->manager->update($paymentQr, $data); - } catch (RuntimeException $e) { - return back()->withInput()->with('error', $e->getMessage()); - } - - return back()->with('success', 'Payment QR updated.'); - } - - public function destroy(QrCode $paymentQr): RedirectResponse - { - $this->authorizePaymentQr($paymentQr); - - $this->manager->delete($paymentQr); - - return redirect() - ->route('mini.payment-qrs.index') - ->with('success', 'Payment QR deleted.'); - } - - public function preview(QrCode $paymentQr): Response - { - $this->authorizePaymentQr($paymentQr); - - $qrCode = $this->imageGenerator->ensureValidImages($paymentQr); - $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 $paymentQr, string $format): StreamedResponse - { - $this->authorizePaymentQr($paymentQr); - - $qrCode = $this->imageGenerator->ensureValidImages($paymentQr); - $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); - } - - private function authorizePaymentQr(QrCode $paymentQr): void - { - abort_unless($paymentQr->type === QrCode::TYPE_PAYMENT, 404); - abort_unless($paymentQr->user_id === ladill_account()->id, 403); - } -} diff --git a/app/Http/Controllers/Mini/PaymentsController.php b/app/Http/Controllers/Mini/PaymentsController.php deleted file mode 100644 index c60277c..0000000 --- a/app/Http/Controllers/Mini/PaymentsController.php +++ /dev/null @@ -1,45 +0,0 @@ -query('q', '')); - - $qrIds = $account->qrCodes() - ->where('type', QrCode::TYPE_PAYMENT) - ->pluck('id'); - - $payments = MiniPayment::query() - ->whereIn('qr_code_id', $qrIds) - ->when($search !== '', function ($query) use ($search) { - $like = '%'.$search.'%'; - $query->where(function ($inner) use ($like) { - $inner->where('payer_name', 'like', $like) - ->orWhere('payer_email', 'like', $like) - ->orWhere('payer_note', 'like', $like) - ->orWhere('reference', 'like', $like) - ->orWhere('payment_reference', 'like', $like) - ->orWhereHas('qrCode', fn ($qr) => $qr->where('label', 'like', $like)); - }); - }) - ->with('qrCode') - ->latest('created_at') - ->paginate(25) - ->withQueryString(); - - return view('mini.payments', [ - 'payments' => $payments, - 'search' => $search, - ]); - } -} diff --git a/app/Http/Controllers/Mini/PayoutsController.php b/app/Http/Controllers/Mini/PayoutsController.php deleted file mode 100644 index 291f9c2..0000000 --- a/app/Http/Controllers/Mini/PayoutsController.php +++ /dev/null @@ -1,48 +0,0 @@ -qrCodes() - ->where('type', QrCode::TYPE_PAYMENT) - ->pluck('id'); - - $revenueMinor = (int) MiniPayment::query() - ->whereIn('qr_code_id', $qrIds) - ->where('status', MiniPayment::STATUS_PAID) - ->sum('merchant_amount_minor'); - - $balanceMinor = 0; - try { - $balanceMinor = $this->billing->balanceMinor($account->public_id); - } catch (Throwable $e) { - Log::warning('Mini payouts could not load wallet balance', [ - 'user' => $account->public_id, - 'error' => $e->getMessage(), - ]); - } - - $accountWalletUrl = 'https://'.config('app.account_domain').'/wallet'; - - return view('mini.payouts', [ - 'revenueMinor' => $revenueMinor, - 'balanceMinor' => $balanceMinor, - 'accountWalletUrl' => $accountWalletUrl, - ]); - } -} diff --git a/app/Http/Controllers/Public/PaymentController.php b/app/Http/Controllers/Public/PaymentController.php deleted file mode 100644 index e9f0917..0000000 --- a/app/Http/Controllers/Public/PaymentController.php +++ /dev/null @@ -1,66 +0,0 @@ -with('user.qrSetting') - ->where('short_code', $shortCode) - ->where('type', QrCode::TYPE_PAYMENT) - ->where('is_active', true) - ->firstOrFail(); - - $validated = $request->validate([ - 'amount' => 'required|numeric|min:0.01', - ]); - - try { - $result = $this->payments->initiate($qrCode, $validated); - } catch (RuntimeException $e) { - if ($request->expectsJson()) { - return response()->json(['error' => $e->getMessage()], 422); - } - - return back()->withInput()->with('error', $e->getMessage()); - } - - if ($request->expectsJson()) { - return response()->json(['checkout_url' => $result['checkout_url']]); - } - - return redirect()->away($result['checkout_url']); - } - - public function callback(Request $request, string $shortCode): RedirectResponse|View - { - $reference = trim((string) $request->query('reference', '')); - if ($reference === '') { - return redirect('/q/'.$shortCode)->with('error', 'Missing payment reference.'); - } - - try { - $payment = $this->payments->complete($reference); - } catch (RuntimeException $e) { - return redirect('/q/'.$shortCode)->with('error', $e->getMessage()); - } - - return view('public.qr.payment-confirmed', [ - 'payment' => $payment, - 'qrCode' => $payment->qrCode, - ]); - } -} diff --git a/app/Http/Controllers/Public/QrScanController.php b/app/Http/Controllers/Public/QrScanController.php deleted file mode 100644 index f901f25..0000000 --- a/app/Http/Controllers/Public/QrScanController.php +++ /dev/null @@ -1,345 +0,0 @@ -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->isPaymentType()) { - return view('public.qr.payment-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 paymentLogo(string $shortCode): StreamedResponse - { - return $this->serveContentImage($shortCode, QrCode::TYPE_PAYMENT, 'logo_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); - } -} diff --git a/app/Http/Controllers/Qr/AccountController.php b/app/Http/Controllers/Qr/AccountController.php deleted file mode 100644 index 67f66d7..0000000 --- a/app/Http/Controllers/Qr/AccountController.php +++ /dev/null @@ -1,94 +0,0 @@ -} */ - private function billingSnapshot(?string $publicId): array - { - if (! $publicId) { - return [0, []]; - } - - $balanceMinor = $this->billing->balanceMinor($publicId); - $ledger = $this->billing->serviceLedger($publicId, config('billing.service', 'mini')); - - return [$balanceMinor, $ledger]; - } - - public function wallet(): View - { - $user = ladill_account(); - [$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 = ladill_account(); - [$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 - { - $account = ladill_account(); - $settings = $account->getOrCreateQrSetting(); - - return view('mini.settings', [ - 'account' => $account, - 'settings' => $settings, - ]); - } - - public function updateSettings(Request $request): RedirectResponse - { - $account = ladill_account(); - - $data = $request->validate([ - 'notify_email' => ['nullable', 'email', 'max:255'], - 'product_updates' => ['nullable', 'boolean'], - 'notify_registrations' => ['nullable', 'boolean'], - 'notify_payouts' => ['nullable', 'boolean'], - ]); - - QrSetting::updateOrCreate( - ['user_id' => $account->id], - [ - 'notify_email' => $data['notify_email'] ?? null, - 'product_updates' => $request->boolean('product_updates'), - 'notify_registrations' => $request->boolean('notify_registrations'), - 'notify_payouts' => $request->boolean('notify_payouts'), - ], - ); - - return redirect()->route('account.settings')->with('success', 'Settings saved.'); - } -} diff --git a/app/Http/Controllers/Qr/AfiaController.php b/app/Http/Controllers/Qr/AfiaController.php deleted file mode 100644 index e734bee..0000000 --- a/app/Http/Controllers/Qr/AfiaController.php +++ /dev/null @@ -1,87 +0,0 @@ -validate([ - 'message' => ['required', 'string', 'max:2000'], - 'history' => ['nullable', 'array', 'max:20'], - 'history.*.role' => ['nullable', 'string'], - 'history.*.text' => ['nullable', 'string'], - ]); - - if (! $afia->enabled()) { - return response()->json(['message' => 'Afia is not available right now.'], 503); - } - - try { - $reply = $afia->chat(trim($validated['message']), $validated['history'] ?? [], $this->context()); - } catch (\Throwable $e) { - report($e); - - return response()->json(['message' => 'Afia could not respond right now. Please try again.'], 502); - } - - return response()->json(['reply' => $reply]); - } - - /** @return array */ - private function context(): array - { - $account = ladill_account(); - - if (! $account) { - return ['signed_in' => 'no']; - } - - $types = QrTypeCatalog::paymentTypes(); - $codes = $account->qrCodes()->whereIn('type', $types); - - $ctx = [ - 'signed_in' => 'yes', - 'qr_codes_total' => (clone $codes)->count(), - 'qr_codes_active' => (clone $codes)->where('is_active', true)->count(), - 'scans_total' => (int) (clone $codes)->sum('scans_total'), - 'top_code_type' => (clone $codes) - ->selectRaw('type, count(*) as total') - ->groupBy('type') - ->orderByDesc('total') - ->value('type') ?: 'none yet', - ]; - - if ($account->public_id) { - try { - $ctx['wallet_balance_ghs'] = number_format(app(BillingClient::class)->balanceMinor((string) $account->public_id) / 100, 2); - } catch (\Throwable) { - } - } - - $recent = $account->qrCodes() - ->whereIn('type', $types) - ->latest('updated_at') - ->limit(3) - ->get(['type', 'label', 'short_code', 'scans_total']); - - if ($recent->isNotEmpty()) { - $ctx['recent_codes'] = $recent->map(fn (QrCode $code): string => sprintf( - '%s (%s, %d scans)', - $code->label ?: $code->short_code, - QrTypeCatalog::label($code->type), - $code->scans_total, - ))->implode('; '); - } - - return $ctx; - } -} diff --git a/app/Http/Controllers/Qr/DeveloperController.php b/app/Http/Controllers/Qr/DeveloperController.php deleted file mode 100644 index 98b53a5..0000000 --- a/app/Http/Controllers/Qr/DeveloperController.php +++ /dev/null @@ -1,45 +0,0 @@ - $request->user()->tokens()->latest()->get(), - 'apiBase' => rtrim((string) config('app.url'), '/').'/api/v1', - 'newToken' => session('new_token'), - ]); - } - - public function store(Request $request): RedirectResponse - { - $data = $request->validate([ - 'name' => ['required', 'string', 'max:60'], - ]); - - $token = $request->user()->createToken($data['name'], ['qr:read', 'qr:write']); - - return redirect()->route('account.developers') - ->with('new_token', $token->plainTextToken) - ->with('success', 'Token created — copy it now, it won’t be shown again.'); - } - - public function destroy(Request $request, int $token): RedirectResponse - { - $request->user()->tokens()->whereKey($token)->delete(); - - return redirect()->route('account.developers')->with('success', 'Token revoked.'); - } -} diff --git a/app/Http/Controllers/Qr/QrCodeController.php b/app/Http/Controllers/Qr/QrCodeController.php deleted file mode 100644 index 879af1b..0000000 --- a/app/Http/Controllers/Qr/QrCodeController.php +++ /dev/null @@ -1,454 +0,0 @@ -manager->walletFor($account); - $qrCodes = $account->qrCodes() - ->whereIn('type', QrTypeCatalog::eventTypes()) - ->withCount(['eventRegistrations as registrations_count' => fn ($q) => $q->where('status', QrEventRegistration::STATUS_CONFIRMED)]) - ->latest() - ->get(); - - $totalRegistrations = (int) $qrCodes->sum('registrations_count'); - - $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_EVENT); - if (! QrTypeCatalog::isValid($requestedType)) { - $requestedType = QrCode::TYPE_EVENT; - } - - 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('events.show', $qrCode) - ->with('success', QrTypeCatalog::label($qrCode->type).' created.'); - } - - public function show(Request $request, QrCode $event): View - { - $this->authorize('view', $event); - - $previewDataUri = $this->imageGenerator->previewDataUri($event); - $qrCode = $event->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 $event): RedirectResponse - { - $this->authorize('update', $event); - $qrCode = $event; - - $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 $event): Response - { - $this->authorize('view', $event); - - $qrCode = $this->imageGenerator->ensureValidImages($event); - - $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 $event, string $format): StreamedResponse - { - $this->authorize('view', $event); - - $qrCode = $this->imageGenerator->ensureValidImages($event); - $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 $event): JsonResponse - { - $this->authorize('update', $event); - $qrCode = $event; - - $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]); - } -} diff --git a/app/Http/Controllers/Qr/TeamController.php b/app/Http/Controllers/Qr/TeamController.php deleted file mode 100644 index 4891ee5..0000000 --- a/app/Http/Controllers/Qr/TeamController.php +++ /dev/null @@ -1,108 +0,0 @@ -where('account_id', $account->id) - ->with('member') - ->orderBy('status')->orderBy('email') - ->get(); - - return view('qr.account.team', [ - 'account' => $account, - 'members' => $members, - 'canManage' => $this->canManage($request), - 'isOwner' => $request->user()->id === $account->id, - ]); - } - - public function store(Request $request): RedirectResponse - { - abort_unless($this->canManage($request), 403); - - $validated = $request->validate([ - 'email' => ['required', 'email', 'max:255'], - 'role' => ['required', 'in:admin,member'], - ]); - - $account = ladill_account(); - $email = strtolower($validated['email']); - - if ($email === strtolower($account->email)) { - return back()->withErrors(['email' => 'The owner is already on the account.']); - } - - $member = QrTeamMember::firstOrNew(['account_id' => $account->id, 'email' => $email]); - $member->role = $validated['role']; - if (! $member->exists) { - $member->status = QrTeamMember::STATUS_INVITED; - $member->token = Str::random(40); - } - $member->save(); - - if ($existing = User::whereRaw('LOWER(email) = ?', [$email])->first()) { - QrTeamMember::linkPendingInvitesFor($existing); - } - - return back()->with('success', $email.' invited.'); - } - - public function updateRole(Request $request, QrTeamMember $member): RedirectResponse - { - abort_unless($this->canManage($request) && $member->account_id === ladill_account()->id, 403); - - $validated = $request->validate(['role' => ['required', 'in:admin,member']]); - $member->update(['role' => $validated['role']]); - - return back()->with('success', 'Role updated.'); - } - - public function destroy(Request $request, QrTeamMember $member): RedirectResponse - { - abort_unless($this->canManage($request) && $member->account_id === ladill_account()->id, 403); - - $member->delete(); - - return back()->with('success', 'Member removed.'); - } - - public function switchAccount(Request $request): RedirectResponse - { - $validated = $request->validate(['account' => ['required', 'integer']]); - - abort_unless($request->user()->canAccessAccount((int) $validated['account']), 403); - - $request->session()->put('ladill_account', (int) $validated['account']); - - return redirect()->route('mini.dashboard'); - } - - private function canManage(Request $request): bool - { - $user = $request->user(); - $account = ladill_account(); - - if ($user->id === $account->id) { - return true; - } - - return $user->memberships() - ->where('account_id', $account->id) - ->where('role', QrTeamMember::ROLE_ADMIN) - ->exists(); - } -} diff --git a/app/Http/Controllers/SearchController.php b/app/Http/Controllers/SearchController.php deleted file mode 100644 index 9cc2bae..0000000 --- a/app/Http/Controllers/SearchController.php +++ /dev/null @@ -1,71 +0,0 @@ -query('q')); - $results = mb_strlen($q) >= 2 ? $this->results($q) : []; - - if ($request->expectsJson() || $request->ajax() || $request->wantsJson()) { - return response()->json(['results' => $results]); - } - - return view('search.index', [ - 'query' => $q, - 'results' => $results, - ]); - } - - /** @return list */ - private function results(string $q): array - { - $account = ladill_account(); - $like = '%'.$q.'%'; - - $qrIds = $account->qrCodes() - ->where('type', QrCode::TYPE_PAYMENT) - ->pluck('id'); - - if ($qrIds->isEmpty()) { - return []; - } - - return MiniPayment::query() - ->whereIn('qr_code_id', $qrIds) - ->with('qrCode') - ->where(function ($query) use ($like) { - $query->where('payer_name', 'like', $like) - ->orWhere('payer_email', 'like', $like) - ->orWhere('payer_note', 'like', $like) - ->orWhere('reference', 'like', $like) - ->orWhere('payment_reference', 'like', $like) - ->orWhereHas('qrCode', fn ($qr) => $qr->where('label', 'like', $like)); - }) - ->latest('created_at') - ->limit(15) - ->get() - ->map(function (MiniPayment $payment): array { - $amount = number_format( - ($payment->status === MiniPayment::STATUS_PAID ? $payment->merchant_amount_minor : $payment->amount_minor) / 100, - 2, - ); - - return [ - 'type' => 'payment', - 'title' => $payment->payer_name ?: 'Walk-in customer', - 'subtitle' => 'GHS '.$amount.' · '.($payment->qrCode?->label ?? 'Payment QR').' · '.ucfirst($payment->status), - 'url' => route('mini.payments.index', ['q' => $payment->reference]), - ]; - }) - ->all(); - } -} diff --git a/app/Http/Controllers/WellKnown/AssetLinksController.php b/app/Http/Controllers/WellKnown/AssetLinksController.php deleted file mode 100644 index aa62c76..0000000 --- a/app/Http/Controllers/WellKnown/AssetLinksController.php +++ /dev/null @@ -1,27 +0,0 @@ -json([ - [ - 'relation' => ['delegate_permission/common.handle_all_urls'], - 'target' => [ - 'namespace' => 'android_app', - 'package_name' => config('android_app_links.package_name'), - 'sha256_cert_fingerprints' => $fingerprints, - ], - ], - ], 200, [], JSON_UNESCAPED_SLASHES); - } -} diff --git a/app/Models/MiniPayment.php b/app/Models/MiniPayment.php deleted file mode 100644 index 8e9f704..0000000 --- a/app/Models/MiniPayment.php +++ /dev/null @@ -1,62 +0,0 @@ - 'integer', - 'platform_fee_minor' => 'integer', - 'merchant_amount_minor' => 'integer', - 'metadata' => 'array', - 'paid_at' => 'datetime', - ]; - - public function qrCode(): BelongsTo - { - return $this->belongsTo(QrCode::class); - } - - public function merchant(): BelongsTo - { - return $this->belongsTo(User::class, 'user_id'); - } - - public function amountMajor(): float - { - return $this->amount_minor / 100; - } -} diff --git a/app/Models/QrCode.php b/app/Models/QrCode.php deleted file mode 100644 index 020a348..0000000 --- a/app/Models/QrCode.php +++ /dev/null @@ -1,212 +0,0 @@ - '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 miniPayments(): HasMany - { - return $this->hasMany(MiniPayment::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 */ - public function content(): array - { - return (array) ($this->payload['content'] ?? []); - } - - /** @return array */ - public function style(): array - { - if ($this->isPaymentType()) { - return QrStyleDefaults::defaults(); - } - - 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 isPaymentType(): bool - { - return $this->type === self::TYPE_PAYMENT; - } - - public function usesLandingPage(): bool - { - return in_array($this->type, [ - self::TYPE_PAYMENT, - ], 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; - } -} diff --git a/app/Models/QrDocument.php b/app/Models/QrDocument.php deleted file mode 100644 index 1ce2d16..0000000 --- a/app/Models/QrDocument.php +++ /dev/null @@ -1,35 +0,0 @@ - 'integer', - 'page_count' => 'integer', - ]; - - public function user(): BelongsTo - { - return $this->belongsTo(User::class); - } - - public function qrCodes(): HasMany - { - return $this->hasMany(QrCode::class); - } -} diff --git a/app/Models/QrScanEvent.php b/app/Models/QrScanEvent.php deleted file mode 100644 index de86c74..0000000 --- a/app/Models/QrScanEvent.php +++ /dev/null @@ -1,32 +0,0 @@ - 'datetime', - 'is_unique' => 'boolean', - ]; - - public function qrCode(): BelongsTo - { - return $this->belongsTo(QrCode::class); - } -} diff --git a/app/Models/QrSetting.php b/app/Models/QrSetting.php deleted file mode 100644 index ebe6e5f..0000000 --- a/app/Models/QrSetting.php +++ /dev/null @@ -1,174 +0,0 @@ - 'boolean', - 'low_balance_alerts' => 'boolean', - 'notify_registrations' => 'boolean', - 'notify_payouts' => 'boolean', - 'auto_withdraw_amount_minor' => 'integer', - 'default_style' => 'array', - 'event_defaults' => 'array', - ]; - - /** @return list */ - public static function storableEventDefaultKeys(): array - { - return [ - 'currency', - 'mode', - 'badge_size', - 'brand_color', - 'organizer', - 'registration_open', - 'badge_fields', - ]; - } - - /** @return list */ - public static function storableStyleKeys(): array - { - return [ - 'foreground', - 'background', - 'error_correction', - 'margin', - 'module_style', - 'finder_outer', - 'finder_inner', - 'frame_style', - 'frame_text', - 'frame_color', - 'gradient_type', - 'gradient_color1', - 'gradient_color2', - 'gradient_rotation', - ]; - } - - public function user(): BelongsTo - { - return $this->belongsTo(User::class); - } - - public function resolvedDefaultType(): string - { - $type = (string) ($this->default_type ?? QrCode::TYPE_EVENT); - - return in_array($type, QrTypeCatalog::eventsTypes(), true) ? $type : QrCode::TYPE_EVENT; - } - - /** @return array */ - public function resolvedEventDefaults(): array - { - $defaults = [ - 'currency' => 'GHS', - 'mode' => 'ticketing', - 'badge_size' => '4x3', - 'brand_color' => '#4f46e5', - 'organizer' => '', - 'registration_open' => true, - 'badge_fields' => ['Company', 'Role'], - ]; - - $stored = collect($this->event_defaults ?? []) - ->only(self::storableEventDefaultKeys()) - ->filter(fn ($value) => $value !== null && $value !== '') - ->all(); - - if (isset($stored['badge_fields']) && is_array($stored['badge_fields'])) { - $stored['badge_fields'] = array_values(array_filter( - array_map('strval', $stored['badge_fields']), - fn (string $field) => trim($field) !== '', - )); - if ($stored['badge_fields'] === []) { - unset($stored['badge_fields']); - } - } - - return array_merge($defaults, $stored); - } - - /** @param array|null $input */ - public static function sanitizeEventDefaults(?array $input): ?array - { - if ($input === null) { - return null; - } - - $allowedModes = ['ticketing', 'contributions', 'free']; - $allowedSizes = ['4x3', '4x6', 'cr80']; - $allowedCurrencies = ['GHS', 'USD', 'NGN', 'KES']; - - $mode = (string) ($input['mode'] ?? 'ticketing'); - $badgeSize = (string) ($input['badge_size'] ?? '4x3'); - $currency = strtoupper(trim((string) ($input['currency'] ?? 'GHS'))); - - $badgeFields = array_values(array_filter( - array_map(fn ($field) => mb_substr(trim((string) $field), 0, 40), (array) ($input['badge_fields'] ?? [])), - fn (string $field) => $field !== '', - )); - - $brandColor = (string) ($input['brand_color'] ?? '#4f46e5'); - if (! preg_match('/^#[0-9a-fA-F]{6}$/', $brandColor)) { - $brandColor = '#4f46e5'; - } - - return [ - 'currency' => in_array($currency, $allowedCurrencies, true) ? $currency : 'GHS', - 'mode' => in_array($mode, $allowedModes, true) ? $mode : 'ticketing', - 'badge_size' => in_array($badgeSize, $allowedSizes, true) ? $badgeSize : '4x3', - 'brand_color' => $brandColor, - 'organizer' => mb_substr(trim((string) ($input['organizer'] ?? '')), 0, 120), - 'registration_open' => filter_var($input['registration_open'] ?? true, FILTER_VALIDATE_BOOL), - 'badge_fields' => $badgeFields !== [] ? $badgeFields : ['Company', 'Role'], - ]; - } - - /** @return array */ - public function resolvedDefaultStyle(): array - { - $stored = collect($this->default_style ?? []) - ->only(self::storableStyleKeys()) - ->filter(fn ($value) => $value !== null && $value !== '') - ->all(); - - return QrStyleDefaults::merge($stored !== [] ? $stored : null); - } - - /** @param array|null $input */ - public static function sanitizeDefaultStyle(?array $input): ?array - { - if ($input === null) { - return null; - } - - $merged = QrStyleDefaults::merge( - collect($input)->only(self::storableStyleKeys())->all(), - ); - - return collect($merged)->only(self::storableStyleKeys())->all(); - } -} diff --git a/app/Models/QrTransaction.php b/app/Models/QrTransaction.php deleted file mode 100644 index b1f29da..0000000 --- a/app/Models/QrTransaction.php +++ /dev/null @@ -1,46 +0,0 @@ - '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); - } -} diff --git a/app/Models/QrWallet.php b/app/Models/QrWallet.php deleted file mode 100644 index 05b1563..0000000 --- a/app/Models/QrWallet.php +++ /dev/null @@ -1,66 +0,0 @@ - '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; - } -} diff --git a/app/Models/User.php b/app/Models/User.php index b158607..3e45a80 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -5,7 +5,6 @@ namespace App\Models; use Database\Factories\UserFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; -use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Illuminate\Support\Collection; @@ -52,39 +51,6 @@ class User extends Authenticatable return collect([$this])->merge(self::whereIn('id', $ids)->get())->unique('id')->values(); } - public function qrWallet(): HasOne - { - return $this->hasOne(QrWallet::class); - } - - public function qrCodes(): HasMany - { - return $this->hasMany(QrCode::class); - } - - public function qrSetting(): HasOne - { - return $this->hasOne(QrSetting::class); - } - - public function pushTokens(): HasMany - { - return $this->hasMany(UserPushToken::class); - } - - public function getOrCreateQrSetting(): QrSetting - { - return $this->qrSetting()->firstOrCreate([]); - } - - public function getOrCreateQrWallet(): QrWallet - { - return $this->qrWallet()->firstOrCreate( - [], - ['credit_balance' => 0, 'qr_codes_total' => 0, 'scans_total' => 0, 'status' => QrWallet::STATUS_ACTIVE], - ); - } - public function avatarUrl(): ?string { $url = trim((string) $this->avatar_url); diff --git a/app/Models/UserPushToken.php b/app/Models/UserPushToken.php deleted file mode 100644 index ebe33ff..0000000 --- a/app/Models/UserPushToken.php +++ /dev/null @@ -1,26 +0,0 @@ - 'datetime', - ]; - - public function user(): BelongsTo - { - return $this->belongsTo(User::class); - } -} diff --git a/app/Notifications/EventProgrammeSharedNotification.php b/app/Notifications/EventProgrammeSharedNotification.php deleted file mode 100644 index ffc4729..0000000 --- a/app/Notifications/EventProgrammeSharedNotification.php +++ /dev/null @@ -1,38 +0,0 @@ -event->content()['name'] ?? $this->event->label; - - return (new MailMessage()) - ->subject('Programme for ' . $eventName) - ->view('mail.notifications.event-programme', [ - 'eventName' => $eventName, - 'programmeUrl' => $this->programme->publicUrl(), - 'attendeeName' => $this->attendeeName, - ]); - } -} diff --git a/app/Notifications/Mini/MiniAlertNotification.php b/app/Notifications/Mini/MiniAlertNotification.php deleted file mode 100644 index 954e951..0000000 --- a/app/Notifications/Mini/MiniAlertNotification.php +++ /dev/null @@ -1,42 +0,0 @@ - $extra - */ - public function __construct( - private readonly string $title, - private readonly string $message, - private readonly string $icon, - private readonly ?string $url, - private readonly string $milestone, - private readonly array $extra = [], - ) {} - - /** @return list */ - public function via(object $notifiable): array - { - return ['database']; - } - - /** @return array */ - public function toArray(object $notifiable): array - { - return array_merge([ - 'title' => $this->title, - 'message' => $this->message, - 'icon' => $this->icon, - 'url' => $this->url, - 'milestone' => $this->milestone, - ], $this->extra); - } -} diff --git a/app/Policies/QrCodePolicy.php b/app/Policies/QrCodePolicy.php deleted file mode 100644 index d98f4f2..0000000 --- a/app/Policies/QrCodePolicy.php +++ /dev/null @@ -1,24 +0,0 @@ -canAccessAccount($qrCode->user_id); - } - - public function update(User $user, QrCode $qrCode): bool - { - return $user->canAccessAccount($qrCode->user_id); - } - - public function delete(User $user, QrCode $qrCode): bool - { - return $user->canAccessAccount($qrCode->user_id); - } -} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index ac11018..15ebe6a 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,9 +2,6 @@ namespace App\Providers; -use App\Models\QrCode; -use App\Policies\QrCodePolicy; -use Illuminate\Support\Facades\Gate; use Illuminate\Support\ServiceProvider; use App\Support\MobileTopbar; use Illuminate\Support\Facades\View; @@ -18,10 +15,8 @@ class AppServiceProvider extends ServiceProvider public function boot(): void { - Gate::policy(QrCode::class, QrCodePolicy::class); - View::composer(['partials.topbar', 'partials.topbar-qr'], function ($view) { + View::composer(['partials.topbar'], function ($view) { $view->with(MobileTopbar::resolve()); }); - } } diff --git a/app/Services/Afia/AfiaService.php b/app/Services/Afia/AfiaService.php deleted file mode 100644 index 937b034..0000000 --- a/app/Services/Afia/AfiaService.php +++ /dev/null @@ -1,117 +0,0 @@ - $history - * @param array $context - */ - public function chat(string $message, array $history, array $context): string - { - if (! $this->enabled()) { - throw new RuntimeException('Afia is not configured.'); - } - - $provider = (string) config('afia.provider', 'openai'); - $model = (string) config('afia.model', 'gpt-4o-mini'); - $apiKey = (string) config('afia.api_key'); - - $messages = [['role' => 'system', 'content' => $this->systemPrompt($context)]]; - foreach (array_slice($history, -8) as $turn) { - $role = ($turn['role'] ?? 'user') === 'assistant' ? 'assistant' : 'user'; - $text = trim((string) ($turn['text'] ?? '')); - if ($text !== '') { - $messages[] = ['role' => $role, 'content' => $text]; - } - } - $messages[] = ['role' => 'user', 'content' => $message]; - - return $provider === 'anthropic' - ? $this->viaAnthropic($model, $apiKey, $messages) - : $this->viaOpenAi($model, $apiKey, $messages); - } - - private function viaOpenAi(string $model, string $apiKey, array $messages): string - { - $res = Http::withToken($apiKey)->acceptJson()->timeout(45) - ->post('https://api.openai.com/v1/chat/completions', [ - 'model' => $model, - 'temperature' => 0.3, - 'max_tokens' => 600, - 'messages' => $messages, - ]); - - if ($res->failed()) { - throw new RuntimeException('OpenAI request failed: '.$res->status()); - } - - return trim((string) $res->json('choices.0.message.content', '')); - } - - private function viaAnthropic(string $model, string $apiKey, array $messages): string - { - $system = $messages[0]['content']; - $turns = array_values(array_filter($messages, fn ($m) => $m['role'] !== 'system')); - - $res = Http::withHeaders([ - 'x-api-key' => $apiKey, - 'anthropic-version' => '2023-06-01', - ])->acceptJson()->timeout(45)->post('https://api.anthropic.com/v1/messages', [ - 'model' => $model, - 'max_tokens' => 600, - 'system' => $system, - 'messages' => array_map(fn ($m) => ['role' => $m['role'], 'content' => $m['content']], $turns), - ]); - - if ($res->failed()) { - throw new RuntimeException('Anthropic request failed: '.$res->status()); - } - - return trim((string) $res->json('content.0.text', '')); - } - - /** @param array $context */ - private function systemPrompt(array $context): string - { - $ctx = collect($context)->map(fn ($v, $k) => "- {$k}: {$v}")->implode("\n"); - - return $this->miniSystemPrompt($ctx); - } - - private function miniSystemPrompt(string $ctx): string - { - return <<getOrCreateQrSetting(); - $thresholdMinor = $settings->auto_withdraw_amount_minor; - - if ($thresholdMinor === null || $thresholdMinor < 100) { - return false; - } - - try { - $balanceMinor = $this->billing->balanceMinor($user->public_id); - } catch (Throwable $e) { - Log::warning('Auto-withdraw balance check failed', [ - 'user' => $user->public_id, - 'error' => $e->getMessage(), - ]); - - return false; - } - - if ($balanceMinor < $thresholdMinor) { - return false; - } - - if (! $this->hasPayoutAccount($user)) { - return false; - } - - if ($this->hasPendingWithdrawal($user)) { - return false; - } - - $amountMajor = round($balanceMinor / 100, 2); - if ($amountMajor < 1) { - return false; - } - - try { - $response = $this->identitySend('POST', '/api/identity/wallet/withdraw', [ - 'user' => $user->public_id, - 'amount' => $amountMajor, - ]); - - if (! $response->successful()) { - Log::warning('Auto-withdraw failed', [ - 'user' => $user->public_id, - 'status' => $response->status(), - 'body' => $response->body(), - ]); - - return false; - } - - $this->notifications->withdrawalSubmitted($user, $amountMajor); - - return true; - } catch (Throwable $e) { - Log::warning('Auto-withdraw exception', [ - 'user' => $user->public_id, - 'error' => $e->getMessage(), - ]); - - return false; - } - } - - public function processAll(): int - { - $count = 0; - - QrSetting::query() - ->whereNotNull('auto_withdraw_amount_minor') - ->where('auto_withdraw_amount_minor', '>=', 100) - ->with('user') - ->chunkById(50, function ($settings) use (&$count) { - foreach ($settings as $setting) { - $user = $setting->user; - if ($user && $this->attemptForUser($user)) { - $count++; - } - } - }); - - return $count; - } - - private function hasPayoutAccount(User $user): bool - { - $response = $this->identitySend( - 'GET', - '/api/identity/payout-account?user='.urlencode((string) $user->public_id), - [], - ); - - if (! $response->successful()) { - return false; - } - - return filled($response->json('data.payout_account.account_number')); - } - - private function hasPendingWithdrawal(User $user): bool - { - $response = $this->identitySend( - 'GET', - '/api/identity/wallet/withdrawals?user='.urlencode((string) $user->public_id), - [], - ); - - if (! $response->successful()) { - return false; - } - - return collect($response->json('data', [])) - ->contains(fn ($withdrawal) => in_array($withdrawal['status'] ?? '', ['pending', 'processing'], true)); - } - - private function identitySend(string $method, string $path, array $payload): HttpResponse - { - return Http::baseUrl(rtrim((string) config('services.ladill_identity.url'), '/')) - ->withToken((string) config('services.ladill_identity.key')) - ->connectTimeout(10) - ->timeout(20) - ->acceptJson() - ->asJson() - ->send($method, $path, ['json' => $payload]); - } -} diff --git a/app/Services/Mini/MiniPaymentService.php b/app/Services/Mini/MiniPaymentService.php deleted file mode 100644 index 0e371ce..0000000 --- a/app/Services/Mini/MiniPaymentService.php +++ /dev/null @@ -1,198 +0,0 @@ -type !== QrCode::TYPE_PAYMENT) { - throw new RuntimeException('This QR is not a payment code.'); - } - - $amountGhs = round((float) ($data['amount'] ?? 0), 2); - if ($amountGhs <= 0) { - throw new RuntimeException('Enter an amount greater than zero.'); - } - - $qrCode->loadMissing('user'); - $amountMinor = (int) round($amountGhs * 100); - $reference = 'MINP-'.strtoupper(Str::random(16)); - $businessName = $qrCode->content()['business_name'] ?? $qrCode->label ?? 'Payment'; - - $payment = MiniPayment::create([ - 'qr_code_id' => $qrCode->id, - 'user_id' => $qrCode->user_id, - 'reference' => $reference, - 'amount_minor' => $amountMinor, - 'currency' => $qrCode->content()['currency'] ?? 'GHS', - 'payer_name' => null, - 'payer_email' => null, - 'payer_phone' => null, - 'payer_note' => null, - 'status' => MiniPayment::STATUS_PENDING, - 'payment_reference' => null, - ]); - - $payOrder = $this->pay->createCheckout([ - 'merchant' => $qrCode->user->public_id, - 'fee_tier' => 'payments', - 'source_service' => 'mini', - 'source_ref' => (string) $qrCode->id, - 'callback_url' => route('qr.public.payment.callback', ['shortCode' => $qrCode->short_code]), - 'line_items' => [ - [ - 'name' => $businessName, - 'unit_price_minor' => $amountMinor, - 'quantity' => 1, - ], - ], - 'metadata' => [ - 'mini_payment_id' => $payment->id, - 'mini_reference' => $reference, - 'qr_code_id' => $qrCode->id, - ], - ]); - - $payment->update([ - 'pay_order_id' => $payOrder['id'] ?? null, - 'payment_reference' => $payOrder['reference'], - 'platform_fee_minor' => $payOrder['platform_fee_minor'] ?? null, - 'merchant_amount_minor' => $payOrder['merchant_amount_minor'] ?? null, - ]); - - $checkoutUrl = (string) ($payOrder['checkout_url'] ?? ''); - if ($checkoutUrl === '') { - throw new RuntimeException('Could not start checkout. Please try again.'); - } - - return [ - 'payment' => $payment->fresh(), - 'checkout_url' => $checkoutUrl, - ]; - } - - public function complete(string $paymentReference): MiniPayment - { - if (str_starts_with($paymentReference, 'LP-')) { - return $this->completeLadillPay($paymentReference); - } - - return $this->completeLegacy($paymentReference); - } - - private function completeLadillPay(string $reference): MiniPayment - { - $payment = MiniPayment::where('payment_reference', $reference) - ->where('status', MiniPayment::STATUS_PENDING) - ->firstOrFail(); - - $payOrder = $this->pay->verify($reference); - - $payment->update([ - 'status' => MiniPayment::STATUS_PAID, - 'amount_minor' => (int) ($payOrder['amount_minor'] ?? $payment->amount_minor), - 'platform_fee_minor' => (int) ($payOrder['platform_fee_minor'] ?? 0), - 'merchant_amount_minor' => (int) ($payOrder['merchant_amount_minor'] ?? 0), - 'pay_order_id' => $payOrder['id'] ?? $payment->pay_order_id, - 'paid_at' => now(), - 'metadata' => array_merge((array) $payment->metadata, ['ladill_pay' => $payOrder]), - ]); - - $payment = $payment->fresh(['qrCode', 'merchant']); - $this->notifyPayer($payment); - $this->notifications->paymentReceived($payment); - $this->autoWithdraw->attemptForUser($payment->merchant); - - return $payment; - } - - /** Legacy MIN-* references before Ladill Pay migration. */ - private function completeLegacy(string $paymentReference): MiniPayment - { - $payment = MiniPayment::where('payment_reference', $paymentReference) - ->where('status', MiniPayment::STATUS_PENDING) - ->firstOrFail(); - - $data = $this->paystack->verifyTransaction($paymentReference); - - if (($data['status'] ?? '') !== 'success') { - $payment->update(['status' => MiniPayment::STATUS_FAILED]); - throw new RuntimeException('Payment was not successful.'); - } - - $paidMinor = (int) ($data['amount'] ?? $payment->amount_minor); - $platformFeeMinor = (int) round($paidMinor * MiniPayment::PLATFORM_FEE_RATE); - $merchantMinor = $paidMinor - $platformFeeMinor; - - $payment->update([ - 'status' => MiniPayment::STATUS_PAID, - 'amount_minor' => $paidMinor, - 'platform_fee_minor' => $platformFeeMinor, - 'merchant_amount_minor' => $merchantMinor, - 'paid_at' => now(), - 'metadata' => array_merge((array) $payment->metadata, ['paystack' => $data, 'legacy' => true]), - ]); - - $businessName = $payment->qrCode?->content()['business_name'] ?? $payment->qrCode?->label ?? 'Payment QR'; - $this->billing->credit( - $payment->merchant->public_id, - $merchantMinor, - 'mini', - 'pay', - $paymentReference, - $payment->id, - sprintf('Payment via %s', $businessName), - ); - - $payment = $payment->fresh(['qrCode', 'merchant']); - $this->notifyPayer($payment); - $this->notifications->paymentReceived($payment); - $this->autoWithdraw->attemptForUser($payment->merchant); - - return $payment; - } - - private function notifyPayer(MiniPayment $payment): void - { - if (! $payment->payer_phone) { - return; - } - - $businessName = $payment->qrCode?->content()['business_name'] ?? $payment->qrCode?->label ?? 'Payment QR'; - - $this->sms->send( - $payment->payer_phone, - sprintf( - 'Payment of %s %s to %s confirmed. Ref: %s', - $payment->currency, - number_format($payment->amount_minor / 100, 2), - $businessName, - $payment->reference - ) - ); - } -} diff --git a/app/Services/Notifications/FcmService.php b/app/Services/Notifications/FcmService.php deleted file mode 100644 index ea3e2ea..0000000 --- a/app/Services/Notifications/FcmService.php +++ /dev/null @@ -1,151 +0,0 @@ -isConfigured()) { - return self::FAILED; - } - - $projectId = (string) config('services.fcm.project_id'); - $accessToken = $this->accessToken(); - - $response = Http::withToken($accessToken) - ->timeout(15) - ->post("https://fcm.googleapis.com/v1/projects/{$projectId}/messages:send", [ - 'message' => [ - 'token' => $fcmToken, - 'notification' => compact('title', 'body'), - 'data' => array_map('strval', $data), - 'android' => [ - 'priority' => 'high', - 'notification' => [ - 'sound' => 'default', - 'channel_id' => 'payments', - ], - ], - ], - ]); - - if ($response->successful()) { - return self::DELIVERED; - } - - $outcome = $this->classifyFailure($response); - - Log::warning('FCM push failed', [ - 'token' => substr($fcmToken, 0, 20).'…', - 'status' => $response->status(), - 'outcome' => $outcome, - 'body' => $response->body(), - ]); - - return $outcome; - } - - /** - * @return self::INVALID_TOKEN|self::FAILED - */ - private function classifyFailure(Response $response): string - { - $statusCode = $response->status(); - $body = strtolower((string) $response->body()); - $apiStatus = strtoupper((string) data_get($response->json(), 'error.status', '')); - - if ($statusCode === 404 - || $apiStatus === 'NOT_FOUND' - || str_contains($body, 'not_found') - || str_contains($body, 'requested entity was not found') - || str_contains($body, 'registration token is not a valid') - || str_contains($body, 'invalid registration') - || str_contains($body, 'unregistered')) { - return self::INVALID_TOKEN; - } - - return self::FAILED; - } - - private function accessToken(): string - { - return Cache::remember('mini_fcm_access_token', 3300, function (): string { - $sa = $this->serviceAccount(); - $jwt = $this->buildJwt($sa); - - $response = Http::asForm()->post('https://oauth2.googleapis.com/token', [ - 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', - 'assertion' => $jwt, - ]); - - if (! $response->successful()) { - throw new \RuntimeException('FCM OAuth2 token exchange failed: '.$response->body()); - } - - return $response->json('access_token'); - }); - } - - private function buildJwt(array $sa): string - { - $header = $this->base64url(json_encode(['alg' => 'RS256', 'typ' => 'JWT'])); - $now = time(); - $payload = $this->base64url(json_encode([ - 'iss' => $sa['client_email'], - 'scope' => 'https://www.googleapis.com/auth/firebase.messaging', - 'aud' => 'https://oauth2.googleapis.com/token', - 'iat' => $now, - 'exp' => $now + 3600, - ])); - - $message = "{$header}.{$payload}"; - openssl_sign($message, $signature, $sa['private_key'], 'sha256WithRSAEncryption'); - - return "{$message}.{$this->base64url($signature)}"; - } - - private function base64url(string $data): string - { - return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); - } - - /** @return array */ - private function serviceAccount(): array - { - $value = config('services.fcm.service_account_json'); - - if (blank($value)) { - throw new \RuntimeException('FCM service account JSON is not configured.'); - } - - $json = is_file($value) ? file_get_contents($value) : $value; - $decoded = json_decode((string) $json, true); - - if (! is_array($decoded) || empty($decoded['private_key'])) { - throw new \RuntimeException('FCM service account JSON is invalid or missing private_key.'); - } - - return $decoded; - } -} diff --git a/app/Services/Notifications/MiniNotificationService.php b/app/Services/Notifications/MiniNotificationService.php deleted file mode 100644 index ac89daf..0000000 --- a/app/Services/Notifications/MiniNotificationService.php +++ /dev/null @@ -1,177 +0,0 @@ -loadMissing(['qrCode', 'merchant']); - $merchant = $payment->merchant; - - if (! $merchant) { - return; - } - - $businessName = $payment->qrCode?->content()['business_name'] ?? $payment->qrCode?->label ?? 'Payment QR'; - $amount = $this->formatMoney($payment->currency, $payment->merchant_amount_minor ?: $payment->amount_minor); - $title = 'Payment received'; - $message = sprintf('%s paid %s to %s.', $this->payerLabel($payment), $amount, $businessName); - - $this->alert( - $merchant, - $title, - $message, - 'payment', - route('mini.payments.index'), - 'payment_received', - [ - 'payment_id' => $payment->id, - 'reference' => $payment->reference, - 'amount_minor' => $payment->amount_minor, - 'currency' => $payment->currency, - ], - respectPayoutPref: false, - ); - } - - public function withdrawalSubmitted(User $user, float $amountMajor, string $currency = 'GHS'): void - { - if (! $this->payoutAlertsEnabled($user)) { - return; - } - - $amount = sprintf('%s %s', $currency, number_format($amountMajor, 2)); - $this->alert( - $user, - 'Withdrawal requested', - sprintf('Your withdrawal of %s has been submitted and is being processed.', $amount), - 'payout', - route('mini.payouts'), - 'withdrawal_submitted', - ['amount_major' => $amountMajor, 'currency' => $currency], - respectPayoutPref: false, - ); - } - - public function walletCredited(User $user, int $amountMinor, string $currency, string $description): void - { - if (! $this->payoutAlertsEnabled($user)) { - return; - } - - $amount = $this->formatMoney($currency, $amountMinor); - $this->alert( - $user, - 'Wallet credited', - sprintf('%s added to your wallet. %s', $amount, $description), - 'wallet', - route('mini.payouts'), - 'wallet_credited', - ['amount_minor' => $amountMinor, 'currency' => $currency], - respectPayoutPref: false, - ); - } - - /** - * @param array $extra - */ - private function alert( - User $user, - string $title, - string $message, - string $icon, - ?string $url, - string $milestone, - array $extra = [], - bool $respectPayoutPref = true, - ): void { - if ($respectPayoutPref && ! $this->payoutAlertsEnabled($user)) { - return; - } - - $user->notify(new MiniAlertNotification($title, $message, $icon, $url, $milestone, $extra)); - $this->pushToDevices($user, $title, $message, array_merge($extra, [ - 'milestone' => $milestone, - 'url' => $url, - ])); - } - - /** @param array $data */ - private function pushToDevices(User $user, string $title, string $body, array $data = []): void - { - if (! $this->fcm->isConfigured() || ! $this->shouldAttemptFcm($user)) { - return; - } - - $windowDays = (int) config('notifications.fcm_active_user_within_days', 60); - $cutoff = now()->subDays($windowDays); - - $tokens = $user->pushTokens() - ->where(function ($query) use ($cutoff) { - $query->where('last_seen_at', '>=', $cutoff) - ->orWhere('updated_at', '>=', $cutoff); - }) - ->get(); - - foreach ($tokens as $device) { - try { - $outcome = $this->fcm->send($device->token, $title, $body, $data); - - if ($outcome === FcmService::INVALID_TOKEN) { - $device->delete(); - } - } catch (\Throwable $e) { - Log::warning('Mini push failed', [ - 'user_id' => $user->id, - 'error' => $e->getMessage(), - ]); - } - } - } - - private function shouldAttemptFcm(User $user): bool - { - if ($user->pushTokens()->doesntExist()) { - return false; - } - - $windowDays = (int) config('notifications.fcm_active_user_within_days', 60); - - if ($user->last_app_active_at === null) { - return false; - } - - return $user->last_app_active_at->gte(now()->subDays($windowDays)); - } - - private function payoutAlertsEnabled(User $user): bool - { - return (bool) ($user->getOrCreateQrSetting()->notify_payouts ?? true); - } - - private function formatMoney(string $currency, int $amountMinor): string - { - return sprintf('%s %s', $currency, number_format($amountMinor / 100, 2)); - } - - private function payerLabel(MiniPayment $payment): string - { - if ($payment->payer_name) { - return $payment->payer_name; - } - - if ($payment->payer_phone) { - return $payment->payer_phone; - } - - return 'A customer'; - } -} diff --git a/app/Services/Qr/QrAnalyticsService.php b/app/Services/Qr/QrAnalyticsService.php deleted file mode 100644 index 2a51a9d..0000000 --- a/app/Services/Qr/QrAnalyticsService.php +++ /dev/null @@ -1,91 +0,0 @@ - - */ - 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 - */ - 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 - */ - 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 - */ - public function recentScans(QrCode $qrCode, int $limit = 20) - { - return QrScanEvent::query() - ->where('qr_code_id', $qrCode->id) - ->latest('scanned_at') - ->limit($limit) - ->get(); - } -} diff --git a/app/Services/Qr/QrCodeManagerService.php b/app/Services/Qr/QrCodeManagerService.php deleted file mode 100644 index 62b07f4..0000000 --- a/app/Services/Qr/QrCodeManagerService.php +++ /dev/null @@ -1,556 +0,0 @@ -qrWallet()->firstOrCreate( - [], - ['credit_balance' => 0, 'status' => QrWallet::STATUS_ACTIVE], - ); - } - - /** - * @param array $data - */ - public function create(User $user, array $data): QrCode - { - $wallet = $this->walletFor($user); - $type = (string) ($data['type'] ?? ''); - - if ($type !== QrCode::TYPE_PAYMENT && ! $wallet->canCreateQr()) { - throw new RuntimeException('Add at least GHS ' . number_format(QrWallet::pricePerQr(), 2) . ' to your QR wallet before creating codes.'); - } - - $validated = $this->payloadValidator->validateForCreate($type, $data); - $style = $type === QrCode::TYPE_PAYMENT - ? QrStyleDefaults::defaults() - : 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 ($type === QrCode::TYPE_PAYMENT && ($data['payment_logo'] ?? null) instanceof UploadedFile) { - $content['logo_path'] = $this->storeMenuBrandImage($user, $data['payment_logo'], 'payment-logos'); - } - - if ($style['logo_path'] === null && ($data['logo'] ?? null) instanceof UploadedFile && $type !== QrCode::TYPE_PAYMENT) { - $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(), - ]); - - if ($type !== QrCode::TYPE_PAYMENT) { - $this->billing->debitForQrCreation($wallet, $qrCode); - } - $this->imageGenerator->generateAndStore($qrCode); - - return $qrCode->fresh(['document']); - }); - } - - /** - * @param array $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_PAYMENT && ($data['payment_logo'] ?? null) instanceof UploadedFile) { - $content['logo_path'] = $this->storeMenuBrandImage($qrCode->user, $data['payment_logo'], 'payment-logos'); - } - - 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 ($qrCode->type === QrCode::TYPE_PAYMENT) { - $style = QrStyleDefaults::defaults(); - } else { - 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 $sections - * @param array $data - * @return array - */ - 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 $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'], - QrCode::TYPE_PAYMENT => ['business_name', 'branch_label', 'currency'], - 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 $data - * @return list - */ - 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.'); - } - - public function delete(QrCode $qrCode): void - { - $paths = array_filter([$qrCode->png_path, $qrCode->svg_path]); - - $logoPath = $qrCode->content()['logo_path'] ?? null; - if (is_string($logoPath) && $logoPath !== '') { - $paths[] = $logoPath; - } - - foreach ($paths as $path) { - Storage::disk('qr')->delete($path); - } - - $qrCode->delete(); - } -} diff --git a/app/Services/Qr/QrImageGeneratorService.php b/app/Services/Qr/QrImageGeneratorService.php deleted file mode 100644 index 7dfa2e8..0000000 --- a/app/Services/Qr/QrImageGeneratorService.php +++ /dev/null @@ -1,488 +0,0 @@ -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 $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 $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 $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 */ - 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 $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('/]/i', $svg)) { - return null; - } - - // Drop XML declaration / doctype. - $svg = preg_replace('/<\?xml.*?\?>/is', '', $svg); - $svg = preg_replace('//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('/])*(?:xlink:)?href\s*=\s*"(?!data:)[^"]*"[^>]*>/is', '', $svg); - - $svg = trim($svg); - if (! str_contains($svg, ']*>/i', $svg, $m)) { - $open = $m[0]; - $fixed = $open; - if (! preg_match('/\sxmlns\s*=/i', $fixed)) { - $fixed = preg_replace('/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; - } -} diff --git a/app/Services/Qr/QrPayloadValidator.php b/app/Services/Qr/QrPayloadValidator.php deleted file mode 100644 index fbfc8c9..0000000 --- a/app/Services/Qr/QrPayloadValidator.php +++ /dev/null @@ -1,663 +0,0 @@ - $input - * @return array{content: array, 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), - QrCode::TYPE_PAYMENT => $this->validatePayment($input), - default => throw new RuntimeException('Unsupported QR type.'), - }; - } - - /** - * @param array $input - * @return array{content: array, 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 $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 $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 $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 $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 $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 $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 $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 $tiers */ - private function eventHasPaidTier(array $tiers): bool - { - foreach ($tiers as $tier) { - if ((float) ($tier['price'] ?? 0) > 0) { - return true; - } - } - - return false; - } - - /** @param array $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 $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 $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 $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 $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 $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 */ - 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}> - */ - 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.'); - } - } - - /** @param array $input */ - private function validatePayment(array $input): array - { - $businessName = trim((string) ($input['business_name'] ?? '')); - if ($businessName === '') { - throw new RuntimeException('Enter your business or display name.'); - } - - return [ - 'content' => [ - 'business_name' => mb_substr($businessName, 0, 120), - 'branch_label' => mb_substr(trim((string) ($input['branch_label'] ?? '')), 0, 80) ?: null, - 'currency' => strtoupper(trim((string) ($input['currency'] ?? 'GHS'))) ?: 'GHS', - ], - 'destination_url' => null, - ]; - } -} diff --git a/app/Services/Qr/QrPdfExporter.php b/app/Services/Qr/QrPdfExporter.php deleted file mode 100644 index dc2e6cc..0000000 --- a/app/Services/Qr/QrPdfExporter.php +++ /dev/null @@ -1,93 +0,0 @@ -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); - } -} diff --git a/app/Services/Qr/QrScanRecorder.php b/app/Services/Qr/QrScanRecorder.php deleted file mode 100644 index c3f381d..0000000 --- a/app/Services/Qr/QrScanRecorder.php +++ /dev/null @@ -1,89 +0,0 @@ -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, - ]; - } -} diff --git a/app/Services/Qr/QrWalletBillingService.php b/app/Services/Qr/QrWalletBillingService.php deleted file mode 100644 index 5969f3d..0000000 --- a/app/Services/Qr/QrWalletBillingService.php +++ /dev/null @@ -1,74 +0,0 @@ -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], - ]); - }); - } -} diff --git a/app/Support/Events/EventBadgeZpl.php b/app/Support/Events/EventBadgeZpl.php deleted file mode 100644 index e98a10e..0000000 --- a/app/Support/Events/EventBadgeZpl.php +++ /dev/null @@ -1,70 +0,0 @@ - $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) ?? ''); - } -} diff --git a/app/Support/Qr/EventBadgeZpl.php b/app/Support/Qr/EventBadgeZpl.php deleted file mode 100644 index 69929f0..0000000 --- a/app/Support/Qr/EventBadgeZpl.php +++ /dev/null @@ -1,70 +0,0 @@ - $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) ?? ''); - } -} diff --git a/app/Support/Qr/QrCornerStyleCatalog.php b/app/Support/Qr/QrCornerStyleCatalog.php deleted file mode 100644 index 0e57264..0000000 --- a/app/Support/Qr/QrCornerStyleCatalog.php +++ /dev/null @@ -1,36 +0,0 @@ - */ - public static function outerStyles(): array - { - return [ - 'square' => ['label' => 'Square eye'], - 'rounded' => ['label' => 'Rounded'], - 'circle' => ['label' => 'Circular'], - ]; - } - - /** @return array */ - 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]); - } -} diff --git a/app/Support/Qr/QrCoverImageSpec.php b/app/Support/Qr/QrCoverImageSpec.php deleted file mode 100644 index aa1af9d..0000000 --- a/app/Support/Qr/QrCoverImageSpec.php +++ /dev/null @@ -1,20 +0,0 @@ - self::BOOK, - default => self::BANNER, - }; - } -} diff --git a/app/Support/Qr/QrDateFormatter.php b/app/Support/Qr/QrDateFormatter.php deleted file mode 100644 index f672ba4..0000000 --- a/app/Support/Qr/QrDateFormatter.php +++ /dev/null @@ -1,55 +0,0 @@ -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); - } - } -} diff --git a/app/Support/Qr/QrFrameStyleCatalog.php b/app/Support/Qr/QrFrameStyleCatalog.php deleted file mode 100644 index aa6d660..0000000 --- a/app/Support/Qr/QrFrameStyleCatalog.php +++ /dev/null @@ -1,63 +0,0 @@ - */ - 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 */ - public static function visible(): array - { - return array_filter(self::all(), fn (array $style): bool => ($style['visible'] ?? true) === true); - } - - /** @return list */ - public static function keys(): array - { - return array_keys(self::all()); - } - - public static function isValid(string $style): bool - { - return isset(self::all()[$style]); - } -} diff --git a/app/Support/Qr/QrModuleStyleCatalog.php b/app/Support/Qr/QrModuleStyleCatalog.php deleted file mode 100644 index 8d0e166..0000000 --- a/app/Support/Qr/QrModuleStyleCatalog.php +++ /dev/null @@ -1,93 +0,0 @@ - */ - 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 */ - public static function visible(): array - { - return array_filter(self::all(), fn (array $style): bool => ($style['visible'] ?? true) === true); - } - - /** @return list */ - public static function keys(): array - { - return array_keys(self::all()); - } - - public static function isValid(string $style): bool - { - return isset(self::all()[$style]); - } - - /** @return array */ - 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); - } -} diff --git a/app/Support/Qr/QrScanReliability.php b/app/Support/Qr/QrScanReliability.php deleted file mode 100644 index 9a56cd6..0000000 --- a/app/Support/Qr/QrScanReliability.php +++ /dev/null @@ -1,102 +0,0 @@ - $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 $style - * @return array{level: string, messages: list} - */ - 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 $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]); - } -} diff --git a/app/Support/Qr/QrStyleDefaults.php b/app/Support/Qr/QrStyleDefaults.php deleted file mode 100644 index c0dd045..0000000 --- a/app/Support/Qr/QrStyleDefaults.php +++ /dev/null @@ -1,114 +0,0 @@ - */ - 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|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|null $style */ - public static function mergeForRender(?array $style): array - { - return QrStyleNormalizer::normalize(self::merge($style)); - } - - /** @param array $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; - } -} diff --git a/app/Support/Qr/QrStyleNormalizer.php b/app/Support/Qr/QrStyleNormalizer.php deleted file mode 100644 index 5742b56..0000000 --- a/app/Support/Qr/QrStyleNormalizer.php +++ /dev/null @@ -1,138 +0,0 @@ - $style - * @return array - */ - 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 $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 $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 $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]); - } -} diff --git a/app/Support/Qr/QrTypeCatalog.php b/app/Support/Qr/QrTypeCatalog.php deleted file mode 100644 index 7fd0c21..0000000 --- a/app/Support/Qr/QrTypeCatalog.php +++ /dev/null @@ -1,58 +0,0 @@ - */ - public static function paymentTypes(): array - { - return [QrCode::TYPE_PAYMENT]; - } - - /** @return array */ - public static function all(): array - { - return [ - QrCode::TYPE_PAYMENT => [ - 'label' => 'Payment QR', - 'description' => 'A static QR customers scan to pay you any amount', - 'category' => 'payments', - 'icon' => 'payment.svg', - ], - ]; - } - - /** @return list */ - 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; - } -} diff --git a/app/Support/Qr/QrWifiPayload.php b/app/Support/Qr/QrWifiPayload.php deleted file mode 100644 index e6b4180..0000000 --- a/app/Support/Qr/QrWifiPayload.php +++ /dev/null @@ -1,45 +0,0 @@ - $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 - ); - } -} diff --git a/database/migrations/2026_05_28_200000_create_qr_product_tables.php b/database/migrations/2026_05_28_200000_create_qr_product_tables.php deleted file mode 100644 index 20b2fd0..0000000 --- a/database/migrations/2026_05_28_200000_create_qr_product_tables.php +++ /dev/null @@ -1,101 +0,0 @@ -id(); - $table->foreignId('user_id')->unique()->constrained()->cascadeOnDelete(); - $table->decimal('credit_balance', 10, 4)->default(0); - $table->unsignedInteger('qr_codes_total')->default(0); - $table->unsignedBigInteger('scans_total')->default(0); - $table->string('status', 20)->default('active'); - $table->timestamps(); - }); - - Schema::create('qr_documents', function (Blueprint $table) { - $table->id(); - $table->foreignId('user_id')->constrained()->cascadeOnDelete(); - $table->string('title')->nullable(); - $table->string('disk', 32)->default('qr'); - $table->string('path'); - $table->string('mime_type', 128)->default('application/pdf'); - $table->unsignedBigInteger('size_bytes')->default(0); - $table->unsignedSmallInteger('page_count')->nullable(); - $table->timestamps(); - - $table->index(['user_id', 'created_at']); - }); - - Schema::create('qr_codes', function (Blueprint $table) { - $table->id(); - $table->foreignId('user_id')->constrained()->cascadeOnDelete(); - $table->string('short_code', 16)->unique(); - $table->string('type', 32); - $table->string('label'); - $table->text('destination_url')->nullable(); - $table->foreignId('qr_document_id')->nullable()->constrained('qr_documents')->nullOnDelete(); - $table->json('payload')->nullable(); - $table->boolean('is_active')->default(true); - $table->string('png_path')->nullable(); - $table->string('svg_path')->nullable(); - $table->unsignedBigInteger('scans_total')->default(0); - $table->unsignedBigInteger('unique_scans_total')->default(0); - $table->timestamp('last_scanned_at')->nullable(); - $table->timestamp('destination_updated_at')->nullable(); - $table->timestamps(); - - $table->index(['user_id', 'created_at']); - $table->index(['user_id', 'is_active']); - }); - - Schema::create('qr_transactions', function (Blueprint $table) { - $table->id(); - $table->foreignId('user_id')->constrained()->cascadeOnDelete(); - $table->foreignId('qr_wallet_id')->constrained()->cascadeOnDelete(); - $table->foreignId('qr_code_id')->nullable()->constrained()->nullOnDelete(); - $table->string('type', 16); - $table->decimal('amount_ghs', 10, 4); - $table->decimal('balance_after_ghs', 10, 4); - $table->string('reference')->nullable()->index(); - $table->string('status', 20)->default('completed'); - $table->string('description')->nullable(); - $table->json('metadata')->nullable(); - $table->timestamps(); - - $table->index(['qr_wallet_id', 'created_at']); - }); - - Schema::create('qr_scan_events', function (Blueprint $table) { - $table->id(); - $table->foreignId('qr_code_id')->constrained()->cascadeOnDelete(); - $table->timestamp('scanned_at'); - $table->string('ip_hash', 64)->nullable(); - $table->text('user_agent')->nullable(); - $table->string('device_type', 32)->nullable(); - $table->string('browser', 64)->nullable(); - $table->string('os', 64)->nullable(); - $table->string('country_code', 8)->nullable(); - $table->string('referrer')->nullable(); - $table->boolean('is_unique')->default(false); - $table->timestamps(); - - $table->index(['qr_code_id', 'scanned_at']); - $table->index(['qr_code_id', 'ip_hash']); - }); - } - - public function down(): void - { - Schema::dropIfExists('qr_scan_events'); - Schema::dropIfExists('qr_transactions'); - Schema::dropIfExists('qr_codes'); - Schema::dropIfExists('qr_documents'); - Schema::dropIfExists('qr_wallets'); - } -}; diff --git a/database/migrations/2026_05_31_000001_widen_qr_codes_short_code.php b/database/migrations/2026_05_31_000001_widen_qr_codes_short_code.php deleted file mode 100644 index f84bb4a..0000000 --- a/database/migrations/2026_05_31_000001_widen_qr_codes_short_code.php +++ /dev/null @@ -1,22 +0,0 @@ -string('short_code', 32)->change(); - }); - } - - public function down(): void - { - Schema::table('qr_codes', function (Blueprint $table) { - $table->string('short_code', 16)->change(); - }); - } -}; diff --git a/database/migrations/2026_06_06_220000_create_qr_settings_table.php b/database/migrations/2026_06_06_220000_create_qr_settings_table.php deleted file mode 100644 index f8a8f2c..0000000 --- a/database/migrations/2026_06_06_220000_create_qr_settings_table.php +++ /dev/null @@ -1,27 +0,0 @@ -id(); - $table->foreignId('user_id')->unique()->constrained()->cascadeOnDelete(); - $table->string('notify_email')->nullable(); - $table->boolean('product_updates')->default(true); - $table->boolean('low_balance_alerts')->default(true); - $table->string('default_type', 32)->nullable(); - $table->json('default_style')->nullable(); - $table->timestamps(); - }); - } - - public function down(): void - { - Schema::dropIfExists('qr_settings'); - } -}; diff --git a/database/migrations/2026_06_07_200000_create_mini_payments_table.php b/database/migrations/2026_06_07_200000_create_mini_payments_table.php deleted file mode 100644 index eceae6c..0000000 --- a/database/migrations/2026_06_07_200000_create_mini_payments_table.php +++ /dev/null @@ -1,39 +0,0 @@ -id(); - $table->foreignId('qr_code_id')->constrained('qr_codes')->cascadeOnDelete(); - $table->foreignId('user_id')->constrained()->cascadeOnDelete(); - $table->string('reference', 32)->unique(); - $table->unsignedInteger('amount_minor'); - $table->string('currency', 3)->default('GHS'); - $table->unsignedInteger('platform_fee_minor')->default(0); - $table->unsignedInteger('merchant_amount_minor')->default(0); - $table->string('payer_name')->nullable(); - $table->string('payer_email')->nullable(); - $table->string('payer_phone', 32)->nullable(); - $table->string('payer_note')->nullable(); - $table->string('status', 16)->default('pending'); - $table->string('payment_reference', 64)->nullable()->unique(); - $table->timestamp('paid_at')->nullable(); - $table->json('metadata')->nullable(); - $table->timestamps(); - - $table->index(['user_id', 'status', 'paid_at']); - $table->index(['qr_code_id', 'status']); - }); - } - - public function down(): void - { - Schema::dropIfExists('mini_payments'); - } -}; diff --git a/database/migrations/2026_06_08_130000_add_pay_order_id_to_mini_payments_table.php b/database/migrations/2026_06_08_130000_add_pay_order_id_to_mini_payments_table.php deleted file mode 100644 index 13696bb..0000000 --- a/database/migrations/2026_06_08_130000_add_pay_order_id_to_mini_payments_table.php +++ /dev/null @@ -1,22 +0,0 @@ -unsignedBigInteger('pay_order_id')->nullable()->after('id'); - }); - } - - public function down(): void - { - Schema::table('mini_payments', function (Blueprint $table) { - $table->dropColumn('pay_order_id'); - }); - } -}; diff --git a/database/migrations/2026_06_11_120000_create_user_push_tokens_table.php b/database/migrations/2026_06_11_120000_create_user_push_tokens_table.php deleted file mode 100644 index d785369..0000000 --- a/database/migrations/2026_06_11_120000_create_user_push_tokens_table.php +++ /dev/null @@ -1,36 +0,0 @@ -id(); - $table->foreignId('user_id')->constrained()->cascadeOnDelete(); - $table->string('token', 512)->unique(); - $table->string('platform', 32)->default('android'); - $table->string('device_name')->nullable(); - $table->timestamp('last_seen_at')->nullable(); - $table->timestamps(); - - $table->index(['user_id', 'updated_at']); - }); - - Schema::table('users', function (Blueprint $table) { - $table->timestamp('last_app_active_at')->nullable()->after('remember_token'); - }); - } - - public function down(): void - { - Schema::table('users', function (Blueprint $table) { - $table->dropColumn('last_app_active_at'); - }); - - Schema::dropIfExists('user_push_tokens'); - } -}; diff --git a/database/migrations/2026_06_12_120000_add_auto_withdraw_to_qr_settings_table.php b/database/migrations/2026_06_12_120000_add_auto_withdraw_to_qr_settings_table.php deleted file mode 100644 index dac2564..0000000 --- a/database/migrations/2026_06_12_120000_add_auto_withdraw_to_qr_settings_table.php +++ /dev/null @@ -1,22 +0,0 @@ -unsignedBigInteger('auto_withdraw_amount_minor')->nullable(); - }); - } - - public function down(): void - { - Schema::table('qr_settings', function (Blueprint $table) { - $table->dropColumn('auto_withdraw_amount_minor'); - }); - } -}; diff --git a/resources/views/components/qr/cover-image-hint.blade.php b/resources/views/components/qr/cover-image-hint.blade.php deleted file mode 100644 index 9e67ed4..0000000 --- a/resources/views/components/qr/cover-image-hint.blade.php +++ /dev/null @@ -1,5 +0,0 @@ -@props(['variant' => 'banner']) - -

merge(['class' => 'mt-1 text-[10px] leading-snug text-slate-400']) }}> - Recommended: {{ \App\Support\Qr\QrCoverImageSpec::label($variant) }}. JPG, PNG, or WebP. -

diff --git a/resources/views/components/qr/customization-section.blade.php b/resources/views/components/qr/customization-section.blade.php deleted file mode 100644 index 9ecd8c7..0000000 --- a/resources/views/components/qr/customization-section.blade.php +++ /dev/null @@ -1,30 +0,0 @@ -@props([ - 'id', - 'title', - 'description', -]) - -
- - -
-
- {{ $slot }} -
-
-
diff --git a/resources/views/mini/dashboard.blade.php b/resources/views/mini/dashboard.blade.php deleted file mode 100644 index e0248eb..0000000 --- a/resources/views/mini/dashboard.blade.php +++ /dev/null @@ -1,68 +0,0 @@ - - Overview - @php $fmt = fn ($m) => 'GHS '.number_format($m / 100, 2); @endphp -
-
-

Overview

-

Today's takings, your payment QRs, and recent incoming payments.

-
-
-
-

Today's takings

-

{{ $fmt($todayMinor) }}

-
-
-

Payments today

-

{{ number_format($todayCount) }}

-
-
-

Payment QRs

-

{{ $paymentQrCount }}

-
-
-

Wallet balance

-

{{ $fmt($balanceMinor) }}

-
-
-
-
-
-

Recent payments

- View all -
- @if($recentPayments->isEmpty()) -

No payments yet. Print your payment QR and start accepting payments.

- @else -
- @foreach($recentPayments as $payment) -
-
-

{{ $payment->payer_name ?: 'Walk-in customer' }}

-

{{ $payment->qrCode?->label }} · {{ $payment->paid_at?->diffForHumans() }}

-
- {{ $fmt($payment->merchant_amount_minor) }} -
- @endforeach -
- @endif -
- -
-
-
diff --git a/resources/views/mini/payment-qrs/create.blade.php b/resources/views/mini/payment-qrs/create.blade.php deleted file mode 100644 index 5ec89a9..0000000 --- a/resources/views/mini/payment-qrs/create.blade.php +++ /dev/null @@ -1,48 +0,0 @@ - - Create Payment QR -
-
- - - - - - Ladill Mini - Create payment QR -
- - @if(session('error')) -
{{ session('error') }}
- @endif -
- @csrf -
- - -

Internal name — shown in your dashboard only.

-
-
- - -

Shown on the customer payment screen.

-
-
- - -
- -
-
-
diff --git a/resources/views/mini/payment-qrs/index.blade.php b/resources/views/mini/payment-qrs/index.blade.php deleted file mode 100644 index 2ff9122..0000000 --- a/resources/views/mini/payment-qrs/index.blade.php +++ /dev/null @@ -1,40 +0,0 @@ - - My Payment QR -
-
-
-

My Payment QR

-

Static QRs to print or display — one per till or branch.

-
- New payment QR -
- @if(session('success')) -
{{ session('success') }}
- @endif - @if($qrCodes->isEmpty()) -
-

No payment QRs yet.

- Create your first payment QR -
- @else - - @endif -
-
diff --git a/resources/views/mini/payment-qrs/partials/delete-modal.blade.php b/resources/views/mini/payment-qrs/partials/delete-modal.blade.php deleted file mode 100644 index 2fdb3e5..0000000 --- a/resources/views/mini/payment-qrs/partials/delete-modal.blade.php +++ /dev/null @@ -1,38 +0,0 @@ -@php - $businessName = $qrCode->content()['business_name'] ?? $qrCode->label; -@endphp - - -
-
-
- - - -
-

Delete payment QR?

-

- {{ $qrCode->label }} - @if($businessName !== $qrCode->label) - · {{ $businessName }} - @endif - will be removed permanently. The payment link will stop working and printed codes for this till will no longer accept payments. -

-

{{ $qrCode->publicUrl() }}

-
- -
- @csrf - @method('DELETE') - - -
-
-
diff --git a/resources/views/mini/payment-qrs/partials/header-actions.blade.php b/resources/views/mini/payment-qrs/partials/header-actions.blade.php deleted file mode 100644 index d03c0a8..0000000 --- a/resources/views/mini/payment-qrs/partials/header-actions.blade.php +++ /dev/null @@ -1,37 +0,0 @@ -@php - $shareUrl = $qrCode->publicUrl(); - $shareEnc = urlencode($shareUrl); - $shareText = urlencode($qrCode->label . ' — pay with QR'); -@endphp -
- PNG - SVG - PDF -
- -
- - WhatsApp - - -
-
-
diff --git a/resources/views/mini/payment-qrs/partials/preview-card.blade.php b/resources/views/mini/payment-qrs/partials/preview-card.blade.php deleted file mode 100644 index 36272ac..0000000 --- a/resources/views/mini/payment-qrs/partials/preview-card.blade.php +++ /dev/null @@ -1,35 +0,0 @@ -@php - $showDownloads = $showDownloads ?? true; -@endphp - -
-
-
-
- Payment QR code for {{ $qrCode->label }} -
-
-
- - @if ($showDownloads) -
-

Download

- -
- @endif -
diff --git a/resources/views/mini/payment-qrs/show.blade.php b/resources/views/mini/payment-qrs/show.blade.php deleted file mode 100644 index 5df9600..0000000 --- a/resources/views/mini/payment-qrs/show.blade.php +++ /dev/null @@ -1,81 +0,0 @@ - - {{ $qrCode->label }} - @php $c = $qrCode->content(); @endphp -
-
-
-
- - - - - - Ladill Mini - {{ $qrCode->label }} -
- - -
- - @include('mini.payment-qrs.partials.header-actions', ['qrCode' => $qrCode]) -
- - @if(session('success')) -
{{ session('success') }}
- @endif - -
-
- @include('mini.payment-qrs.partials.preview-card', [ - 'qrCode' => $qrCode, - 'previewDataUri' => $previewDataUri, - 'showDownloads' => false, - ]) -

{{ $qrCode->publicUrl() }}

-
- -
-
- @csrf - @method('PATCH') -

Settings

-
- - -
-
- - -
-
- - -
- - -
- -
-

Danger zone

-

Remove this payment QR and deactivate its link.

- -
- - @include('mini.payment-qrs.partials.delete-modal', ['qrCode' => $qrCode]) -
-
-
-
diff --git a/resources/views/mini/payments.blade.php b/resources/views/mini/payments.blade.php deleted file mode 100644 index 388dbbd..0000000 --- a/resources/views/mini/payments.blade.php +++ /dev/null @@ -1,54 +0,0 @@ - - Payments - @php $fmt = fn ($m) => 'GHS '.number_format($m / 100, 2); @endphp -
-
-

Payments

-

Live feed of incoming customer payments across all your payment QRs.

-
-
- @if($payments->isEmpty()) -

No payments yet.

- @else -
- - - - - - - - - - - - - @foreach($payments as $payment) - - - - - - - - - @endforeach - -
WhenPayerQR / tillNoteAmountStatus
{{ $payment->paid_at?->format('M j, g:i A') ?? $payment->created_at->format('M j, g:i A') }} -

{{ $payment->payer_name ?: 'Walk-in customer' }}

- @if($payment->payer_email) -

{{ $payment->payer_email }}

- @endif -
{{ $payment->qrCode?->label }}{{ $payment->payer_note ?: '—' }}{{ $fmt($payment->status === \App\Models\MiniPayment::STATUS_PAID ? $payment->merchant_amount_minor : $payment->amount_minor) }} - - {{ ucfirst($payment->status) }} - -
-
- @if($payments->hasPages()) -
{{ $payments->links() }}
- @endif - @endif -
-
-
diff --git a/resources/views/mini/payouts.blade.php b/resources/views/mini/payouts.blade.php deleted file mode 100644 index b995736..0000000 --- a/resources/views/mini/payouts.blade.php +++ /dev/null @@ -1,27 +0,0 @@ - - Payouts - @php $fmt = fn ($m) => 'GHS '.number_format($m / 100, 2); @endphp -
-
-

Payouts

-

Takings settle into your Ladill wallet (3.5% fee), then withdraw to bank or MoMo.

-
-
-
-

Total received (net)

-

{{ $fmt($revenueMinor) }}

-
-
-

Available in wallet

-

{{ $fmt($balanceMinor) }}

-
-
-
- Payments are collected through Ladill Pay and credited to your Ladill wallet. Withdraw to bank or MoMo from your account wallet — payout and withdrawal history is there. -
- - Open wallet & withdraw - - -
-
diff --git a/resources/views/mini/settings.blade.php b/resources/views/mini/settings.blade.php deleted file mode 100644 index 428f899..0000000 --- a/resources/views/mini/settings.blade.php +++ /dev/null @@ -1,69 +0,0 @@ - - Settings - -
-

Settings

-

Notification preferences for your payment QRs.

- - @if (session('success')) -
{{ session('success') }}
- @endif - -
- @csrf - @method('PUT') - -
-

Notifications

-

Choose what we email you about payments and your account.

- -
- - - @error('notify_email')

{{ $message }}

@enderror -
- - - - - - -
- - -
- -

- Profile, password, and security are managed on - account.ladill.com. -

-
-
diff --git a/resources/views/mini/signed-out.blade.php b/resources/views/mini/signed-out.blade.php deleted file mode 100644 index 10d87e7..0000000 --- a/resources/views/mini/signed-out.blade.php +++ /dev/null @@ -1 +0,0 @@ -@include('auth.signed-out') diff --git a/resources/views/public/qr/document-viewer.blade.php b/resources/views/public/qr/document-viewer.blade.php deleted file mode 100644 index b5f1845..0000000 --- a/resources/views/public/qr/document-viewer.blade.php +++ /dev/null @@ -1,178 +0,0 @@ - - - - - - @include('partials.favicon') - {{ $qrCode->label }} - - - - -
- {{ $qrCode->label }} - @if($allowDownload) - - - - - Download - - @else - - @endif -
- -
-
-
- Loading document… -
-
- -
- - - - diff --git a/resources/views/public/qr/event-confirmed.blade.php b/resources/views/public/qr/event-confirmed.blade.php deleted file mode 100644 index 222b28b..0000000 --- a/resources/views/public/qr/event-confirmed.blade.php +++ /dev/null @@ -1,73 +0,0 @@ -@php - $c = $qrCode->content(); - $evColor = $c['brand_color'] ?? '#4f46e5'; - $evName = $c['name'] ?? $qrCode->label; - $isContribution = ($c['mode'] ?? 'ticketing') === 'contributions'; - $badgeUrl = 'https://api.qrserver.com/v1/create-qr-code/?size=220x220&data=' . urlencode($registration->badge_code); -@endphp - - - - - - {{ $isContribution ? 'Thank you' : "You're registered" }} — {{ $evName }} - @include('partials.favicon') - - - -
-
-
-
- -
-

{{ $isContribution ? 'Thank you!' : "You're registered!" }}

-

{{ $evName }}

-
- -
- @if($isContribution) -

Hi {{ $registration->attendee_name }}, your contribution has been received.

- -
-

{{ $registration->tier_name }}

-

{{ $registration->currency }} {{ number_format($registration->amountCedis(), 2) }}

-

Reference

-

{{ $registration->badge_code }}

-
- - @if(($registration->badge_fields ?? [])) -
- @foreach(($registration->badge_fields ?? []) as $label => $value) -
{{ $label }}{{ $value }}
- @endforeach -
- @endif - -

A receipt was sent to {{ $registration->attendee_email }}.

- @else -

Hi {{ $registration->attendee_name }}, your spot is confirmed.

- -
- Badge QR -

Badge code

-

{{ $registration->badge_code }}

-
- -
-
Ticket{{ $registration->tier_name }}
- @if($registration->isPaid()) -
Paid{{ $registration->currency }} {{ number_format($registration->amountCedis(), 2) }}
- @endif - @foreach(($registration->badge_fields ?? []) as $label => $value) -
{{ $label }}{{ $value }}
- @endforeach -
- -

Show this badge code at check-in. A confirmation was sent to {{ $registration->attendee_email }}.

- @endif -
-
-
- - diff --git a/resources/views/public/qr/inactive.blade.php b/resources/views/public/qr/inactive.blade.php deleted file mode 100644 index 40e828e..0000000 --- a/resources/views/public/qr/inactive.blade.php +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - @include('partials.favicon') - QR code inactive - - - -
-

This QR code is not active

-

The owner has paused this link. Try again later.

-
- - diff --git a/resources/views/public/qr/landing.blade.php b/resources/views/public/qr/landing.blade.php deleted file mode 100644 index f4a6755..0000000 --- a/resources/views/public/qr/landing.blade.php +++ /dev/null @@ -1,1491 +0,0 @@ - - - - - - @include('partials.favicon') - {{ $qrCode->label }} - - @vite(['resources/css/app.css']) - - - -@php - $content = $qrCode->content(); - $type = $qrCode->type; -@endphp - -{{-- ===== PDF ===== --}} -@if($type === \App\Models\QrCode::TYPE_DOCUMENT) - - -{{-- ===== LIST OF LINKS ===== --}} -@elseif($type === \App\Models\QrCode::TYPE_LINK_LIST) -
-

{{ $qrCode->label }}

-
- @foreach($content['links'] ?? [] as $link) - - {{ $link['title'] }} - - - - - @endforeach -
-
- -{{-- ===== VCARD ===== --}} -@elseif($type === \App\Models\QrCode::TYPE_VCARD) -@php - $avatarUrl = !empty($content['avatar_path']) ? route('qr.public.vcard.avatar', $qrCode->short_code) : null; - $rawSocial = $content['social'] ?? []; - $social = []; - foreach ($rawSocial as $platform => $url) { - $social[$platform] = \App\Services\Qr\QrPayloadValidator::normalizeSocialUrl($platform, trim((string) $url)); - } - $fullName = trim(($content['first_name'] ?? '') . ' ' . ($content['last_name'] ?? '')); - $initials = strtoupper(substr($content['first_name'] ?? ($content['last_name'] ?? '?'), 0, 1)); - - $socialIcons = [ - 'linkedin' => 'linkedin.svg', - 'twitter' => 'twitter.svg', - 'instagram' => 'instagram.svg', - 'facebook' => 'facebook.svg', - 'tiktok' => 'tik-tok.svg', - 'youtube' => 'youtube.svg', - 'whatsapp' => 'whatsapp.svg', - 'snapchat' => 'snapchat.svg', - ]; -@endphp -
- - {{-- Colored header strip --}} -
- - {{-- Floating card --}} -
-
- - {{-- Avatar --}} -
- @if($avatarUrl) - {{ $fullName }} - @else -
- {{ $initials }} -
- @endif -
- - {{-- Name & company --}} -
-

{{ $fullName }}

- @if(!empty($content['company'])) -

{{ $content['company'] }}

- @endif -
- - {{-- Contact rows --}} -
- @if(!empty($content['phone'])) - - - {{ $content['phone'] }} - - @endif - @if(!empty($content['email'])) - - - {{ $content['email'] }} - - @endif - @if(!empty($content['website'])) - - - {{ $content['website'] }} - - @endif - @if(!empty($content['address'])) -
- - {{ $content['address'] }} -
- @endif - @if(!empty($content['note'])) -

{{ $content['note'] }}

- @endif -
- - {{-- Social links --}} - @if(!empty($social)) -
- @foreach($social as $platform => $url) - @if(isset($socialIcons[$platform])) - - {{ ucfirst($platform) }} - - @endif - @endforeach -
- @endif - - {{-- Save contact button --}} - - -
-
-
- -{{-- ===== BUSINESS ===== --}} -@elseif($type === \App\Models\QrCode::TYPE_BUSINESS) -@php - $bizName = $content['name'] ?? $qrCode->label; - $bizTagline = $content['tagline'] ?? ''; - $bizColor = $content['brand_color'] ?? '#1e3a5f'; - $bizHasLogo = !empty($content['logo_path']); - $bizHasCover = !empty($content['cover_path']); - $bizLogoUrl = $bizHasLogo ? route('qr.public.business.logo', $qrCode->short_code) : null; - $bizCoverUrl = $bizHasCover ? route('qr.public.business.cover', $qrCode->short_code) : null; - $bizInitials = strtoupper(substr($bizName, 0, 1)); - $bizRawSocial = $content['social'] ?? []; - $bizSocial = []; - foreach ($bizRawSocial as $platform => $url) { - $bizSocial[$platform] = \App\Services\Qr\QrPayloadValidator::normalizeSocialUrl($platform, trim((string) $url)); - } - $socialIcons = [ - 'linkedin' => 'linkedin.svg', - 'twitter' => 'twitter.svg', - 'instagram' => 'instagram.svg', - 'facebook' => 'facebook.svg', - 'tiktok' => 'tik-tok.svg', - 'youtube' => 'youtube.svg', - 'whatsapp' => 'whatsapp.svg', - ]; -@endphp -
- - {{-- Cover strip + logo anchor --}} -
- {{-- Cover --}} -
- @if($bizHasCover) - {{ $bizName }} -
- @else -
-
- @endif -
- - {{-- Logo: floats at the cover/card seam --}} -
- @if($bizHasLogo) -
- {{ $bizName }} -
- @else -
- {{ $bizInitials }} -
- @endif -
-
- - {{-- Floating card --}} -
-
- - {{-- Name & tagline centred --}} -
-

{{ $bizName }}

- @if($bizTagline) -

{{ $bizTagline }}

- @endif -
- - {{-- Contact rows --}} -
- @if(!empty($content['phone'])) - - - {{ $content['phone'] }} - - @endif - @if(!empty($content['email'])) - - - {{ $content['email'] }} - - @endif - @if(!empty($content['website'])) - - - {{ $content['website'] }} - - @endif - @if(!empty($content['address'])) -
- - {{ $content['address'] }} -
- @endif - @if(!empty($content['hours'])) -
- - {{ $content['hours'] }} -
- @endif -
- - {{-- Social links --}} - @if(!empty($bizSocial)) -
- @foreach($bizSocial as $platform => $url) - @if(isset($socialIcons[$platform])) - - {{ ucfirst($platform) }} - - @endif - @endforeach -
- @endif - - {{-- CTA: website button if present --}} - @if(!empty($content['website'])) - - @endif - -
-
-
- -{{-- ===== CHURCH ===== --}} -@elseif($type === \App\Models\QrCode::TYPE_CHURCH) -@php - $churchName = $content['name'] ?? $qrCode->label; - $churchColor = $content['brand_color'] ?? '#1a3a5c'; - $churchHasLogo = !empty($content['logo_path']); - $churchHasCover = !empty($content['cover_path']); - $churchLogoUrl = $churchHasLogo ? route('qr.public.church.logo', $qrCode->short_code) : null; - $churchCoverUrl = $churchHasCover ? route('qr.public.church.cover', $qrCode->short_code) : null; - $churchInitials = strtoupper(substr($churchName, 0, 2)); - $churchAcceptsPay = !empty($content['accepts_payment']); - $churchCurrency = $content['currency'] ?? 'GHS'; - $churchTypes = $content['collection_types'] ?? ['offering', 'tithe', 'donation', 'harvest']; - // Normalise legacy lowercase slugs → display strings - $legacyLabels = ['offering'=>'Offering','tithe'=>'Tithe','donation'=>'Donation','harvest'=>'Harvest']; - $churchTypeLabels = array_combine( - $churchTypes, - array_map(fn($t) => $legacyLabels[strtolower(trim($t))] ?? trim($t), $churchTypes) - ); - $churchOrderRoute = route('qr.public.order', $qrCode->short_code); - $churchCsrf = csrf_token(); -@endphp -
- - {{-- Cover strip + logo anchor --}} -
-
- @if($churchHasCover) - {{ $churchName }} -
- @else -
-
- @endif -
- - {{-- Logo: floats at cover/card seam --}} -
- @if($churchHasLogo) -
- {{ $churchName }} -
- @else -
- {{ $churchInitials }} -
- @endif -
-
- - {{-- Floating card --}} -
-
- - {{-- Name + denomination + description --}} -
-

{{ $churchName }}

- @if(!empty($content['denomination'])) -

{{ $content['denomination'] }}

- @endif - @if(!empty($content['description'])) -

{{ $content['description'] }}

- @endif -
- - {{-- Service times badge --}} - @if(!empty($content['service_times'])) -
- - - {{ $content['service_times'] }} - -
- @endif - - {{-- Compact icon action row --}} - @php $hasContact = !empty($content['phone']) || !empty($content['email']) || !empty($content['address']) || !empty($content['website']); @endphp - @if($hasContact) -
- @if(!empty($content['phone'])) - - - - @endif - @if(!empty($content['email'])) - - - - @endif - @if(!empty($content['address'])) - - - - @endif - @if(!empty($content['website'])) - - - - @endif -
- @endif - -
- - @if($churchAcceptsPay && !empty($churchTypes)) - {{-- Brand color CSS variable so focus rings match --}} - - - {{-- Giving card --}} -
- - {{-- Header --}} -
-

Give Online

-

Secured by Paystack. Powered by Ladill Pay

-
- -
- - {{-- Collection type icon cards --}} - @php - // Keyed by lowercase label for case-insensitive lookup - $typeIconMap = [ - 'offering' => 'M21 11.25v8.25a1.5 1.5 0 0 1-1.5 1.5H5.25a1.5 1.5 0 0 1-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 1 0 9.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1 1 14.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125Z', - 'tithe' => 'M2.25 18.75a60.07 60.07 0 0 1 15.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 0 1 3 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 0 0-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 0 1-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 0 0 3 15h-.75M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm3 0h.008v.008H18V10.5Zm-12 0h.008v.008H6V10.5Z', - 'donation' => 'M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z', - 'harvest' => 'M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z', - 'school fees' => 'M4.26 10.147a60.438 60.438 0 0 0-.491 6.347A48.63 48.63 0 0 1 12 20.904a48.63 48.63 0 0 1 8.232-4.41 60.46 60.46 0 0 0-.491-6.347m-15.482 0a50.57 50.57 0 0 0-2.658-.813A59.906 59.906 0 0 1 12 3.493a59.903 59.903 0 0 1 10.399 5.84c-.896.248-1.783.52-2.658.814m-15.482 0A50.697 50.697 0 0 1 12 13.489a50.702 50.702 0 0 1 3.741-1.342M6.75 14.25v4.5', - 'pta levy' => 'M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z', - 'exam fees' => 'M16.862 4.487l1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10', - 'development fund' => 'M2.25 21h19.5m-18-18v18m10.5-18v18m6-13.5V21M6.75 6.75h.75m-.75 3h.75m-.75 3h.75m3-6h.75m-.75 3h.75m-.75 3h.75M6.75 21v-3.375c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21M3 3h12m-.75 4.5H21m-3.75 3.75h.008v.008h-.008v-.008Zm0 3h.008v.008h-.008v-.008Zm0 3h.008v.008h-.008v-.008Z', - 'sadaqah' => 'M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z', - 'zakat' => 'M2.25 18.75a60.07 60.07 0 0 1 15.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 0 1 3 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 0 0-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 0 1-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 0 0 3 15h-.75M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm3 0h.008v.008H18V10.5Zm-12 0h.008v.008H6V10.5Z', - "waqf" => 'M2.25 21h19.5m-18-18v18m10.5-18v18m6-13.5V21M6.75 6.75h.75m-.75 3h.75m-.75 3h.75m3-6h.75m-.75 3h.75m-.75 3h.75M6.75 21v-3.375c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21M3 3h12m-.75 4.5H21m-3.75 3.75h.008v.008h-.008v-.008Zm0 3h.008v.008h-.008v-.008Zm0 3h.008v.008h-.008v-.008Z', - "jumu'ah" => 'M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z', - 'general donation' => 'M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z', - 'project fund' => 'M20.25 14.15v4.25c0 1.094-.787 2.036-1.872 2.18-2.087.277-4.216.42-6.378.42s-4.291-.143-6.378-.42c-1.085-.144-1.872-1.086-1.872-2.18v-4.25m16.5 0a2.18 2.18 0 0 0 .75-1.661V8.706c0-1.081-.768-2.015-1.837-2.175a48.114 48.114 0 0 0-3.413-.387m4.5 8.006c-.194.165-.42.295-.673.38A23.978 23.978 0 0 1 12 15.75c-2.648 0-5.195-.429-7.577-1.22a2.016 2.016 0 0 1-.673-.38m0 0A2.18 2.18 0 0 1 3 12.489V8.706c0-1.081.768-2.015 1.837-2.175a48.111 48.111 0 0 1 3.413-.387m7.5 0V5.25A2.25 2.25 0 0 0 13.5 3h-3a2.25 2.25 0 0 0-2.25 2.25v.894m7.5 0a48.667 48.667 0 0 0-7.5 0M12 12.75h.008v.008H12v-.008Z', - 'emergency relief' => 'M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z', - 'sponsorship' => 'M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z', - 'membership dues' => 'M15 9h3.75M15 12h3.75M15 15h3.75M4.5 19.5h15a2.25 2.25 0 0 0 2.25-2.25V6.75A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25v10.5A2.25 2.25 0 0 0 4.5 19.5Zm6-10.125a1.875 1.875 0 1 1-3.75 0 1.875 1.875 0 0 1 3.75 0Zm1.294 6.336a6.721 6.721 0 0 1-3.17.789 6.721 6.721 0 0 1-3.168-.789 3.376 3.376 0 0 1 6.338 0Z', - 'event fund' => 'M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5', - ]; - $defaultIcon = 'M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09Z'; - // Resolve icon for each type by lowercase key - $typeIcons = []; - foreach ($churchTypeLabels as $key => $label) { - $typeIcons[$key] = $typeIconMap[strtolower($label)] ?? $defaultIcon; - } - @endphp - @if(count($churchTypeLabels) > 1) -
-
-
-

I'm giving for

-

Choose a giving category

-
- - - - - -
-
- @foreach($churchTypeLabels as $ct => $ctLabel) - @php $iconPath = $typeIcons[$ct] ?? $defaultIcon; @endphp - - @endforeach -
-
- @endif - - {{-- Quick amounts carousel — shows 2 full chips + peek at 3rd --}} -
-

Quick amount

- -
- - {{-- Amount input — inline styles to prevent overflow on mobile --}} -
-

Or enter amount

-
- {{ $churchCurrency }} - -
-
- - {{-- Contact fields --}} -
-

Your details

- - - -
- - {{-- Error --}} -
- - {{-- Give button --}} - - -

Your giving is safe & secure

-
-
- @endif - - @include('partials.paystack-sheet') -
-
- -{{-- ===== EVENT ===== --}} -@elseif($type === \App\Models\QrCode::TYPE_EVENT) -@php - $evColor = $content['brand_color'] ?? '#4f46e5'; - $evHasLogo = !empty($content['logo_path']); - $evHasCover = !empty($content['cover_path']); - $evLogoUrl = $evHasLogo ? route('qr.public.event.logo', $qrCode->short_code) : null; - $evCoverUrl = $evHasCover ? route('qr.public.event.cover', $qrCode->short_code) : null; - $evName = $content['name'] ?? $qrCode->label; - $evInitials = strtoupper(mb_substr($evName, 0, 2)); - $evTiers = $content['tiers'] ?? []; - $evCurrency = $content['currency'] ?? 'GHS'; - $evFields = $content['badge_fields'] ?? []; - $evOpen = (bool) ($content['registration_open'] ?? true); - $evMode = in_array($content['mode'] ?? 'ticketing', ['contributions', 'free'], true) ? ($content['mode'] ?? 'ticketing') : 'ticketing'; - $evCategories = $content['contribution_categories'] ?? ['Contribution']; - $evHeading = $evMode === 'contributions' ? 'Make a contribution' : 'Register'; - $evRegRoute = route('qr.public.event.register', $qrCode->short_code); - $evCsrf = csrf_token(); -@endphp -
- - {{-- Hero --}} -
- @if($evHasCover) - {{ $evName }} -
- @else -
- @endif -
- @if($evHasLogo) - {{ $evName }} - @else -
{{ $evInitials }}
- @endif -

{{ $evName }}

- @if(!empty($content['tagline']))

{{ $content['tagline'] }}

@endif -
- @if(!empty($content['starts_at'])) - - - {{ \App\Support\Qr\QrDateFormatter::forDisplay($content['starts_at']) }} - - @endif - @if(!empty($content['location'])) - - - {{ $content['location'] }} - - @endif -
-
-
- -
- @if(!empty($content['description'])) -

{{ $content['description'] }}

- @endif - - @if(! $evOpen) -
{{ $evMode === 'contributions' ? 'Contributions are currently closed.' : 'Registration is currently closed.' }}
- @else -
-
-

{{ $evHeading }}

-
-
- - {{-- Ticket tiers --}} - @if($evMode === 'ticketing' && count($evTiers) > 0) -
-
-
-

Ticket

-

Choose a ticket type

-
- - - - - -
-
- @foreach($evTiers as $t) - @php $tPrice = (float) ($t['price'] ?? 0); @endphp - - @endforeach -
-
- @endif - - {{-- Contribution categories + amount --}} - @if($evMode === 'contributions') - @if(count($evCategories) > 1) -
-
-
-

I'm giving for

-

Choose a category

-
- - - - - -
-
- @foreach($evCategories as $cat) - - @endforeach -
-
- @endif - - {{-- Quick amounts --}} -
-

Quick amount

-
- @foreach([50, 100, 200, 500, 1000] as $qa) - - @endforeach -
-
- - {{-- Custom amount --}} -
-

Or enter amount

-
- {{ $evCurrency }} - -
-
- @endif - - {{-- Attendee details --}} -
-

Your details

- - - - @foreach($evFields as $field) - - @endforeach -
- -
- - -

{{ $evMode === 'contributions' ? 'Your contribution is safe & secure.' : 'A badge will be issued on registration.' }}

-
-
- @endif -
- - @include('partials.paystack-sheet') -
- -{{-- ===== ITINERARY ===== --}} -@elseif($type === \App\Models\QrCode::TYPE_ITINERARY) -@php - $itinColor = $content['brand_color'] ?? '#b45309'; - $itinHasCover = !empty($content['cover_path']); - $itinCoverUrl = $itinHasCover ? route('qr.public.itinerary.cover', $qrCode->short_code) : null; - $itinTitle = $content['title'] ?? $qrCode->label; - $itinDays = $content['days'] ?? []; - $multiDay = count($itinDays) > 1; -@endphp -
- - {{-- Hero --}} -
- @if($itinHasCover) - {{ $itinTitle }} -
- @else -
-
- @endif -
-

{{ $itinTitle }}

- @if(!empty($content['subtitle'])) -

{{ $content['subtitle'] }}

- @endif -
- @if(!empty($content['event_date'])) - - - {{ \App\Support\Qr\QrDateFormatter::forDisplay($content['event_date']) }} - - @endif - @if(!empty($content['location'])) - - - {{ $content['location'] }} - - @endif -
-
-
- -
- @if(!empty($content['description'])) -

{{ $content['description'] }}

- @endif - - {{-- Programme --}} -
- @if($multiDay) -
- @foreach($itinDays as $di => $day) - - @endforeach -
- @endif - - @foreach($itinDays as $di => $day) -
-
-

{{ $day['label'] ?: ('Day ' . ($di + 1)) }}

- @if(!empty($day['date'])){{ \App\Support\Qr\QrDateFormatter::forDisplay($day['date']) }}@endif -
- -
- @foreach($day['items'] ?? [] as $ii => $item) -
- {{-- Timeline rail --}} -
- - @if(! $loop->last)@endif -
-
- @if(!empty($item['time'])) -

{{ $item['time'] }}

- @endif -

{{ $item['title'] }}

- @if(!empty($item['host']) || !empty($item['location'])) -

- {{ $item['host'] ?? '' }}{{ !empty($item['host']) && !empty($item['location']) ? ' · ' : '' }}{{ $item['location'] ?? '' }} -

- @endif - @if(!empty($item['description'])) -

{{ $item['description'] }}

- @endif -
-
- @endforeach -
-
- @endforeach -
-
-
- -{{-- ===== WIFI ===== --}} -@elseif($type === \App\Models\QrCode::TYPE_WIFI) -
-
-
-
- - - -
-

Join WiFi

-

{{ $qrCode->label }}

-
- -
-
-
-

Network

-

{{ $content['ssid'] ?? '' }}

-
- @if(($content['encryption'] ?? 'WPA') !== 'NOPASS' && !empty($content['password'])) -
-

Password

-
-

{{ $content['password'] }}

- -
-
- @endif -
- - {{ $content['encryption'] ?? 'WPA' }} - {{ !empty($content['hidden']) ? ' · Hidden' : '' }} - -
-
-
-
-
- -{{-- ===== IMAGES ===== --}} -@elseif($type === \App\Models\QrCode::TYPE_IMAGE) -
-

{{ $qrCode->label }}

-
- @foreach($content['images'] ?? [] as $index => $image) - {{ $image['title'] ?? 'Image ' . ($index + 1) }} - @endforeach -
-
- -{{-- ===== APP ===== --}} -@elseif($type === \App\Models\QrCode::TYPE_APP) -@php $appIconUrl = !empty($content['icon_path']) ? route('qr.public.app.icon', $qrCode->short_code) : null; @endphp -
-
- @if($appIconUrl) - {{ $content['name'] ?? $qrCode->label }} - @else -
- - - -
- @endif -

{{ $content['name'] ?? $qrCode->label }}

-
- @if(!empty($content['ios_url'])) - - - - - - -
-

Download on the

-

App Store

-
-
- @endif - @if(!empty($content['android_url'])) - - - - -
-

Get it on

-

Google Play

-
-
- @endif - @if(!empty($content['web_url'])) - - -
-

Open

-

Website

-
-
- @endif -
-
-
- -{{-- ===== COUPON ===== --}} -@elseif($type === \App\Models\QrCode::TYPE_COUPON) -
-
-
-
- - - -

{{ $content['title'] ?? $qrCode->label }}

-
-
-
-

Your code

-

{{ $content['code'] ?? '' }}

-
- @if(!empty($content['description']))

{{ $content['description'] }}

@endif - @if(!empty($content['expires_at']))

Expires {{ $content['expires_at'] }}

@endif - @if(!empty($content['button_url'])) - - {{ $content['button_text'] ?? 'Shop now' }} - - @endif -
-
-
-
- -{{-- ===== MENU & SHOP (shared cart/order UI) ===== --}} -@elseif(in_array($type, [\App\Models\QrCode::TYPE_MENU, \App\Models\QrCode::TYPE_SHOP])) - -@php - $isShop = $type === \App\Models\QrCode::TYPE_SHOP; - $currency = $content['currency'] ?? 'GHS'; - $sections = $content['sections'] ?? []; - $title = $content['title'] ?? $qrCode->label; - $acceptsPayment = (bool) ($content['accepts_payment'] ?? false); - $orderRoute = route('qr.public.order', $qrCode->short_code); - $csrfToken = csrf_token(); - $paystackPublicKey = $acceptsPayment ? app(\App\Services\Billing\PaystackService::class)->publicKey() : ''; - $brandColor = $content['brand_color'] ?? ($isShop ? '#7c3aed' : '#f97316'); - $hasLogo = !empty($content['logo_path']); - $hasCover = !empty($content['cover_path']); - $logoUrl = $hasLogo ? route('qr.public.menu.logo', $qrCode->short_code) : null; - $coverUrl = $hasCover ? route('qr.public.menu.cover', $qrCode->short_code) : null; - $shippingType = $content['shipping_type'] ?? 'none'; - $shippingFee = (float) ($content['shipping_fee'] ?? 0); - $freeShippingAbove = (float) ($content['free_shipping_above'] ?? 0); - $itemCount = array_sum(array_map(fn($s) => count($s['items'] ?? []), $sections)); -@endphp - -
- -{{-- ===== HERO ===== --}} -
- @if($hasCover) - {{ $title }} - @endif - {{-- gradient overlay always present --}} -
- @if(!$hasCover) -
- @endif - - {{-- Hero content --}} -
-
- @if($hasLogo) - {{ $title }} - @else -
- {{ strtoupper(substr($title, 0, 1)) }} -
- @endif -
-

{{ $title }}

-
- - {{ $isShop ? 'Shop' : 'Restaurant' }} - - - {{ $itemCount }} {{ $isShop ? ($itemCount === 1 ? 'product' : 'products') : ($itemCount === 1 ? 'item' : 'items') }} - - @if($acceptsPayment) - - Order online - - @endif -
-
-
-
-
- -{{-- ===== SECTION TABS ===== --}} -@if(count($sections) > 1) -
-
- @foreach($sections as $si => $section) - - @endforeach -
-
-@endif - -{{-- ===== ITEMS ===== --}} -
- @foreach($sections as $si => $section) -
- @if(count($sections) === 1) -

{{ $section['name'] }}

- @endif -
- @foreach($section['items'] ?? [] as $ii => $item) - @php $hasImage = !empty($item['image_path']); @endphp -
- {{-- Item image --}} - @if($hasImage) - {{ $item['name'] }} - @endif - - {{-- Item details --}} -
-
-

{{ $item['name'] }}

- @if(!empty($item['description'])) -

{{ $item['description'] }}

- @endif -
-
- @if(!empty($item['price'])) -
- {{ $currency }} - {{ $item['price'] }} -
- @else - - @endif - - @if($acceptsPayment) -
- - - -
- @endif -
-
-
- @endforeach -
-
- @endforeach -
- -@if($acceptsPayment) -{{-- ===== STICKY CART BAR ===== --}} -
- -
- -{{-- ===== PAYMENT MODAL (bottom sheet) ===== --}} -
- -
- - {{-- Drag handle --}} -
-
-
- - {{-- Header --}} -
-

Your order

- -
- -
- {{-- Cart items --}} -
- -
- - {{-- Totals --}} -
-
- Subtotal - -
- - -
- Total - -
-
- - {{-- Contact fields --}} -
-

Your details

- - - -
- -
- - - -

Secured by Paystack. Powered by Ladill Pay

-
-
-
-@endif - -@if($acceptsPayment) -@include('partials.paystack-sheet') -@endif - -
{{-- end x-data --}} - -@endif - - - - - diff --git a/resources/views/public/qr/payment-confirmed.blade.php b/resources/views/public/qr/payment-confirmed.blade.php deleted file mode 100644 index a961e03..0000000 --- a/resources/views/public/qr/payment-confirmed.blade.php +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - Payment confirmed - - - @vite(['resources/css/app.css']) - - -
-
-
- -
-

Payment successful

-

- {{ $payment->currency }} {{ number_format($payment->amount_minor / 100, 2) }} paid to - {{ $qrCode->content()['business_name'] ?? $qrCode->label }}. -

-

Reference: {{ $payment->reference }}

-
-
- - diff --git a/resources/views/public/qr/payment-landing.blade.php b/resources/views/public/qr/payment-landing.blade.php deleted file mode 100644 index fa0cd18..0000000 --- a/resources/views/public/qr/payment-landing.blade.php +++ /dev/null @@ -1,114 +0,0 @@ -@php - $content = $qrCode->content(); - $businessName = $content['business_name'] ?? $qrCode->label; - $currency = $content['currency'] ?? 'GHS'; - $payUrl = route('qr.public.payment.pay', $qrCode->short_code); - $csrf = csrf_token(); -@endphp - - - - - - - Pay {{ $businessName }} - - - @vite(['resources/css/app.css', 'resources/js/app.js']) - - - - {{-- Mobile: Ladill Mini branding + fixed payment sheet --}} -
-
-
- Ladill Mini -

{{ $businessName }}

- @if(!empty($content['branch_label'])) -

{{ $content['branch_label'] }}

- @endif -
-
- - - -
-
- -
- - -
- {{ $currency }} - -
- - - -

Secured by Paystack. Powered by Ladill Pay

-
-
- - {{-- Desktop: centered card --}} -
-
-
- @if(!empty($content['logo_path'])) - - @endif -

{{ $businessName }}

- @if(!empty($content['branch_label'])) -

{{ $content['branch_label'] }}

- @endif -
- -
- -
- -
- {{ $currency }} - -
- -
- -

Secured by Paystack. Powered by Ladill Pay

-
-
- - @include('partials.paystack-sheet') - - diff --git a/resources/views/qr-codes/attendees.blade.php b/resources/views/qr-codes/attendees.blade.php deleted file mode 100644 index 0cb596f..0000000 --- a/resources/views/qr-codes/attendees.blade.php +++ /dev/null @@ -1,176 +0,0 @@ - - @php $isContribution = ($qrCode->content()['mode'] ?? 'ticketing') === 'contributions'; @endphp - {{ $isContribution ? 'Contributions' : 'Attendees' }} — {{ $qrCode->label }} - -
- - {{-- Flash --}} - @foreach(['success', 'error'] as $flash) - @if(session($flash)) -
- {{ session($flash) }} -
- @endif - @endforeach - - {{-- Header --}} -
- - - {{ $qrCode->label }} - -

{{ $isContribution ? 'Contributions' : 'Attendees' }}

-
- - {{-- Share programme outline --}} - @if($programme) -
-
- - - -
-

Programme outline

-

Email & text the {{ $programme->label }} programme link to all confirmed attendees.

-
-
-
- @csrf - -
-
- @endif - - {{-- Stats --}} -
-
-

{{ number_format($stats['total']) }}

-

{{ $isContribution ? 'Contributions' : 'Registered' }}

-
- @unless($isContribution) -
-

{{ number_format($stats['checked_in']) }}

-

Checked in

-
- @endunless -
-

GHS {{ number_format($stats['revenue'], 2) }}

-

{{ $isContribution ? 'Total raised' : 'Revenue' }}

-
-
- - {{-- Toolbar --}} -
-
- - -
- @unless($isContribution) -
- selected - - - Print all badges - Download ZPL -
- @endunless -
- - {{-- Table --}} -
-
- - - - @unless($isContribution)@endunless - - - @unless($isContribution)@endunless - - @unless($isContribution)@endunless - - - - @forelse($registrations as $reg) - - @unless($isContribution) - - @endunless - - - @unless($isContribution) - - @endunless - - @unless($isContribution) - - @endunless - - @empty - - @endforelse - -
{{ $isContribution ? 'Contributor' : 'Attendee' }}{{ $isContribution ? 'Category' : 'Ticket' }}BadgeStatusCheck-in
- @if($reg->status === \App\Models\QrEventRegistration::STATUS_CONFIRMED) - - @endif - -

{{ $reg->attendee_name }}

-

{{ $reg->attendee_email }}

- @foreach(($reg->badge_fields ?? []) as $k => $v) - {{ $k }}: {{ $v }} - @endforeach -
- {{ $reg->tier_name }} - @if($reg->isPaid())

GHS {{ number_format($reg->amountCedis(), 2) }}

@endif -
{{ $reg->badge_code }} - - {{ ucfirst($reg->status) }} - - -
- @csrf @method('PATCH') - -
-
{{ $isContribution ? 'No contributions yet.' : 'No registrations yet.' }}
-
- @if($registrations->hasPages()) -
{{ $registrations->links() }}
- @endif -
-
-
diff --git a/resources/views/qr-codes/badges.blade.php b/resources/views/qr-codes/badges.blade.php deleted file mode 100644 index b951707..0000000 --- a/resources/views/qr-codes/badges.blade.php +++ /dev/null @@ -1,85 +0,0 @@ -@php - $c = $qrCode->content(); - $evColor = $c['brand_color'] ?? '#4f46e5'; - $evName = $c['name'] ?? $qrCode->label; - $hasLogo = !empty($c['logo_path']); - $logoUrl = $hasLogo ? route('qr.public.event.logo', $qrCode->short_code) : null; - $size = $c['badge_size'] ?? '4x3'; - // Physical badge dimensions (inches) - [$bw, $bh] = match ($size) { - '4x6' => ['4in', '6in'], - 'cr80' => ['3.375in', '2.125in'], - default => ['4in', '3in'], - }; - $stack = $size === '4x6'; // tall badge → stack vertically -@endphp - - - - - @include('partials.favicon') - Badges — {{ $evName }} - - - -
- - -
- -
- @forelse($registrations as $reg) - @php $badgeQr = 'https://api.qrserver.com/v1/create-qr-code/?size=120x120&margin=0&data=' . urlencode($reg->badge_code); @endphp -
-
- @if($hasLogo)@endif - {{ $evName }} -
-
-
{{ $reg->attendee_name }}
-
{{ $reg->tier_name }}
- @if(!empty($reg->badge_fields)) -
{{ collect($reg->badge_fields)->map(fn($v,$k) => $v)->implode(' · ') }}
- @endif -
-
- {{ $reg->badge_code }} - {{ $reg->badge_code }} -
-
- @empty -

No confirmed attendees to print.

- @endforelse -
- - diff --git a/resources/views/qr-codes/create.blade.php b/resources/views/qr-codes/create.blade.php deleted file mode 100644 index 7214105..0000000 --- a/resources/views/qr-codes/create.blade.php +++ /dev/null @@ -1,303 +0,0 @@ - - @php - $createType = old('type', $requestedType ?? \App\Models\QrCode::TYPE_EVENT); - $isProgramme = $createType === \App\Models\QrCode::TYPE_ITINERARY; - $defaultStyle = \App\Support\Qr\QrStyleDefaults::merge(old('style') ?: ($accountDefaultStyle ?? null)); - @endphp - {{ $isProgramme ? 'New programme' : 'Create event' }} - -
- - @if(session('error')) -
{{ session('error') }}
- @endif - - {{-- Mobile page header --}} -
- - - - - - {{ $isProgramme ? 'New programme' : 'Create event' }} -
- - {{-- Desktop page header --}} - - - @unless($isProgramme) - {{-- Event creation wizard steps --}} -
-
    - @foreach(['Event details', 'QR code', 'Review & create'] as $i => $label) - @php $n = $i + 1; @endphp -
  1. - {{ $n }} - - @if($n < 3) - - @endif -
  2. - @endforeach -
-
- @endunless - -
- @csrf - - - {{-- QR preview (step 2 for events, always for programmes) --}} -
- -
- -
- - {{-- Step 1 / Programme: Event details --}} -
-
-

{{ $isProgramme ? 'Programme details' : 'Event details' }}

-

{{ $isProgramme ? 'Title, schedule, and venue for this outline.' : 'Name, dates, ticketing, and registration settings.' }}

-
- -
-
- - -
- -
- -
- - {{ \App\Models\QrCode::publicBaseUrl() }}/q/ - - -
-
- - - - - -
-
- -
- @include('qr-codes.partials.type-fields-create') -
- - @unless($isProgramme) -

- You will design the QR code in the next step. Details can be updated anytime without reprinting. -

- @else -

- Attendees scan your Ladill event link — update details anytime without reprinting the QR. -

- @endunless -
-
- - {{-- Step 2: QR customization (events) or inline (programmes) --}} -
- @include('qr-codes.partials.customization-fields', [ - 'style' => $defaultStyle, - 'moduleStyles' => $moduleStyles, - 'cornerOuterStyles' => $cornerOuterStyles, - 'cornerInnerStyles' => $cornerInnerStyles, - 'frameStyles' => $frameStyles, - ]) -
- - {{-- Step 3: Review & create (events only) --}} -
-
-
-

Review your event

-

Confirm details before publishing. GHS {{ number_format($pricePerQr, 2) }} will be charged from your QR balance.

-
-
-
- Event - -
-
- Admin label - -
-
- QR balance after - GHS -
-
-
- -
- @include('qr-codes.partials.qr-preview-card', ['showDownloads' => false]) -
-
- - {{-- Desktop navigation --}} - -
-
- - {{-- Mobile sticky action bar --}} -
-
- - - - -
- -
- - {{-- Preview modal --}} -
-
- Preview - -
-
-
-
-
-
- -
-
- -
-
-
-
-
- -
-
diff --git a/resources/views/qr-codes/index.blade.php b/resources/views/qr-codes/index.blade.php deleted file mode 100644 index 34feb37..0000000 --- a/resources/views/qr-codes/index.blade.php +++ /dev/null @@ -1,86 +0,0 @@ - - Events - -
- @foreach(['success', 'error'] as $flash) - @if(session($flash)) -
-

{{ session($flash) }}

-
- @endif - @endforeach - -
-
-
-
-
-
- Tickets · Contributions · Free registration -
-

Your events

-

- Create ticketed events, manage attendees, print badges, and attach programme outlines. -

-
- -
-
- -
-
-

GHS {{ number_format($wallet->spendableBalance(), 2) }}

-

Account balance

-
-
-

{{ $qrCodes->count() }}

-

Events

-
-
-

{{ number_format($totalRegistrations) }}

-

Registrations

-
-
-
-
-
- -
-
-

Your events

-
- @if($qrCodes->isEmpty()) -

No events yet. Create your first event to get started.

- @else - - @endif -
-
-
diff --git a/resources/views/qr-codes/partials/customization-fields.blade.php b/resources/views/qr-codes/partials/customization-fields.blade.php deleted file mode 100644 index 132cb97..0000000 --- a/resources/views/qr-codes/partials/customization-fields.blade.php +++ /dev/null @@ -1,530 +0,0 @@ -@php - $style = \App\Support\Qr\QrStyleDefaults::merge($style ?? null); - $moduleStyles = $moduleStyles ?? \App\Support\Qr\QrModuleStyleCatalog::visible(); - $cornerOuterStyles = $cornerOuterStyles ?? \App\Support\Qr\QrCornerStyleCatalog::outerStyles(); - $cornerInnerStyles = $cornerInnerStyles ?? \App\Support\Qr\QrCornerStyleCatalog::innerStyles(); - $frameStyles = $frameStyles ?? \App\Support\Qr\QrFrameStyleCatalog::visible(); - $qrBits = [1,0,1,0,1, 0,1,1,0,0, 1,1,0,1,1, 0,0,1,0,1, 1,0,1,1,0]; -@endphp - -@include('qr-codes.partials.qr-customizer-script') - -{{-- ── Quick style presets ──────────────────────────────────────────── --}} -
-
-

Quick styles

-
-
- @foreach([ - [ - 'key' => 'classic', - 'label' => 'Classic', - 'fg' => '#000000', - 'bg' => '#ffffff', - 'outer' => 'square', - 'inner' => 'square', - 'dot' => false, - ], - [ - 'key' => 'rounded', - 'label' => 'Rounded', - 'fg' => '#111827', - 'bg' => '#ffffff', - 'outer' => 'rounded', - 'inner' => 'square', - 'dot' => false, - ], - [ - 'key' => 'branded', - 'label' => 'Branded', - 'fg' => '#1d4ed8', - 'bg' => '#ffffff', - 'outer' => 'rounded', - 'inner' => 'square', - 'dot' => false, - ], - [ - 'key' => 'dots', - 'label' => 'Dots', - 'fg' => '#0f172a', - 'bg' => '#ffffff', - 'outer' => 'square', - 'inner' => 'dot', - 'dot' => true, - ], - ] as $preset) - @php - $outerRadius = match($preset['outer']) { 'rounded' => '3px', 'circle' => '50%', default => '1px' }; - $innerRadius = $preset['inner'] === 'dot' ? '50%' : '1px'; - $dotRadius = $preset['dot'] ? '50%' : '1px'; - @endphp - - @endforeach -
-
- -
- - {{-- Pill tab nav --}} -
-
- @foreach([ - ['id' => 'body', 'label' => 'Body'], - ['id' => 'colors', 'label' => 'Colors'], - ['id' => 'eyes', 'label' => 'Eyes'], - ['id' => 'logo', 'label' => 'Logo'], - ['id' => 'frame', 'label' => 'Frame'], - ] as $tab) - - @endforeach -
-
- -
- - {{-- ── Body ─────────────────────────────────────────────────────── --}} -
-

Dot shape

-
- @foreach($moduleStyles as $key => $meta) - - @endforeach -
-
- - {{-- ── Colors ───────────────────────────────────────────────────── --}} -
-
-

Quick presets

-
- @foreach([ - ['fg' => '#000000', 'bg' => '#ffffff', 'label' => 'Classic'], - ['fg' => '#1d4ed8', 'bg' => '#ffffff', 'label' => 'Blue'], - ['fg' => '#047857', 'bg' => '#ffffff', 'label' => 'Green'], - ['fg' => '#7c3aed', 'bg' => '#ffffff', 'label' => 'Purple'], - ['fg' => '#c2410c', 'bg' => '#fff7ed', 'label' => 'Orange'], - ['fg' => '#be185d', 'bg' => '#fdf4ff', 'label' => 'Pink'], - ['fg' => '#0f172a', 'bg' => '#ecfdf5', 'label' => 'Dark teal'], - ] as $preset) - - @endforeach -
-
- -
-
- -
- - -
-
-
- -
- - -
-
-
- -
-

Gradient

- -
- @foreach([['none', 'None'], ['linear', 'Linear'], ['radial', 'Radial']] as [$val, $lbl]) - - @endforeach -
-
-
-
- -
- - -
-
-
- -
- - -
-
-
-
- - -
-
-
-
- - {{-- ── Eyes ─────────────────────────────────────────────────────── --}} -
-
-

Outer frame

-
- @foreach([ - ['value' => 'square', 'label' => 'Square', 'shape' => 'h-7 w-7 border-[3px] border-slate-800'], - ['value' => 'rounded', 'label' => 'Rounded', 'shape' => 'h-7 w-7 rounded-[5px] border-[3px] border-slate-800'], - ['value' => 'circle', 'label' => 'Circle', 'shape' => 'h-7 w-7 rounded-full border-[3px] border-slate-800'], - ] as $opt) - - @endforeach -
-
- -
-

Inner dot

-
- @foreach([ - ['value' => 'square', 'label' => 'Square', 'shape' => 'h-5 w-5 rounded-[2px] bg-slate-800'], - ['value' => 'rounded', 'label' => 'Rounded', 'shape' => 'h-5 w-5 rounded-[4px] bg-slate-800'], - ['value' => 'dot', 'label' => 'Dot', 'shape' => 'h-5 w-5 rounded-full bg-slate-800'], - ] as $opt) - - @endforeach -
-
-
- - {{-- ── Logo ─────────────────────────────────────────────────────── --}} -
-
-

Center logo

-

Placed in the center. Error correction is raised automatically when a logo is present.

-
- - - {{-- Logo options: shown when a logo is present (uploaded or existing) --}} -
- - {{-- Size --}} -
- - - -
- - {{-- Padding --}} -
- - -
- - {{-- White background toggle --}} -
- White background - - -
- - {{-- Shape --}} -
-

Shape

-
- {{-- Original: landscape rectangle — logo keeps its natural aspect ratio --}} - - {{-- Rounded: square crop with rounded corners --}} - - {{-- Circle: square crop clipped to circle --}} - -
- -
- -
- - @if(!empty($showRemoveLogo)) - - @endif -
- - {{-- ── Frame ────────────────────────────────────────────────────── --}} -
-
-

Frame style

-
- @foreach($frameStyles as $key => $meta) - - @endforeach -
-
- - {{-- Frame colour --}} -
- -
- - -
-
- - {{-- Frame text -- only for frames that show a CTA label --}} -
- - -
- -
-
- - -
- -
- - -
- -
- - -
-
-
- -
-
diff --git a/resources/views/qr-codes/partials/qr-customizer-script.blade.php b/resources/views/qr-codes/partials/qr-customizer-script.blade.php deleted file mode 100644 index d471090..0000000 --- a/resources/views/qr-codes/partials/qr-customizer-script.blade.php +++ /dev/null @@ -1,669 +0,0 @@ -@once('qr-customizer-alpine') - -@endonce diff --git a/resources/views/qr-codes/partials/qr-preview-card.blade.php b/resources/views/qr-codes/partials/qr-preview-card.blade.php deleted file mode 100644 index 30e2d1f..0000000 --- a/resources/views/qr-codes/partials/qr-preview-card.blade.php +++ /dev/null @@ -1,188 +0,0 @@ -@php - $showDownloads = $showDownloads ?? true; -@endphp - -
- - {{-- QR display area with gradient background --}} -
- - {{-- Spinner overlay --}} -
- - - - -
- - {{-- Floating QR card --}} -
-
- - {{-- Frame wrapper: adds padding + CTA for label/pill frames --}} -
- {{-- QR code area — SVG renderer only, no static fallback --}} -
- - {{-- Scan me label --}} -
- -
- - {{-- Tap to scan pill --}} -
- -
-
- -
-
-
- - {{-- Scanability score --}} -
-
- Scan quality - -
-
-
-
- -
- - {{-- Download + Share buttons --}} - @if($showDownloads && isset($qrCode)) - @php - $shareUrl = $qrCode->publicUrl(); - $shareEnc = urlencode($shareUrl); - $shareText = urlencode($qrCode->label . ' — scan my QR code'); - @endphp -
-

Download & Share

-
- - - - - - PNG - - - - - - - SVG - - - - - - - PDF - - - {{-- Share button with popover --}} -
- - -
- - - - WhatsApp - - - - - X (Twitter) - - - - - Facebook - - -
- - -
-
- -
-
- @endif - -
diff --git a/resources/views/qr-codes/partials/type-fields-create.blade.php b/resources/views/qr-codes/partials/type-fields-create.blade.php deleted file mode 100644 index 5515642..0000000 --- a/resources/views/qr-codes/partials/type-fields-create.blade.php +++ /dev/null @@ -1,1049 +0,0 @@ -@php - $ed = $accountEventDefaults ?? []; -@endphp - -{{-- URL --}} -
- - -
- -{{-- PDF --}} -
-
- - -
-
-
-

Allow download

-

Show a Download button on the PDF viewer

-
- -
-
- -{{-- List of Links --}} -
- -
- -
- -
- -{{-- vCard --}} -
- - {{-- Avatar --}} -
-
-
- -
- -
-
-
-
- - -
-
- - {{-- Name --}} -
- - - - - - - - -
- - {{-- Social links --}} -
-

Social links (optional)

-
- @foreach([ - 'linkedin' => 'LinkedIn URL', - 'twitter' => 'X / Twitter URL', - 'instagram' => 'Instagram URL', - 'facebook' => 'Facebook URL', - 'tiktok' => 'TikTok URL', - 'youtube' => 'YouTube URL', - 'whatsapp' => 'WhatsApp link or number', - 'snapchat' => 'Snapchat username or URL', - ] as $platform => $placeholder) - - @endforeach -
-
- -
- -{{-- Business --}} -
-
- - -
- - -
- - - -
- - {{-- Branding --}} -
-

Branding (optional)

-
-
- - -
- -
-

Square image recommended.

-
-
- - - -
- -
-
-
- - -
-
-
- - {{-- Social links --}} -
-

Social links (optional)

-
- @foreach([ - 'linkedin' => 'LinkedIn URL', - 'twitter' => 'X / Twitter URL', - 'instagram' => 'Instagram URL', - 'facebook' => 'Facebook URL', - 'tiktok' => 'TikTok URL', - 'youtube' => 'YouTube URL', - 'whatsapp' => 'WhatsApp link or number', - ] as $platform => $placeholder) - - @endforeach -
-
-
- -{{-- Images --}} -
- - -

Select one or more images to display as a gallery.

-
- -{{-- Give (Church / NGO / School etc.) --}} -@php - $giveOldTypes = old('collection_types', ['Offering','Tithe','Donation','Harvest']); - $giveOldOrg = old('org_type', 'church'); -@endphp -
- - {{-- Org type selector --}} -
-

Organisation type

-
- @foreach([ - 'church' => ['Church', 'm21.941,12.493l-8.941-6.502v-1.991h1c.552,0,1-.448,1-1s-.448-1-1-1h-1v-1c0-.552-.448-1-1-1s-1,.448-1,1v1h-1c-.552,0-1,.448-1,1s.448,1,1,1h1v1.991L2.059,12.493c-1.289.938-2.059,2.45-2.059,4.044v3.463c0,2.206,1.794,4,4,4h16c2.206,0,4-1.794,4-4v-3.463c0-1.594-.77-3.106-2.059-4.044Zm-9.941,2.507c-1.654,0-3,1.346-3,3v4h-2v-10.627l5-3.636,5,3.636v10.627h-2v-4c0-1.654-1.346-3-3-3Zm-10,5v-3.463c0-.957.462-1.864,1.236-2.427l1.764-1.283v9.173h-1c-1.103,0-2-.897-2-2Zm9,2v-4c0-.551.449-1,1-1s1,.449,1,1v4h-2Zm11-2c0,1.103-.897,2-2,2h-1v-9.173l1.764,1.283c.774.563,1.236,1.47,1.236,2.427v3.463Z'], - 'school' => ['School', 'm21 6h-6.586l-1.159-1.159 3.745-2.341-4-2.5h-2v4.586l-1.414 1.414h-6.586a3 3 0 0 0 -3 3v15h24v-15a3 3 0 0 0 -3-3zm0 2a1 1 0 0 1 1 1v1h-3.586l-2-2zm-18 0h4.586l-2 2h-3.586v-1a1 1 0 0 1 1-1zm10 14h-2v-3a1 1 0 0 1 2 0zm2 0v-3a3 3 0 0 0 -6 0v3h-7v-10h4.414l5.586-5.586 5.586 5.586h4.414v10zm-11-8h3v2h-3zm0 4h3v2h-3zm13-4h3v2h-3zm0 4h3v2h-3zm-3-6a2 2 0 1 1 -2-2 2 2 0 0 1 2 2z'], - 'mosque' => ['Mosque', 'm21,22h-.949c1.228-1.354,1.949-2.91,1.949-4.39,0-4.285-3.708-6.157-6.415-7.523-.731-.369-1.718-.87-2.234-1.287-.032-.081-.229-.628-.312-1.807,1.193-.067,2.236-.683,2.887-1.599.202-.285-.038-.671-.383-.619-.175.026-.354.04-.537.04-2.111,0-3.822-1.711-3.822-3.822,0-.183.014-.362.04-.537.052-.345-.334-.585-.619-.383-1.051.747-1.708,2.009-1.593,3.422.106,1.301.884,2.414,1.976,3.019-.053,1.624-.31,2.248-.31,2.248l.008.007c-.505.422-1.524.939-2.272,1.316-2.707,1.367-6.415,3.239-6.415,7.523,0,1.48.721,3.036,1.949,4.39h-.949c-.553,0-1,.448-1,1s.447,1,1,1h18c.553,0,1-.448,1-1s-.447-1-1-1Zm-17-4.39c0-3.054,2.824-4.48,5.316-5.738,1.039-.524,2.003-1.011,2.684-1.597.681.586,1.645,1.072,2.684,1.597,2.492,1.258,5.316,2.684,5.316,5.738,0,1.457-1.171,3.169-2.987,4.39H6.987c-1.816-1.221-2.987-2.932-2.987-4.39Z'], - 'ngo' => ['NGO', 'm16,10v9c0,2.757-2.243,5-5,5h-6c-2.757,0-5-2.243-5-5L.007,4.872C.007,2.501,1.916.135,3.935.002c.538-.029,1.027.381,1.063.933.036.551-.381,1.027-.933,1.063-.899.059-2.059,1.463-2.059,2.874l-.007,14.128c0,1.654,1.346,3,3,3h6c1.654,0,3-1.346,3-3v-9c0-.553.448-1,1-1s1,.447,1,1Zm-12,1c0,.553.448,1,1,1h1c.552,0,1-.447,1-1s-.448-1-1-1h-1c-.552,0-1,.447-1,1Zm7-1h-1c-.552,0-1,.447-1,1s.448,1,1,1h1c.552,0,1-.447,1-1s-.448-1-1-1Zm-5,4h-1c-.552,0-1,.447-1,1s.448,1,1,1h1c.552,0,1-.447,1-1s-.448-1-1-1Zm5,0h-1c-.552,0-1,.447-1,1s.448,1,1,1h1c.552,0,1-.447,1-1s-.448-1-1-1Zm-5,4h-1c-.552,0-1,.447-1,1s.448,1,1,1h1c.552,0,1-.447,1-1s-.448-1-1-1Zm5,0h-1c-.552,0-1,.447-1,1s.448,1,1,1h1c.552,0,1-.447,1-1s-.448-1-1-1ZM21.5,0c-1.381,0-2.5,1.119-2.5,2.5v3c0,1.381,1.119,2.5,2.5,2.5s2.5-1.119,2.5-2.5v-3c0-1.381-1.119-2.5-2.5-2.5Zm.9,5.5c0,.496-.404.9-.9.9s-.9-.404-.9-.9v-3c0-.496.404-.9.9-.9s.9.404.9.9v3Zm-6.9-1.25h0c0,.414.336.75.75.75h.15v.468c0,.479-.361.898-.838.93-.524.035-.962-.381-.962-.898v-2.971c0-.434.296-.825.722-.911.283-.057.535.025.726.184.147.123.328.198.519.198h.062c.721,0,1.068-.894.528-1.372C16.711.233,16.124-.005,15.481,0c-1.383.01-2.481,1.175-2.481,2.558v2.886c0,1.402,1.147,2.582,2.548,2.556,1.358-.026,2.452-1.135,2.452-2.5v-1.25c0-.414-.336-.75-.75-.75h-1c-.414,0-.75.336-.75.75ZM10.4.8v3.263l-1.545-3.486C8.7.226,8.353,0,7.969,0h0C7.434,0,7,.434,7,.969v6.231c0,.442.358.8.8.8h0c.442,0,.8-.358.8-.8v-3.254l1.544,3.478c.156.35.503.576.886.576h0c.535,0,.97-.434.97-.97V.8C12,.358,11.642,0,11.2,0h0C10.758,0,10.4.358,10.4.8Z'], - 'club' => ['Club', 'm13.5,12c0,2.206,1.794,4,4,4s4-1.794,4-4-1.794-4-4-4-4,1.794-4,4Zm4-2c1.103,0,2,.897,2,2s-.897,2-2,2-2-.897-2-2,.897-2,2-2Zm-5.5-2c2.206,0,4-1.794,4-4S14.206,0,12,0s-4,1.794-4,4,1.794,4,4,4Zm0-6c1.103,0,2,.897,2,2s-.897,2-2,2-2-.897-2-2,.897-2,2-2Zm-5.5,14c2.206,0,4-1.794,4-4s-1.794-4-4-4-4,1.794-4,4,1.794,4,4,4Zm0-6c1.103,0,2,.897,2,2s-.897,2-2,2-2-.897-2-2,.897-2,2-2Zm17.471,12.91c.05.55-.356,1.036-.906,1.086-.03.002-.061.004-.091.004-.512,0-.948-.391-.995-.91-.11-1.219-.737-2.355-1.721-3.118-.025-.02-.107-.011-.16.049l-1.841,2.133c-.189.22-.485.389-.757.347-.288,0-.562-.124-.752-.34l-1.865-2.126c-.051-.059-.133-.07-.16-.048-.986.774-1.591,1.876-1.701,3.104-.043.476-.413.82-.873.881-.029.005-.054.022-.084.025-.031.002-.061.004-.091.004-.01,0-.018-.005-.027-.005-.004,0-.007.002-.011.001-.011,0-.02-.008-.031-.009-.481-.034-.882-.402-.926-.897-.111-1.237-.722-2.344-1.72-3.118-.029-.021-.11-.011-.16.049l-1.841,2.133c-.19.221-.468.349-.757.347-.288,0-.562-.124-.752-.341l-1.865-2.126c-.052-.058-.134-.069-.16-.047-.987.774-1.592,1.876-1.702,3.104-.049.55-.528.948-1.086.906-.55-.05-.956-.536-.906-1.086.158-1.757,1.055-3.396,2.46-4.499.876-.686,2.148-.552,2.897.303l1.107,1.262,1.09-1.263c.745-.865,2.019-1.007,2.9-.322.608.471,1.115,1.047,1.516,1.685.396-.629.893-1.197,1.489-1.665.874-.685,2.148-.553,2.897.304l1.107,1.261,1.09-1.263c.745-.865,2.02-1.005,2.899-.323,1.421,1.102,2.327,2.749,2.487,4.519Z'], - ] as $orgKey => [$orgLabel, $iconPath]) - - @endforeach -
-
- - {{-- Hidden org_type + all selected collection types --}} - - - - {{-- Core fields --}} -
- -
- - -
- -
- - -
- - -
- - -
-
- - {{-- Branding --}} -
-

Branding (optional)

-
-
- - -
- -
-
-
- - - -
- -
-
-
- - -
-
-
- - {{-- Online Giving --}} -
-

Online Giving

- - - {{-- Collection type manager --}} -
-

Collection types (shown as giving options)

- - {{-- Current types as removable tags --}} -
- -
- - {{-- Presets from selected org type --}} -
-

Add from preset

-
- -
-
- - {{-- Add custom --}} -
- - -
-
-
-
- -{{-- Event --}} -
-
- - - -
-
- - -
-
- - -
-
- -
- - -
-
- - {{-- Branding: optional logo + cover + color --}} -
-

Branding (optional)

-
-
- - -
-
-
- - - -
-
-
- - -
-
-
- - {{-- Mode: sell tickets, or collect cash contributions --}} - -
-

What is this event collecting?

-
- - - -
-
- - {{-- Ticket tiers --}} -
-

Ticket types (price 0 = free)

-
- -
- -
- - {{-- Contribution categories --}} -
-

Contribution categories (what supporters can give towards)

-
- -
- -

Supporters choose a category and enter any cash amount to give.

-
- - {{-- Badge fields --}} -
-

Extra registration fields (shown on badge — e.g. Company, Role)

-
- - -
-
- - {{-- Badge size + registration toggle --}} -
-
- - -
- -
-
- -{{-- Itinerary --}} -
-
- - -
-
- - -
- -
- -
- - {{-- Cover + brand color (no logo) --}} -
-
- - - -
- -
-
-
- - -
-
- - {{-- Programme builder --}} -
-

Programme

- - -
-
- -{{-- WiFi --}} -
- - - - -
- -{{-- App --}} -
- -
- -
-
- -
- -
-

Use a square image. Displayed as a rounded icon on the landing page.

-
- - - -
- -{{-- Book --}} -
-
-
- - -
-
- - -
-
-
- - -
-
- - -
-
-
- - -

Max 100 MB

-
-
- - - -
- -
-
-
-
- -{{-- Menu --}} -
- - {{-- Branding --}} -
-

Branding (optional)

-
-
- - -
- -
-

Use a square image for best results.

-
-
- - - -
- -
-
-
- - -
-
-
- - - - - - {{-- Delivery --}} -
- - -
-
- - -
-
- - -

Waive the delivery fee when the cart reaches this amount. Enter 0 to always charge.

-
-
-
- - - - -
- -{{-- Shop --}} -
- - {{-- Branding --}} -
-

Branding (optional)

-
-
- - -
- -
-

Use a square image for best results.

-
-
- - - -
- -
-
-
- - -
-
-
- -
- - -
- - - - {{-- Shipping --}} -
- - -
-
- - -
-
- - -

Waive shipping when the cart total reaches this amount. Enter 0 to always charge.

-
-
-
- - - - -
diff --git a/resources/views/qr-codes/partials/type-fields-edit.blade.php b/resources/views/qr-codes/partials/type-fields-edit.blade.php deleted file mode 100644 index 716b8c7..0000000 --- a/resources/views/qr-codes/partials/type-fields-edit.blade.php +++ /dev/null @@ -1,1157 +0,0 @@ -@php - $c = $qrCode->content(); - $style = $qrCode->style(); -@endphp - -
- @if($qrCode->isUrlType()) -
- - -
- - @elseif($qrCode->isDocumentType()) -
-
- - -
-
-
-

Allow download

-

Show a Download button on the PDF viewer

-
- -
-
- - @elseif($qrCode->type === \App\Models\QrCode::TYPE_LINK_LIST) - -
- @foreach($c['links'] ?? [] as $i => $link) -
- - -
- @endforeach -
- - @elseif($qrCode->type === \App\Models\QrCode::TYPE_VCARD) - @php $social = $c['social'] ?? []; @endphp -
- - {{-- Avatar --}} -
-
-
- - @if(!empty($c['avatar_path'])) - - @else -
- -
- @endif -
-
-
- - -
-
- - {{-- Name + contact --}} -
- - - - - - - - -
- - {{-- Social links --}} -
-

Social links (optional)

-
- @foreach([ - 'linkedin' => 'LinkedIn URL', - 'twitter' => 'X / Twitter URL', - 'instagram' => 'Instagram URL', - 'facebook' => 'Facebook URL', - 'tiktok' => 'TikTok URL', - 'youtube' => 'YouTube URL', - 'whatsapp' => 'WhatsApp link or number', - 'snapchat' => 'Snapchat username or URL', - ] as $platform => $placeholder) - - @endforeach -
-
- -
- - @elseif($qrCode->type === \App\Models\QrCode::TYPE_BUSINESS) - @php $bizSocial = $c['social'] ?? []; @endphp -
-
- - -
- - -
- - - -
- - {{-- Branding --}} -
-

Branding (optional)

-
-
- - -
- - @if(!empty($c['logo_path'])) - Current logo - @endif -
-
-
- - - -
- - @if(!empty($c['cover_path'])) - Current cover - @endif -
-
-
- - -
-
-
- - {{-- Social links --}} -
-

Social links (optional)

-
- @foreach([ - 'linkedin' => 'LinkedIn URL', - 'twitter' => 'X / Twitter URL', - 'instagram' => 'Instagram URL', - 'facebook' => 'Facebook URL', - 'tiktok' => 'TikTok URL', - 'youtube' => 'YouTube URL', - 'whatsapp' => 'WhatsApp link or number', - ] as $platform => $placeholder) - - @endforeach -
-
-
- - @elseif($qrCode->isImageType()) -
- - - @if(!empty($c['images'])) -

{{ count($c['images']) }} image(s) currently uploaded. Adding new images appends them.

- @endif -
- - @elseif($qrCode->type === \App\Models\QrCode::TYPE_CHURCH) - @php - $editOrgType = $c['org_type'] ?? 'church'; - $editOrgTypes = ['church','school','mosque','ngo','club']; - $editOrgLabels = ['church'=>'Church','school'=>'School','mosque'=>'Mosque','ngo'=>'NGO','club'=>'Club']; - $editTypes = array_values(array_filter($c['collection_types'] ?? ['Offering','Tithe','Donation','Harvest'])); - @endphp -
- - {{-- Org type selector --}} -
-

Organisation type

-
- @foreach([ - 'church' => ['Church', 'm21.941,12.493l-8.941-6.502v-1.991h1c.552,0,1-.448,1-1s-.448-1-1-1h-1v-1c0-.552-.448-1-1-1s-1,.448-1,1v1h-1c-.552,0-1,.448-1,1s.448,1,1,1h1v1.991L2.059,12.493c-1.289.938-2.059,2.45-2.059,4.044v3.463c0,2.206,1.794,4,4,4h16c2.206,0,4-1.794,4-4v-3.463c0-1.594-.77-3.106-2.059-4.044Zm-9.941,2.507c-1.654,0-3,1.346-3,3v4h-2v-10.627l5-3.636,5,3.636v10.627h-2v-4c0-1.654-1.346-3-3-3Zm-10,5v-3.463c0-.957.462-1.864,1.236-2.427l1.764-1.283v9.173h-1c-1.103,0-2-.897-2-2Zm9,2v-4c0-.551.449-1,1-1s1,.449,1,1v4h-2Zm11-2c0,1.103-.897,2-2,2h-1v-9.173l1.764,1.283c.774.563,1.236,1.47,1.236,2.427v3.463Z'], - 'school' => ['School', 'm21 6h-6.586l-1.159-1.159 3.745-2.341-4-2.5h-2v4.586l-1.414 1.414h-6.586a3 3 0 0 0 -3 3v15h24v-15a3 3 0 0 0 -3-3zm0 2a1 1 0 0 1 1 1v1h-3.586l-2-2zm-18 0h4.586l-2 2h-3.586v-1a1 1 0 0 1 1-1zm10 14h-2v-3a1 1 0 0 1 2 0zm2 0v-3a3 3 0 0 0 -6 0v3h-7v-10h4.414l5.586-5.586 5.586 5.586h4.414v10zm-11-8h3v2h-3zm0 4h3v2h-3zm13-4h3v2h-3zm0 4h3v2h-3zm-3-6a2 2 0 1 1 -2-2 2 2 0 0 1 2 2z'], - 'mosque' => ['Mosque', 'm21,22h-.949c1.228-1.354,1.949-2.91,1.949-4.39,0-4.285-3.708-6.157-6.415-7.523-.731-.369-1.718-.87-2.234-1.287-.032-.081-.229-.628-.312-1.807,1.193-.067,2.236-.683,2.887-1.599.202-.285-.038-.671-.383-.619-.175.026-.354.04-.537.04-2.111,0-3.822-1.711-3.822-3.822,0-.183.014-.362.04-.537.052-.345-.334-.585-.619-.383-1.051.747-1.708,2.009-1.593,3.422.106,1.301.884,2.414,1.976,3.019-.053,1.624-.31,2.248-.31,2.248l.008.007c-.505.422-1.524.939-2.272,1.316-2.707,1.367-6.415,3.239-6.415,7.523,0,1.48.721,3.036,1.949,4.39h-.949c-.553,0-1,.448-1,1s.447,1,1,1h18c.553,0,1-.448,1-1s-.447-1-1-1Zm-17-4.39c0-3.054,2.824-4.48,5.316-5.738,1.039-.524,2.003-1.011,2.684-1.597.681.586,1.645,1.072,2.684,1.597,2.492,1.258,5.316,2.684,5.316,5.738,0,1.457-1.171,3.169-2.987,4.39H6.987c-1.816-1.221-2.987-2.932-2.987-4.39Z'], - 'ngo' => ['NGO', 'm16,10v9c0,2.757-2.243,5-5,5h-6c-2.757,0-5-2.243-5-5L.007,4.872C.007,2.501,1.916.135,3.935.002c.538-.029,1.027.381,1.063.933.036.551-.381,1.027-.933,1.063-.899.059-2.059,1.463-2.059,2.874l-.007,14.128c0,1.654,1.346,3,3,3h6c1.654,0,3-1.346,3-3v-9c0-.553.448-1,1-1s1,.447,1,1Zm-12,1c0,.553.448,1,1,1h1c.552,0,1-.447,1-1s-.448-1-1-1h-1c-.552,0-1,.447-1,1Zm7-1h-1c-.552,0-1,.447-1,1s.448,1,1,1h1c.552,0,1-.447,1-1s-.448-1-1-1Zm-5,4h-1c-.552,0-1,.447-1,1s.448,1,1,1h1c.552,0,1-.447,1-1s-.448-1-1-1Zm5,0h-1c-.552,0-1,.447-1,1s.448,1,1,1h1c.552,0,1-.447,1-1s-.448-1-1-1Zm-5,4h-1c-.552,0-1,.447-1,1s.448,1,1,1h1c.552,0,1-.447,1-1s-.448-1-1-1Zm5,0h-1c-.552,0-1,.447-1,1s.448,1,1,1h1c.552,0,1-.447,1-1s-.448-1-1-1ZM21.5,0c-1.381,0-2.5,1.119-2.5,2.5v3c0,1.381,1.119,2.5,2.5,2.5s2.5-1.119,2.5-2.5v-3c0-1.381-1.119-2.5-2.5-2.5Zm.9,5.5c0,.496-.404.9-.9.9s-.9-.404-.9-.9v-3c0-.496.404-.9.9-.9s.9.404.9.9v3Zm-6.9-1.25h0c0,.414.336.75.75.75h.15v.468c0,.479-.361.898-.838.93-.524.035-.962-.381-.962-.898v-2.971c0-.434.296-.825.722-.911.283-.057.535.025.726.184.147.123.328.198.519.198h.062c.721,0,1.068-.894.528-1.372C16.711.233,16.124-.005,15.481,0c-1.383.01-2.481,1.175-2.481,2.558v2.886c0,1.402,1.147,2.582,2.548,2.556,1.358-.026,2.452-1.135,2.452-2.5v-1.25c0-.414-.336-.75-.75-.75h-1c-.414,0-.75.336-.75.75ZM10.4.8v3.263l-1.545-3.486C8.7.226,8.353,0,7.969,0h0C7.434,0,7,.434,7,.969v6.231c0,.442.358.8.8.8h0c.442,0,.8-.358.8-.8v-3.254l1.544,3.478c.156.35.503.576.886.576h0c.535,0,.97-.434.97-.97V.8C12,.358,11.642,0,11.2,0h0C10.758,0,10.4.358,10.4.8Z'], - 'club' => ['Club', 'm13.5,12c0,2.206,1.794,4,4,4s4-1.794,4-4-1.794-4-4-4-4,1.794-4,4Zm4-2c1.103,0,2,.897,2,2s-.897,2-2,2-2-.897-2-2,.897-2,2-2Zm-5.5-2c2.206,0,4-1.794,4-4S14.206,0,12,0s-4,1.794-4,4,1.794,4,4,4Zm0-6c1.103,0,2,.897,2,2s-.897,2-2,2-2-.897-2-2,.897-2,2-2Zm-5.5,14c2.206,0,4-1.794,4-4s-1.794-4-4-4-4,1.794-4,4,1.794,4,4,4Zm0-6c1.103,0,2,.897,2,2s-.897,2-2,2-2-.897-2-2,.897-2,2-2Zm17.471,12.91c.05.55-.356,1.036-.906,1.086-.03.002-.061.004-.091.004-.512,0-.948-.391-.995-.91-.11-1.219-.737-2.355-1.721-3.118-.025-.02-.107-.011-.16.049l-1.841,2.133c-.189.22-.485.389-.757.347-.288,0-.562-.124-.752-.34l-1.865-2.126c-.051-.059-.133-.07-.16-.048-.986.774-1.591,1.876-1.701,3.104-.043.476-.413.82-.873.881-.029.005-.054.022-.084.025-.031.002-.061.004-.091.004-.01,0-.018-.005-.027-.005-.004,0-.007.002-.011.001-.011,0-.02-.008-.031-.009-.481-.034-.882-.402-.926-.897-.111-1.237-.722-2.344-1.72-3.118-.029-.021-.11-.011-.16.049l-1.841,2.133c-.19.221-.468.349-.757.347-.288,0-.562-.124-.752-.341l-1.865-2.126c-.052-.058-.134-.069-.16-.047-.987.774-1.592,1.876-1.702,3.104-.049.55-.528.948-1.086.906-.55-.05-.956-.536-.906-1.086.158-1.757,1.055-3.396,2.46-4.499.876-.686,2.148-.552,2.897.303l1.107,1.262,1.09-1.263c.745-.865,2.019-1.007,2.9-.322.608.471,1.115,1.047,1.516,1.685.396-.629.893-1.197,1.489-1.665.874-.685,2.148-.553,2.897.304l1.107,1.261,1.09-1.263c.745-.865,2.02-1.005,2.899-.323,1.421,1.102,2.327,2.749,2.487,4.519Z'], - ] as $orgKey => [$orgLabel, $iconPath]) - - @endforeach -
-
- - {{-- Hidden org_type + collection types --}} - - - - {{-- Core fields --}} -
- -
- - -
- -
- - -
- - -
- - -
-
- - {{-- Branding --}} -
-

Branding (optional)

-
-
- - -
- - @if(!empty($c['logo_path'])) - Current logo - @endif -
-
-
- - - -
- - @if(!empty($c['cover_path'])) - Current cover - @endif -
-
-
- - -
-
-
- - {{-- Online Giving --}} -
-

Online Giving

- - -
-

Collection types

-
- -
-
-

Add from preset

-
- -
-
-
- - -
-
-
-
- - @elseif($qrCode->type === \App\Models\QrCode::TYPE_EVENT) - @php - $evTiers = $c['tiers'] ?? [['name' => 'General Admission', 'price' => '', 'capacity' => '']]; - $evBadgeFields = array_values($c['badge_fields'] ?? []); - $evMode = in_array($c['mode'] ?? 'ticketing', ['contributions', 'free'], true) ? ($c['mode'] ?? 'ticketing') : 'ticketing'; - $evCategories = array_values($c['contribution_categories'] ?? ['Contribution']); - @endphp -
-
- - - -
-
- - -
-
- - -
-
- -
- - -
-
- - {{-- Branding --}} -
-

Branding (optional)

-
-
- - -
- - @if(!empty($c['logo_path']))Current logo@endif -
-
-
- - - -
- - @if(!empty($c['cover_path']))Current cover@endif -
-
-
- - -
-
-
- - {{-- Mode: sell tickets, or collect cash contributions --}} - -
-

What is this event collecting?

-
- - - -
-
- - {{-- Ticket tiers --}} -
-

Ticket types (price 0 = free)

-
- -
- -
- - {{-- Contribution categories --}} -
-

Contribution categories (what supporters can give towards)

-
- -
- -

Supporters choose a category and enter any cash amount to give.

-
- - {{-- Badge fields --}} -
-

Extra registration fields (shown on badge)

-
- - -
-
- - {{-- Badge size + registration toggle --}} -
-
- - -
- -
- - {{-- Programme outline --}} - @php $itineraryOptions = $itineraryOptions ?? collect(); @endphp -
- - @if($itineraryOptions->isEmpty()) -

- Create an Itinerary QR code first to attach it here as the event programme. -

- @else - -

Attendees can be sent this programme from the attendees page.

- @endif -
- - @if (Route::has('events.attendees')) -
- - - Manage attendees - - @if (Route::has('events.badge-printing') && ($c['mode'] ?? 'ticketing') !== 'contributions') - - - Print badges - - @endif -
- @endif -
- - @elseif($qrCode->type === \App\Models\QrCode::TYPE_ITINERARY) - @php - $itinDays = $c['days'] ?? [['label' => 'Day 1', 'date' => '', 'items' => [['time' => '', 'title' => '', 'description' => '', 'location' => '', 'host' => '']]]]; - foreach ($itinDays as &$itinDay) { - $itinDay['date'] = \App\Support\Qr\QrDateFormatter::forInput($itinDay['date'] ?? null); - } - unset($itinDay); - @endphp -
-
- - -
-
- - -
- -
- -
- - {{-- Cover + brand color --}} -
-
- - - -
- - @if(!empty($c['cover_path'])) - Current cover - @endif -
-
-
- - -
-
- - {{-- Programme builder --}} -
-

Programme

- - -
-
- - @elseif($qrCode->type === \App\Models\QrCode::TYPE_WIFI) -
- - - - -
- - @elseif($qrCode->type === \App\Models\QrCode::TYPE_APP) -
- -
- -
-
- - @if(!empty($c['icon_path'])) - Current icon - @else -
- - - -
- @endif -
- -
-

Use a square image. Displayed as a rounded icon on the landing page.

-
- - - -
- - @elseif($qrCode->type === \App\Models\QrCode::TYPE_BOOK) -
-
-
- - -
-
- - -
-
-
- - -
-
- - -
-
-
- - - @if(!empty($c['file_path'])) -

Current: {{ strtoupper($c['file_type'] ?? 'file') }} · {{ number_format(($c['file_size'] ?? 0) / 1048576, 1) }} MB

- @endif -
-
- - - -
- -
- @if(!empty($c['cover_path'])) -
-

Current cover:

- Current cover -
- @endif -
-
-
- - @elseif($qrCode->type === \App\Models\QrCode::TYPE_MENU) - {{-- Branding --}} -
-

Branding (optional)

-
-
- - -
- -
-

Use a square image for best results.

- @if(!empty($c['logo_path'])) -
- Current logo -
- @endif -
-
- - - -
- -
- @if(!empty($c['cover_path'])) -
- Current cover -
- @endif -
-
- - -
-
-
- - - - - - {{-- Delivery --}} -
- - -
-
- - -
-
- - -

Waive the delivery fee when the cart reaches this amount. Enter 0 to always charge.

-
-
-
- -
- - - -
- - @elseif($qrCode->type === \App\Models\QrCode::TYPE_SHOP) - {{-- Branding --}} -
-

Branding (optional)

-
-
- - -
- -
-

Use a square image for best results.

- @if(!empty($c['logo_path'])) -
- Current logo -
- @endif -
-
- - - -
- -
- @if(!empty($c['cover_path'])) -
- Current cover -
- @endif -
-
- - -
-
-
- -
- - -
- - - - {{-- Shipping --}} -
- - -
-
- - -
-
- - -

Waive shipping when the cart total reaches this amount. Enter 0 to always charge.

-
-
-
- -
- - - -
- - @endif -
diff --git a/resources/views/qr-codes/show.blade.php b/resources/views/qr-codes/show.blade.php deleted file mode 100644 index 1b27a1f..0000000 --- a/resources/views/qr-codes/show.blade.php +++ /dev/null @@ -1,343 +0,0 @@ - - {{ $qrCode->label }} - - @php - $qrStyle = $qrCode->style(); - @endphp - -
- - @foreach(['success', 'error'] as $flash) - @if(session($flash)) -
- {{ session($flash) }} -
- @endif - @endforeach - - {{-- Mobile page header --}} -
- - - - - - {{ $qrCode->label }} -
- - {{-- Desktop page header --}} - - - {{-- Main 2-column layout --}} -
- @csrf - @method('PATCH') - - {{-- Left: Sticky preview + downloads (desktop only) --}} - - - {{-- Right: Controls --}} -
- - {{-- Content first --}} -
-
-

Content

-

- @if($qrCode->type === \App\Models\QrCode::TYPE_WIFI) - Auto-join is locked to the network set when this code was created. Editing updates your saved info but won't change the printed code. - @else - Edits are free. The printed QR stays the same. - @endif -

-
-
-
- - -
- @include('qr-codes.partials.type-fields-edit') - -
-
- - {{-- Customizer --}} - @include('qr-codes.partials.customization-fields', [ - 'style' => $qrStyle, - 'moduleStyles' => $moduleStyles, - 'cornerOuterStyles' => $cornerOuterStyles, - 'cornerInnerStyles' => $cornerInnerStyles, - 'frameStyles' => $frameStyles, - 'showRemoveLogo' => ! empty($qrStyle['logo_path']), - ]) - - {{-- Desktop save button --}} - - -
-
- - {{-- Mobile sticky action bar --}} -
-
- - -
- -
- - {{-- Preview modal --}} -
-
- Preview - -
-
-
-
-
-
- -
-
- -
-
-
-
- {{-- Downloads pinned to bottom of modal --}} - @php - $mobileShareUrl = $qrCode->publicUrl(); - $mobileShareText = urlencode($qrCode->label . ' — scan my QR code'); - $mobileShareEnc = urlencode($mobileShareUrl); - @endphp -
- - PNG - - - SVG - - - PDF - - -
-
- - {{-- Orders (Book / Menu / Shop) --}} - @if(isset($orders) && !is_null($orders)) -
-
-

Orders

- {{ $orders->count() }} paid -
- -
-
-

{{ $orders->count() }}

-

Total orders

-
-
-

GHS {{ number_format((float) $orders->sum('amount_ghs'), 2) }}

-

Revenue earned

-
-
- - @if($orders->isEmpty()) -
-

No paid orders yet.

-
- @else -
- - - - - - - - - - - @foreach($orders as $order) - - - - - - - @endforeach - -
CustomerAmountDate
-

{{ $order->customer_name ?: '—' }}

-

{{ $order->customer_email ?: '' }}

-
- GHS {{ number_format((float) $order->amount_ghs, 2) }} - - {{ $order->paid_at?->format('M j, Y') }} -
-
- @endif -
- @endif - - {{-- Scan analytics --}} -
-

Analytics

- -
-
-

{{ number_format($summary['total_scans']) }}

-

Total scans

-
-
-

{{ number_format($summary['unique_scans']) }}

-

Unique scans

-
-
-

{{ number_format($summary['scans_7d']) }}

-

Last 7 days

-
-
-

{{ number_format($summary['scans_30d']) }}

-

Last 30 days

-
-
- -
-

Scans — last 30 days

- @php $maxDaily = max(1, $dailyScans->max('total')); @endphp -
- @foreach($dailyScans as $day) -
-
-
- @endforeach -
-
- -
-
-

Devices

-
    - @forelse($devices as $row) -
  • - {{ ucfirst($row['label']) }} - {{ $row['total'] }} -
  • - @empty -
  • No scans yet
  • - @endforelse -
-
-
-

Browsers

-
    - @forelse($browsers as $row) -
  • - {{ $row['label'] }} - {{ $row['total'] }} -
  • - @empty -
  • No scans yet
  • - @endforelse -
-
-
-
- -
-
diff --git a/resources/views/qr/account/billing.blade.php b/resources/views/qr/account/billing.blade.php deleted file mode 100644 index e29d615..0000000 --- a/resources/views/qr/account/billing.blade.php +++ /dev/null @@ -1,13 +0,0 @@ - - Billing - @php $fmt = fn ($m) => 'GHS '.number_format($m / 100, 2); @endphp -
-

Billing

-

QR code creation is billed from your Ladill wallet (GHS {{ number_format(config('qr.price_per_qr_ghs', 5), 2) }} per code).

-
-

Current balance

-

{{ $fmt($balanceMinor) }}

- Top up wallet -
-
-
diff --git a/resources/views/qr/account/developers.blade.php b/resources/views/qr/account/developers.blade.php deleted file mode 100644 index d506b9a..0000000 --- a/resources/views/qr/account/developers.blade.php +++ /dev/null @@ -1,87 +0,0 @@ - - Developers -
-

Developers

-

API tokens to manage your QR codes programmatically.

- - @if($newToken) -
-

Your new token — copy it now

-

This is the only time it will be shown.

-
- {{ $newToken }} - -
-
- @endif - -
-

Create a token

-
- @csrf -
- - - @error('name')

{{ $message }}

@enderror -
- -
-
- -
-

Your tokens

- @forelse($tokens as $token) -
-
-

{{ $token->name }}

-

- Created {{ $token->created_at->diffForHumans() }} · - {{ $token->last_used_at ? 'last used '.$token->last_used_at->diffForHumans() : 'never used' }} -

-
- - - - - -
- @empty -

No tokens yet.

- @endforelse -
- -
-

Quick start

-

Authenticate with a Bearer token. Base URL:

- {{ $apiBase }} -
curl {{ $apiBase }}/qr-codes \
-  -H "Authorization: Bearer <your-token>" \
-  -H "Accept: application/json"
-

Endpoints:

-
    -
  • GET /me — token user and acting account
  • -
  • GET /qr-codes — list your codes
  • -
  • GET /qr-codes/{id} — code details
  • -
  • GET /qr-codes/{id}/analytics — scan stats
  • -
  • POST /qr-codes — create (url, link_list, wifi, business, app)
  • -
  • PATCH /qr-codes/{id} — update label, content, pause/resume
  • -
-
curl -X POST {{ $apiBase }}/qr-codes \
-  -H "Authorization: Bearer <your-token>" \
-  -H "Content-Type: application/json" \
-  -d '{"label":"Homepage","type":"url","destination_url":"https://example.com"}'
-

Team access: pass X-Ladill-Account: <owner-user-id>. PDF codes need the web app. Regenerate tokens to get write access if yours only has read.

-
-
-
diff --git a/resources/views/qr/account/settings.blade.php b/resources/views/qr/account/settings.blade.php deleted file mode 100644 index ace0e9f..0000000 --- a/resources/views/qr/account/settings.blade.php +++ /dev/null @@ -1,260 +0,0 @@ - - Settings - - @php - $ed = $eventDefaults; - $badgeFieldLabels = old('event_defaults.badge_fields', $ed['badge_fields'] ?? ['Company', 'Role']); - @endphp - -
-

Settings

-

Notifications, defaults for new events, and QR preferences.

- - @if (session('success')) -
{{ session('success') }}
- @endif - -
- @csrf - @method('PUT') - -
-

Notifications

-

Choose what we email you about your events and account.

- -
- - - @error('notify_email')

{{ $message }}

@enderror -
- - - - - - - - -
- -
-

New event defaults

-

Pre-fill the create flow so every new event starts with your usual setup.

- -
-
- - -
-
- - -
-
- -
-
- - -
-
- - -
-
- -
- - -
- -
- -

Extra attendee fields shown on badges (e.g. Company, Role).

-
- -
- -
- - -
- -
- -
-
-

QR code defaults

-

Style applied when you design event QR codes.

-
- -
-
- -
-
-
- - -
-
- - -
-
- -
-
- - -
-
- - -
-
- -
-
- - -
-
- - -
-
- -
-
- - -
-
- - -
-
- -
- - -
-
-
- - -
- -

- Profile, password, and security are managed on - account.ladill.com. -

-
-
diff --git a/resources/views/qr/account/team.blade.php b/resources/views/qr/account/team.blade.php deleted file mode 100644 index cd3ec45..0000000 --- a/resources/views/qr/account/team.blade.php +++ /dev/null @@ -1,92 +0,0 @@ - - Team -
-

Team

-

Invite people to help manage this account’s QR codes.

- - @if($canManage) -
-

Invite a teammate

-
- @csrf -
- - - @error('email')

{{ $message }}

@enderror -
-
- - -
- -
-

Admins can manage QR codes and the team. Members can manage QR codes. Invitees join by signing in with that email.

-
- @endif - -
-

Members

-
    -
  • -
    - {{ strtoupper(substr($account->name ?? $account->email, 0, 1)) }} -
    -

    - {{ $account->name ?? $account->email }} - @if($isOwner)(you)@endif -

    -

    {{ $account->email }}

    -
    -
    - Owner -
  • - - @forelse($members as $member) -
  • -
    - {{ strtoupper(substr($member->email, 0, 1)) }} -
    -

    {{ $member->member->name ?? $member->email }}

    -

    {{ $member->email }}

    -
    -
    -
    - @if($member->status === 'invited') - Invited - @endif - @if($canManage) -
    - @csrf @method('PATCH') - -
    - - - - - - @else - {{ $member->role }} - @endif -
    -
  • - @empty -
  • No teammates yet.
  • - @endforelse -
-
-
-
diff --git a/resources/views/qr/account/wallet.blade.php b/resources/views/qr/account/wallet.blade.php deleted file mode 100644 index cc2074a..0000000 --- a/resources/views/qr/account/wallet.blade.php +++ /dev/null @@ -1,23 +0,0 @@ - - Wallet - @php $fmt = fn ($m) => 'GHS '.number_format($m / 100, 2); @endphp -
-

Wallet

-

Your Ladill wallet funds QR Plus and other Ladill apps.

-
-

Balance

-

{{ $fmt($balanceMinor) }}

- Add funds -
-
-
-

Spent on QR Plus

-

{{ $fmt($spentMinor) }}

-
-
-

Refunded / credited

-

{{ $fmt($creditedMinor) }}

-
-
-
-
diff --git a/resources/views/qr/dashboard.blade.php b/resources/views/qr/dashboard.blade.php deleted file mode 100644 index 68ed5a4..0000000 --- a/resources/views/qr/dashboard.blade.php +++ /dev/null @@ -1,45 +0,0 @@ - - Overview - @php $fmt = fn ($m) => 'GHS '.number_format($m / 100, 2); @endphp -
-
-

QR Plus

-

Dynamic utility QR codes — links, WiFi, business pages & more.

-
-
-
-

Wallet balance

-

{{ $fmt($balanceMinor) }}

-
-
-

Active codes

-

{{ $activeCount }}

-
-
-

Scans (30d)

-

{{ number_format($scans30d) }}

-
-
-
-
-

Recent codes

- Create code -
- @if($recentCodes->isEmpty()) -

No QR codes yet. Create your first code.

- @else - - @endif -
-
-
diff --git a/resources/views/qr/signed-out.blade.php b/resources/views/qr/signed-out.blade.php deleted file mode 100644 index b73841b..0000000 --- a/resources/views/qr/signed-out.blade.php +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - Signed out — Ladill Events - @include('partials.favicon') - - -
-

You have been signed out of Ladill Events.

- Sign in again -
- - diff --git a/resources/views/search/index.blade.php b/resources/views/search/index.blade.php deleted file mode 100644 index 48b5c11..0000000 --- a/resources/views/search/index.blade.php +++ /dev/null @@ -1,13 +0,0 @@ - - Search - - @include('partials.search-screen', [ - 'query' => $query, - 'results' => $results, - 'backUrl' => route('mini.dashboard'), - 'searchUrl' => route('mini.search'), - 'heading' => 'Find payments', - 'placeholder' => 'Search payer, note, reference, QR…', - 'emptyHint' => 'Use at least 2 characters to search payer names, notes, references, or QR labels.', - ]) - diff --git a/routes/api.php b/routes/api.php index c546bca..1d8a31c 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,76 +1,4 @@ group(function () { - // Mobile token auth (Ladill Mini Android). Public — proxies to the central - // Ladill identity API and issues a Sanctum token. - Route::post('/auth/login', [AuthController::class, 'login'])->middleware('throttle:10,1')->name('api.auth.login'); - Route::post('/auth/register', [AuthController::class, 'register'])->middleware('throttle:10,1')->name('api.auth.register'); - - Route::middleware(['auth:sanctum', \App\Http\Middleware\SetActingAccount::class])->group(function () { - Route::post('/auth/logout', [AuthController::class, 'logout'])->name('api.auth.logout'); - Route::get('/me', MeController::class); - - Route::get('/qr-codes', [QrCodeController::class, 'index']); - Route::post('/qr-codes', [QrCodeController::class, 'store']); - Route::get('/qr-codes/{qrCode}', [QrCodeController::class, 'show'])->whereNumber('qrCode'); - Route::patch('/qr-codes/{qrCode}', [QrCodeController::class, 'update'])->whereNumber('qrCode'); - Route::get('/qr-codes/{qrCode}/analytics', [QrCodeController::class, 'analytics'])->whereNumber('qrCode'); - - // Ladill Mini — payments product. - Route::get('/mini/overview', MiniOverviewController::class)->name('api.mini.overview'); - - Route::get('/mini/notifications', [MiniNotificationController::class, 'index'])->name('api.mini.notifications.index'); - Route::get('/mini/notifications/unread-count', [MiniNotificationController::class, 'unreadCount'])->name('api.mini.notifications.unread-count'); - Route::post('/mini/notifications/{id}/read', [MiniNotificationController::class, 'markAsRead'])->name('api.mini.notifications.read'); - Route::post('/mini/notifications/mark-all-read', [MiniNotificationController::class, 'markAllAsRead'])->name('api.mini.notifications.mark-all-read'); - - Route::post('/mini/push-token', [MiniPushTokenController::class, 'store'])->name('api.mini.push-token.store'); - Route::delete('/mini/push-token', [MiniPushTokenController::class, 'destroy'])->name('api.mini.push-token.destroy'); - Route::post('/mini/afia/chat', [MiniAfiaController::class, 'chat'])->middleware('throttle:30,1')->name('api.mini.afia.chat'); - Route::get('/mini/payment-qrs', [MiniPaymentQrController::class, 'index'])->name('api.mini.payment-qrs.index'); - Route::post('/mini/payment-qrs', [MiniPaymentQrController::class, 'store'])->name('api.mini.payment-qrs.store'); - Route::get('/mini/payment-qrs/{paymentQr}', [MiniPaymentQrController::class, 'show'])->whereNumber('paymentQr')->name('api.mini.payment-qrs.show'); - Route::patch('/mini/payment-qrs/{paymentQr}', [MiniPaymentQrController::class, 'update'])->whereNumber('paymentQr')->name('api.mini.payment-qrs.update'); - Route::delete('/mini/payment-qrs/{paymentQr}', [MiniPaymentQrController::class, 'destroy'])->whereNumber('paymentQr')->name('api.mini.payment-qrs.destroy'); - Route::get('/mini/payment-qrs/{paymentQr}/preview.png', [MiniPaymentQrController::class, 'preview'])->whereNumber('paymentQr')->name('api.mini.payment-qrs.preview'); - - Route::get('/mini/payments', [MiniPaymentsController::class, 'index'])->name('api.mini.payments.index'); - Route::get('/mini/payouts', MiniPayoutsController::class)->name('api.mini.payouts'); - - // Account self-service (Settings, Wallet, Support) — keeps Mini usable - // entirely in-app without the website. - Route::get('/mini/account/settings', [MiniAccountController::class, 'settings'])->name('api.mini.account.settings'); - Route::put('/mini/account/settings', [MiniAccountController::class, 'updateSettings'])->name('api.mini.account.settings.update'); - Route::put('/mini/account/profile', [MiniAccountController::class, 'updateProfile'])->name('api.mini.account.profile'); - Route::post('/mini/account/avatar', [MiniAccountController::class, 'uploadAvatar'])->name('api.mini.account.avatar'); - Route::post('/mini/account/change-password', [MiniAccountController::class, 'changePassword'])->name('api.mini.account.change-password'); - - Route::get('/mini/wallet', [MiniWalletController::class, 'show'])->name('api.mini.wallet'); - Route::post('/mini/wallet/topup', [MiniWalletController::class, 'topup'])->name('api.mini.wallet.topup'); - Route::get('/mini/wallet/banks', [MiniWalletController::class, 'banks'])->name('api.mini.wallet.banks'); - Route::get('/mini/wallet/payout-account', [MiniWalletController::class, 'payoutAccount'])->name('api.mini.wallet.payout-account'); - Route::put('/mini/wallet/payout-account', [MiniWalletController::class, 'updatePayoutAccount'])->name('api.mini.wallet.payout-account.update'); - Route::get('/mini/wallet/withdrawals', [MiniWalletController::class, 'withdrawals'])->name('api.mini.wallet.withdrawals'); - Route::post('/mini/wallet/withdraw', [MiniWalletController::class, 'withdraw'])->name('api.mini.wallet.withdraw'); - Route::match(['put', 'post'], '/mini/wallet/auto-withdraw', [MiniWalletController::class, 'updateAutoWithdraw'])->name('api.mini.wallet.auto-withdraw'); - - Route::get('/mini/support/tickets', [MiniSupportController::class, 'tickets'])->name('api.mini.support.tickets'); - Route::post('/mini/support/tickets', [MiniSupportController::class, 'store'])->name('api.mini.support.tickets.store'); - Route::get('/mini/support/tickets/{ticket}', [MiniSupportController::class, 'ticket'])->whereNumber('ticket')->name('api.mini.support.ticket'); - }); -}); +// Ladill POS is a web-only in-store register; it exposes no JSON API. +// (The mobile/QR API surface from the upstream fork was removed.) diff --git a/routes/console.php b/routes/console.php index c63efc4..3c9adf1 100644 --- a/routes/console.php +++ b/routes/console.php @@ -2,19 +2,7 @@ use Illuminate\Foundation\Inspiring; use Illuminate\Support\Facades\Artisan; -use Illuminate\Support\Facades\Schedule; Artisan::command('inspire', function () { $this->comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); - -Schedule::command('mini:cancel-stale-payments')->hourly()->withoutOverlapping(); -Schedule::command('mini:process-auto-withdrawals')->everyFiveMinutes()->withoutOverlapping(); - -Schedule::command('hosting:check-node-health --all')->everyFiveMinutes()->withoutOverlapping(); -Schedule::command('hosting:sync-account-usage')->hourly()->withoutOverlapping(); -Schedule::command('hosting:recalculate-node-capacity --shared-only')->daily()->withoutOverlapping(); -Schedule::command('hosting:process-expired-accounts')->daily()->withoutOverlapping(); -Schedule::command('hosting:notify-expiring')->daily()->withoutOverlapping(); -Schedule::command('hosting:retry-pending-fulfillment')->everyFifteenMinutes()->withoutOverlapping(); -Schedule::command('ssl:renew')->daily()->withoutOverlapping(); diff --git a/tests/Feature/AfiaTest.php b/tests/Feature/AfiaTest.php deleted file mode 100644 index 7868306..0000000 --- a/tests/Feature/AfiaTest.php +++ /dev/null @@ -1,54 +0,0 @@ - (string) Str::uuid(), 'name' => 'A', 'email' => 'a+'.uniqid().'@x.com']); - } - - public function test_requires_a_message(): void - { - config(['afia.api_key' => 'sk-test']); - $this->actingAs($this->user())->postJson('/afia/chat', [])->assertStatus(422); - } - - public function test_returns_503_when_not_configured(): void - { - config(['afia.api_key' => '']); - $this->actingAs($this->user())->postJson('/afia/chat', ['message' => 'hi'])->assertStatus(503); - } - - public function test_returns_reply_from_llm(): void - { - config(['afia.api_key' => 'sk-test', 'afia.provider' => 'openai', 'afia.model' => 'gpt-4o-mini']); - Http::fake([ - 'api.openai.com/*' => Http::response(['choices' => [['message' => ['content' => 'Create a Business QR under My Codes.']]]]), - ]); - - $this->actingAs($this->user()) - ->postJson('/afia/chat', ['message' => 'How do I create a business QR?']) - ->assertOk() - ->assertJson(['reply' => 'Create a Business QR under My Codes.']); - - Http::assertSent(fn ($r) => str_contains($r->url(), 'openai.com') - && collect($r['messages'])->first()['role'] === 'system' - && str_contains(collect($r['messages'])->first()['content'], 'Ladill QR Plus')); - } -} diff --git a/tests/Feature/AssetLinksTest.php b/tests/Feature/AssetLinksTest.php deleted file mode 100644 index 6ec13e1..0000000 --- a/tests/Feature/AssetLinksTest.php +++ /dev/null @@ -1,41 +0,0 @@ - []]); - - $this->get('/.well-known/assetlinks.json')->assertNotFound(); - } - - public function test_assetlinks_returns_json_when_configured(): void - { - config([ - 'android_app_links.package_name' => 'com.ladill.mini', - 'android_app_links.sha256_cert_fingerprints' => [ - 'AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99', - ], - ]); - - $this->get('/.well-known/assetlinks.json') - ->assertOk() - ->assertHeader('Content-Type', 'application/json') - ->assertJson([ - [ - 'relation' => ['delegate_permission/common.handle_all_urls'], - 'target' => [ - 'namespace' => 'android_app', - 'package_name' => 'com.ladill.mini', - 'sha256_cert_fingerprints' => [ - 'AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99', - ], - ], - ], - ]); - } -} diff --git a/tests/Feature/ImportEventsCommandTest.php b/tests/Feature/ImportEventsCommandTest.php deleted file mode 100644 index 6bb83c8..0000000 --- a/tests/Feature/ImportEventsCommandTest.php +++ /dev/null @@ -1,82 +0,0 @@ -create(['public_id' => 'usr_events_owner']); - - $payload = [ - 'qr_wallets' => [], - 'qr_codes' => [ - [ - 'platform_id' => 10, - 'owner_public_id' => $owner->public_id, - 'owner_email' => $owner->email, - 'short_code' => 'demo-programme', - 'type' => QrCode::TYPE_ITINERARY, - 'label' => 'Demo Programme', - 'payload' => json_encode(['content' => ['title' => 'Demo Programme'], 'style' => []]), - 'is_active' => true, - 'scans_total' => 0, - 'created_at' => now()->toDateTimeString(), - 'updated_at' => now()->toDateTimeString(), - 'destination_updated_at' => now()->toDateTimeString(), - ], - [ - 'platform_id' => 11, - 'owner_public_id' => $owner->public_id, - 'owner_email' => $owner->email, - 'short_code' => 'demo-event', - 'type' => QrCode::TYPE_EVENT, - 'label' => 'Demo Event', - 'payload' => json_encode([ - 'content' => ['name' => 'Demo Event', 'programme_qr_id' => 10], - 'style' => [], - ]), - 'is_active' => true, - 'scans_total' => 0, - 'created_at' => now()->toDateTimeString(), - 'updated_at' => now()->toDateTimeString(), - 'destination_updated_at' => now()->toDateTimeString(), - ], - ], - 'qr_event_registrations' => [], - 'qr_scan_events' => [], - 'qr_transactions' => [], - ]; - - $exportPath = storage_path('framework/testing/events-import.json'); - if (! is_dir(dirname($exportPath))) { - mkdir(dirname($exportPath), 0777, true); - } - file_put_contents($exportPath, json_encode($payload)); - - Artisan::call('events:import', [ - 'file' => $exportPath, - '--commit' => true, - ]); - - $event = QrCode::where('short_code', 'demo-event')->first(); - $programme = QrCode::where('short_code', 'demo-programme')->first(); - - $this->assertNotNull($event); - $this->assertNotNull($programme); - $this->assertIsArray($event->payload); - $this->assertSame('Demo Event', $event->content()['name'] ?? null); - $this->assertSame($programme->id, $event->content()['programme_qr_id'] ?? null); - } -} diff --git a/tests/Feature/QrSettingsTest.php b/tests/Feature/QrSettingsTest.php deleted file mode 100644 index 07b870f..0000000 --- a/tests/Feature/QrSettingsTest.php +++ /dev/null @@ -1,108 +0,0 @@ - (string) Str::uuid(), 'name' => 'A', 'email' => 'a+'.uniqid().'@x.com']); - } - - public function test_settings_page_renders_for_authenticated_user(): void - { - $this->actingAs($this->user()) - ->get(route('account.settings')) - ->assertOk() - ->assertSee('favicon.ico', false) - ->assertSee('New event defaults') - ->assertSee('QR code defaults') - ->assertSee('New registrations') - ->assertSee('Notifications'); - } - - public function test_can_save_qr_settings(): void - { - $user = $this->user(); - - $this->actingAs($user) - ->put(route('account.settings.update'), [ - 'notify_email' => 'alerts@example.com', - 'product_updates' => '1', - 'low_balance_alerts' => '0', - 'notify_registrations' => '1', - 'notify_payouts' => '0', - 'event_defaults' => [ - 'currency' => 'USD', - 'mode' => 'free', - 'badge_size' => '4x6', - 'brand_color' => '#aabbcc', - 'organizer' => 'CAPBuSS', - 'registration_open' => '1', - 'badge_fields' => ['Company', 'Title'], - ], - 'default_style' => [ - 'foreground' => '#112233', - 'background' => '#ffffff', - 'module_style' => 'dots', - 'frame_style' => 'scan_me', - 'frame_color' => '#445566', - 'frame_text' => 'SCAN ME', - ], - ]) - ->assertRedirect(route('account.settings')); - - $settings = QrSetting::where('user_id', $user->id)->first(); - - $this->assertNotNull($settings); - $this->assertSame('alerts@example.com', $settings->notify_email); - $this->assertTrue($settings->product_updates); - $this->assertFalse($settings->low_balance_alerts); - $this->assertTrue($settings->notify_registrations); - $this->assertFalse($settings->notify_payouts); - $this->assertSame('USD', $settings->resolvedEventDefaults()['currency']); - $this->assertSame('free', $settings->resolvedEventDefaults()['mode']); - $this->assertSame('CAPBuSS', $settings->resolvedEventDefaults()['organizer']); - $this->assertSame('#112233', $settings->resolvedDefaultStyle()['foreground']); - $this->assertSame('scan_me', $settings->resolvedDefaultStyle()['frame_style']); - } - - public function test_create_page_uses_saved_defaults(): void - { - $user = $this->user(); - QrSetting::create([ - 'user_id' => $user->id, - 'default_type' => QrCode::TYPE_EVENT, - 'default_style' => ['foreground' => '#aabbcc', 'module_style' => 'dots'], - 'event_defaults' => ['mode' => 'contributions', 'organizer' => 'Test Org', 'brand_color' => '#ff00aa'], - ]); - - Http::fake([ - config('billing.api_url').'/balance*' => Http::response(['balance_minor' => 50000]), - ]); - - $this->actingAs($user) - ->get(route('events.create')) - ->assertOk() - ->assertSee('favicon.ico', false) - ->assertSee('#aabbcc', false) - ->assertSee('Test Org', false) - ->assertSee('"event"', false); - } -} diff --git a/tests/Feature/SearchTest.php b/tests/Feature/SearchTest.php deleted file mode 100644 index 7c87c64..0000000 --- a/tests/Feature/SearchTest.php +++ /dev/null @@ -1,64 +0,0 @@ - (string) Str::uuid(), 'name' => 'A', 'email' => 'a+'.uniqid().'@x.com']); - } - - public function test_user_can_search_payments_by_payer_name(): void - { - $user = $this->user(); - - $qr = QrCode::query()->create([ - 'user_id' => $user->id, - 'short_code' => 'till01', - 'type' => QrCode::TYPE_PAYMENT, - 'label' => 'Main till', - 'destination_url' => 'https://example.com', - 'is_active' => true, - ]); - - MiniPayment::query()->create([ - 'qr_code_id' => $qr->id, - 'user_id' => $user->id, - 'reference' => 'pay-'.Str::uuid(), - 'amount_minor' => 5000, - 'currency' => 'GHS', - 'platform_fee_minor' => 250, - 'merchant_amount_minor' => 4750, - 'payer_name' => 'Ama Mensah', - 'status' => MiniPayment::STATUS_PAID, - 'paid_at' => now(), - ]); - - $response = $this->actingAs($user) - ->getJson(route('mini.search', ['q' => 'Ama'])); - - $response->assertOk(); - $response->assertJsonPath('results.0.type', 'payment'); - $response->assertJsonPath('results.0.title', 'Ama Mensah'); - } - - public function test_search_page_renders_for_authenticated_user(): void - { - $user = $this->user(); - - $this->actingAs($user) - ->get(route('mini.search')) - ->assertOk() - ->assertSee('Find payments', false); - } -} diff --git a/tests/Unit/Services/Qr/PaymentQrUpdateTest.php b/tests/Unit/Services/Qr/PaymentQrUpdateTest.php deleted file mode 100644 index 7949c3a..0000000 --- a/tests/Unit/Services/Qr/PaymentQrUpdateTest.php +++ /dev/null @@ -1,57 +0,0 @@ - (string) Str::uuid(), - 'name' => 'Trader', - 'email' => 'trader@example.com', - ]); - - $qrCode = QrCode::create([ - 'user_id' => $user->id, - 'short_code' => 'paytest01', - 'type' => QrCode::TYPE_PAYMENT, - 'label' => 'Till 1', - 'payload' => [ - 'content' => [ - 'business_name' => 'Old Shop', - 'branch_label' => 'Main', - 'currency' => 'GHS', - ], - 'style' => [], - ], - 'is_active' => true, - ]); - - $manager = new QrCodeManagerService( - $this->createMock(QrWalletBillingService::class), - $this->createMock(QrImageGeneratorService::class), - new QrPayloadValidator(), - ); - - $updated = $manager->update($qrCode, [ - 'business_name' => 'New Shop', - 'branch_label' => 'Accra Mall', - ]); - - $this->assertSame('New Shop', $updated->content()['business_name']); - $this->assertSame('Accra Mall', $updated->content()['branch_label']); - } -}