Prune the upstream Mini/QR mobile subsystem from POS
Deploy Ladill POS / deploy (push) Successful in 33s
Deploy Ladill POS / deploy (push) Successful in 33s
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
57862acb47
commit
46b5091b1e
@@ -1,34 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\MiniPayment;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Cancels Mini payments left pending longer than MiniPayment::STALE_PENDING_HOURS.
|
||||
* Scheduled hourly (routes/console.php).
|
||||
*/
|
||||
class CancelStalePayments extends Command
|
||||
{
|
||||
protected $signature = 'mini:cancel-stale-payments';
|
||||
|
||||
protected $description = 'Cancel Ladill Mini payments stuck pending beyond the stale window.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$cutoff = now()->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;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Mini\AutoWithdrawService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class ProcessAutoWithdrawals extends Command
|
||||
{
|
||||
protected $signature = 'mini:process-auto-withdrawals';
|
||||
|
||||
protected $description = 'Withdraw wallet balances that have reached each user\'s auto-withdraw threshold.';
|
||||
|
||||
public function handle(AutoWithdrawService $autoWithdraw): int
|
||||
{
|
||||
$count = $autoWithdraw->processAll();
|
||||
$this->info("Processed {$count} auto-withdrawal(s).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\QrTeamMember;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Http\Client\Response as HttpResponse;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/**
|
||||
* Native token auth for the Ladill Mini mobile app.
|
||||
*
|
||||
* Login and registration proxy to the central Ladill identity API
|
||||
* (auth.ladill.com /api/identity/auth/*, gated by the shared first-party
|
||||
* service key) so app accounts are the same single identity used by the website
|
||||
* and every other Ladill app. We then provision the thin local user mirror
|
||||
* (keyed by the OIDC `sub`, exactly like the web SSO callback) and hand back a
|
||||
* Sanctum token the app uses for `auth:sanctum` requests.
|
||||
*/
|
||||
class AuthController extends Controller
|
||||
{
|
||||
public function login(Request $request): JsonResponse
|
||||
{
|
||||
$credentials = $request->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<string, mixed> */
|
||||
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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Concerns;
|
||||
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Http\Client\Response as HttpResponse;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/**
|
||||
* Shared helper for talking to the central Ladill identity API
|
||||
* (auth.ladill.com /api/identity/*) with the first-party service key.
|
||||
*/
|
||||
trait CallsIdentityApi
|
||||
{
|
||||
protected function identity(): PendingRequest
|
||||
{
|
||||
return Http::baseUrl(rtrim((string) config('services.ladill_identity.url'), '/'))
|
||||
->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.']],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class MeController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->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(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Mini;
|
||||
|
||||
use App\Http\Controllers\Api\Concerns\CallsIdentityApi;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\QrSetting;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class AccountController extends Controller
|
||||
{
|
||||
use CallsIdentityApi;
|
||||
|
||||
public function settings(): JsonResponse
|
||||
{
|
||||
$account = ladill_account();
|
||||
$settings = $account->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.']]);
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Mini;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\QrCode;
|
||||
use App\Services\Afia\AfiaService;
|
||||
use App\Services\Billing\BillingClient;
|
||||
use App\Support\Qr\QrTypeCatalog;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AfiaController extends Controller
|
||||
{
|
||||
public function chat(Request $request, AfiaService $afia): JsonResponse
|
||||
{
|
||||
$validated = $request->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<string,mixed> */
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Mini;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class NotificationController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$account = ladill_account();
|
||||
|
||||
$notifications = $account->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<string, mixed> */
|
||||
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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Mini;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\MiniPayment;
|
||||
use App\Models\QrCode;
|
||||
use App\Services\Billing\BillingClient;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
class OverviewController extends Controller
|
||||
{
|
||||
public function __construct(private BillingClient $billing) {}
|
||||
|
||||
public function __invoke(): JsonResponse
|
||||
{
|
||||
$account = ladill_account();
|
||||
|
||||
$qrIds = $account->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(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Mini;
|
||||
|
||||
use App\Models\MiniPayment;
|
||||
use App\Models\QrCode;
|
||||
|
||||
/**
|
||||
* Shared JSON shape for Mini payments and payment QRs so the Android app and
|
||||
* the web views stay describing the same records.
|
||||
*/
|
||||
class PaymentPresenter
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
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<string, mixed> */
|
||||
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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Mini;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\QrCode;
|
||||
use App\Services\Qr\QrCodeManagerService;
|
||||
use App\Services\Qr\QrImageGeneratorService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class PaymentQrController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private QrCodeManagerService $manager,
|
||||
private QrImageGeneratorService $imageGenerator,
|
||||
) {}
|
||||
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$qrCodes = ladill_account()->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);
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Mini;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\MiniPayment;
|
||||
use App\Models\QrCode;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PaymentsController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$search = trim((string) $request->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(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Mini;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\MiniPayment;
|
||||
use App\Models\QrCode;
|
||||
use App\Services\Billing\BillingClient;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
class PayoutsController extends Controller
|
||||
{
|
||||
public function __construct(private BillingClient $billing) {}
|
||||
|
||||
public function __invoke(): JsonResponse
|
||||
{
|
||||
$account = ladill_account();
|
||||
|
||||
$qrIds = $account->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',
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Mini;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\UserPushToken;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PushTokenController extends Controller
|
||||
{
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->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]]);
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Mini;
|
||||
|
||||
use App\Http\Controllers\Api\Concerns\CallsIdentityApi;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Support tickets — proxies to the central support system (auth.ladill.com),
|
||||
* so tickets land in the same admin support queue as every other Ladill app.
|
||||
*/
|
||||
class SupportController extends Controller
|
||||
{
|
||||
use CallsIdentityApi;
|
||||
|
||||
public function tickets(): JsonResponse
|
||||
{
|
||||
$response = $this->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);
|
||||
}
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Mini;
|
||||
|
||||
use App\Http\Controllers\Api\Concerns\CallsIdentityApi;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Billing\BillingClient;
|
||||
use App\Services\Mini\AutoWithdrawService;
|
||||
use App\Services\Notifications\MiniNotificationService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
class WalletController extends Controller
|
||||
{
|
||||
use CallsIdentityApi;
|
||||
|
||||
public function __construct(
|
||||
private BillingClient $billing,
|
||||
private MiniNotificationService $notifications,
|
||||
private AutoWithdrawService $autoWithdraw,
|
||||
) {}
|
||||
|
||||
public function show(): JsonResponse
|
||||
{
|
||||
$account = ladill_account();
|
||||
$balanceMinor = 0;
|
||||
$ledger = [];
|
||||
|
||||
try {
|
||||
$balanceMinor = $this->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', [])]);
|
||||
}
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\QrCode;
|
||||
use App\Services\Qr\QrAnalyticsService;
|
||||
use App\Services\Qr\QrCodeManagerService;
|
||||
use App\Support\Qr\QrTypeCatalog;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use RuntimeException;
|
||||
|
||||
class QrCodeController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private QrCodeManagerService $manager,
|
||||
private QrAnalyticsService $analytics,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$codes = $this->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<QrCode> */
|
||||
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<string, mixed> */
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Mini;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\MiniPayment;
|
||||
use App\Models\QrCode;
|
||||
use App\Services\Billing\BillingClient;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\View\View;
|
||||
use Throwable;
|
||||
|
||||
class OverviewController extends Controller
|
||||
{
|
||||
public function __construct(private BillingClient $billing) {}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$account = ladill_account();
|
||||
|
||||
$qrIds = $account->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Mini;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\QrCode;
|
||||
use App\Services\Qr\QrCodeManagerService;
|
||||
use App\Services\Qr\QrImageGeneratorService;
|
||||
use App\Services\Qr\QrPdfExporter;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class PaymentQrController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private QrCodeManagerService $manager,
|
||||
private QrImageGeneratorService $imageGenerator,
|
||||
private QrPdfExporter $pdfExporter,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$account = ladill_account();
|
||||
$qrCodes = $account->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);
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Mini;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\MiniPayment;
|
||||
use App\Models\QrCode;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class PaymentsController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$account = ladill_account();
|
||||
$search = trim((string) $request->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Mini;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\MiniPayment;
|
||||
use App\Models\QrCode;
|
||||
use App\Services\Billing\BillingClient;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\View\View;
|
||||
use Throwable;
|
||||
|
||||
class PayoutsController extends Controller
|
||||
{
|
||||
public function __construct(private BillingClient $billing) {}
|
||||
|
||||
public function index(): View
|
||||
{
|
||||
$account = ladill_account();
|
||||
|
||||
$qrIds = $account->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Public;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\QrCode;
|
||||
use App\Services\Mini\MiniPaymentService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use RuntimeException;
|
||||
|
||||
class PaymentController extends Controller
|
||||
{
|
||||
public function __construct(private MiniPaymentService $payments) {}
|
||||
|
||||
public function pay(Request $request, string $shortCode): JsonResponse|RedirectResponse
|
||||
{
|
||||
$qrCode = QrCode::query()
|
||||
->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,345 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Public;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\QrCode;
|
||||
use App\Services\Qr\QrScanRecorder;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class QrScanController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private QrScanRecorder $scanRecorder,
|
||||
) {}
|
||||
|
||||
public function resolve(Request $request, string $shortCode): RedirectResponse|View
|
||||
{
|
||||
$qrCode = QrCode::query()->where('short_code', $shortCode)->firstOrFail();
|
||||
|
||||
$this->scanRecorder->record($qrCode, $request);
|
||||
|
||||
if (! $qrCode->is_active) {
|
||||
return view('public.qr.inactive', ['qrCode' => $qrCode]);
|
||||
}
|
||||
|
||||
if ($qrCode->resolvesToRedirect()) {
|
||||
$url = $qrCode->redirectUrl();
|
||||
abort_unless($url, 404);
|
||||
|
||||
return redirect()->away($url);
|
||||
}
|
||||
|
||||
if ($qrCode->isDocumentType()) {
|
||||
return redirect()->route('qr.public.view', $shortCode);
|
||||
}
|
||||
|
||||
if ($qrCode->isBookType()) {
|
||||
return view('public.qr.book-landing', ['qrCode' => $qrCode]);
|
||||
}
|
||||
|
||||
if ($qrCode->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);
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Qr;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\QrSetting;
|
||||
use App\Services\Billing\BillingClient;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AccountController extends Controller
|
||||
{
|
||||
public function __construct(private BillingClient $billing) {}
|
||||
|
||||
private function topupUrl(): string
|
||||
{
|
||||
return 'https://'.config('app.account_domain').'/wallet';
|
||||
}
|
||||
|
||||
/** @return array{0: int, 1: array<string, mixed>} */
|
||||
private function billingSnapshot(?string $publicId): array
|
||||
{
|
||||
if (! $publicId) {
|
||||
return [0, []];
|
||||
}
|
||||
|
||||
$balanceMinor = $this->billing->balanceMinor($publicId);
|
||||
$ledger = $this->billing->serviceLedger($publicId, 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.');
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Qr;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\QrCode;
|
||||
use App\Services\Afia\AfiaService;
|
||||
use App\Services\Billing\BillingClient;
|
||||
use App\Support\Qr\QrTypeCatalog;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AfiaController extends Controller
|
||||
{
|
||||
public function chat(Request $request, AfiaService $afia): JsonResponse
|
||||
{
|
||||
$validated = $request->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<string,mixed> */
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Qr;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Developers — personal API tokens for the Ladill QR Plus API (Sanctum). Tokens
|
||||
* belong to the signed-in user and authorize calls to /api/v1/*. The plaintext
|
||||
* token is shown once on create.
|
||||
*/
|
||||
class DeveloperController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
return view('qr.account.developers', [
|
||||
'tokens' => $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.');
|
||||
}
|
||||
}
|
||||
@@ -1,454 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Qr;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\QrCode;
|
||||
use App\Models\QrEventRegistration;
|
||||
use App\Models\QrWallet;
|
||||
use App\Services\Billing\BillingClient;
|
||||
use App\Services\Qr\QrAnalyticsService;
|
||||
use App\Services\Qr\QrCodeManagerService;
|
||||
use App\Services\Qr\QrImageGeneratorService;
|
||||
use App\Services\Qr\QrPdfExporter;
|
||||
use App\Services\Qr\QrWalletBillingService;
|
||||
use App\Support\Qr\QrCornerStyleCatalog;
|
||||
use App\Support\Qr\QrFrameStyleCatalog;
|
||||
use App\Support\Qr\QrModuleStyleCatalog;
|
||||
use App\Support\Qr\QrTypeCatalog;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\View\View;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class QrCodeController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private QrCodeManagerService $manager,
|
||||
private QrWalletBillingService $billing,
|
||||
private QrAnalyticsService $analytics,
|
||||
private QrImageGeneratorService $imageGenerator,
|
||||
private QrPdfExporter $pdfExporter,
|
||||
private BillingClient $platformBilling,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$account = ladill_account();
|
||||
$wallet = $this->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<string, mixed> */
|
||||
private function styleRules(): array
|
||||
{
|
||||
return [
|
||||
'style' => ['nullable', 'array'],
|
||||
'style.foreground' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'],
|
||||
'style.background' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'],
|
||||
'style.error_correction' => ['nullable', 'in:L,M,Q,H'],
|
||||
'style.margin' => ['nullable', 'integer', 'min:0', 'max:10'],
|
||||
'style.module_style' => ['nullable', 'in:' . implode(',', QrModuleStyleCatalog::keys())],
|
||||
'style.finder_outer' => ['nullable', 'in:' . implode(',', array_keys(QrCornerStyleCatalog::outerStyles()))],
|
||||
'style.finder_inner' => ['nullable', 'in:' . implode(',', array_keys(QrCornerStyleCatalog::innerStyles()))],
|
||||
'style.frame_style' => ['nullable', 'in:' . implode(',', QrFrameStyleCatalog::keys())],
|
||||
'style.frame_text' => ['nullable', 'string', 'max:100'],
|
||||
'style.frame_color' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'],
|
||||
'style.scale' => ['nullable', 'integer', 'min:4', 'max:16'],
|
||||
'style.gradient_type' => ['nullable', 'in:none,linear,radial'],
|
||||
'style.gradient_color1' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'],
|
||||
'style.gradient_color2' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'],
|
||||
'style.gradient_rotation' => ['nullable', 'integer', 'min:0', 'max:360'],
|
||||
'style.logo_size' => ['nullable', 'numeric', 'min:0.1', 'max:0.4'],
|
||||
'style.logo_margin' => ['nullable', 'integer', 'min:0', 'max:15'],
|
||||
'style.logo_white_bg' => ['nullable', 'boolean'],
|
||||
'style.logo_shape' => ['nullable', 'in:none,rounded,circle'],
|
||||
];
|
||||
}
|
||||
|
||||
public function preview(QrCode $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]);
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Qr;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\QrTeamMember;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class TeamController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$account = ladill_account();
|
||||
|
||||
$members = QrTeamMember::query()
|
||||
->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();
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\MiniPayment;
|
||||
use App\Models\QrCode;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class SearchController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request): JsonResponse|View
|
||||
{
|
||||
$q = trim((string) $request->query('q'));
|
||||
$results = mb_strlen($q) >= 2 ? $this->results($q) : [];
|
||||
|
||||
if ($request->expectsJson() || $request->ajax() || $request->wantsJson()) {
|
||||
return response()->json(['results' => $results]);
|
||||
}
|
||||
|
||||
return view('search.index', [
|
||||
'query' => $q,
|
||||
'results' => $results,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return list<array{type: string, title: string, subtitle: string, url: string}> */
|
||||
private function results(string $q): array
|
||||
{
|
||||
$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();
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\WellKnown;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class AssetLinksController extends Controller
|
||||
{
|
||||
public function __invoke(): JsonResponse
|
||||
{
|
||||
$fingerprints = config('android_app_links.sha256_cert_fingerprints', []);
|
||||
|
||||
abort_if($fingerprints === [], 404);
|
||||
|
||||
return response()->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);
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class MiniPayment extends Model
|
||||
{
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_PAID = 'paid';
|
||||
public const STATUS_FAILED = 'failed';
|
||||
public const STATUS_CANCELED = 'canceled';
|
||||
|
||||
/** Pending payments are auto-canceled after this many hours. */
|
||||
public const STALE_PENDING_HOURS = 6;
|
||||
|
||||
/** Platform fee on trader payments (Ladill Mini tier). */
|
||||
public const PLATFORM_FEE_RATE = 0.035;
|
||||
|
||||
protected $fillable = [
|
||||
'pay_order_id',
|
||||
'qr_code_id',
|
||||
'user_id',
|
||||
'reference',
|
||||
'amount_minor',
|
||||
'currency',
|
||||
'platform_fee_minor',
|
||||
'merchant_amount_minor',
|
||||
'payer_name',
|
||||
'payer_email',
|
||||
'payer_phone',
|
||||
'payer_note',
|
||||
'status',
|
||||
'payment_reference',
|
||||
'paid_at',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'amount_minor' => '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;
|
||||
}
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\Qr\QrStyleDefaults;
|
||||
use App\Support\Qr\QrTypeCatalog;
|
||||
use App\Support\Qr\QrWifiPayload;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class QrCode extends Model
|
||||
{
|
||||
public const TYPE_URL = 'url';
|
||||
public const TYPE_DOCUMENT = 'document';
|
||||
public const TYPE_LINK_LIST = 'link_list';
|
||||
public const TYPE_VCARD = 'vcard';
|
||||
public const TYPE_BUSINESS = 'business';
|
||||
public const TYPE_IMAGE = 'image';
|
||||
public const TYPE_CHURCH = 'church';
|
||||
public const TYPE_MENU = 'menu';
|
||||
public const TYPE_SHOP = 'shop';
|
||||
public const TYPE_APP = 'app';
|
||||
public const TYPE_BOOK = 'book';
|
||||
public const TYPE_WIFI = 'wifi';
|
||||
public const TYPE_COUPON = 'coupon';
|
||||
public const TYPE_EVENT = 'event';
|
||||
public const TYPE_ITINERARY = 'itinerary';
|
||||
public const TYPE_PAYMENT = 'payment';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'short_code',
|
||||
'type',
|
||||
'label',
|
||||
'destination_url',
|
||||
'qr_document_id',
|
||||
'payload',
|
||||
'is_active',
|
||||
'png_path',
|
||||
'svg_path',
|
||||
'scans_total',
|
||||
'unique_scans_total',
|
||||
'last_scanned_at',
|
||||
'destination_updated_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'payload' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
'scans_total' => 'integer',
|
||||
'unique_scans_total' => 'integer',
|
||||
'last_scanned_at' => 'datetime',
|
||||
'destination_updated_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function document(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(QrDocument::class, 'qr_document_id');
|
||||
}
|
||||
|
||||
public function scanEvents(): HasMany
|
||||
{
|
||||
return $this->hasMany(QrScanEvent::class);
|
||||
}
|
||||
|
||||
public function transactions(): HasMany
|
||||
{
|
||||
return $this->hasMany(QrTransaction::class);
|
||||
}
|
||||
|
||||
public function 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<string, mixed> */
|
||||
public function content(): array
|
||||
{
|
||||
return (array) ($this->payload['content'] ?? []);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class QrDocument extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'title',
|
||||
'disk',
|
||||
'path',
|
||||
'mime_type',
|
||||
'size_bytes',
|
||||
'page_count',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'size_bytes' => 'integer',
|
||||
'page_count' => 'integer',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function qrCodes(): HasMany
|
||||
{
|
||||
return $this->hasMany(QrCode::class);
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class QrScanEvent extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'qr_code_id',
|
||||
'scanned_at',
|
||||
'ip_hash',
|
||||
'user_agent',
|
||||
'device_type',
|
||||
'browser',
|
||||
'os',
|
||||
'country_code',
|
||||
'referrer',
|
||||
'is_unique',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'scanned_at' => 'datetime',
|
||||
'is_unique' => 'boolean',
|
||||
];
|
||||
|
||||
public function qrCode(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(QrCode::class);
|
||||
}
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\Qr\QrStyleDefaults;
|
||||
use App\Support\Qr\QrTypeCatalog;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/** Per-account Ladill Events preferences (notifications, event defaults, QR style). */
|
||||
class QrSetting extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'notify_email',
|
||||
'product_updates',
|
||||
'low_balance_alerts',
|
||||
'notify_registrations',
|
||||
'notify_payouts',
|
||||
'auto_withdraw_amount_minor',
|
||||
'default_type',
|
||||
'default_style',
|
||||
'event_defaults',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'product_updates' => 'boolean',
|
||||
'low_balance_alerts' => 'boolean',
|
||||
'notify_registrations' => 'boolean',
|
||||
'notify_payouts' => 'boolean',
|
||||
'auto_withdraw_amount_minor' => 'integer',
|
||||
'default_style' => 'array',
|
||||
'event_defaults' => 'array',
|
||||
];
|
||||
|
||||
/** @return list<string> */
|
||||
public static function storableEventDefaultKeys(): array
|
||||
{
|
||||
return [
|
||||
'currency',
|
||||
'mode',
|
||||
'badge_size',
|
||||
'brand_color',
|
||||
'organizer',
|
||||
'registration_open',
|
||||
'badge_fields',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
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<string, mixed> */
|
||||
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<string, mixed>|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<string, mixed> */
|
||||
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<string, mixed>|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();
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class QrTransaction extends Model
|
||||
{
|
||||
public const TYPE_CREDIT = 'credit';
|
||||
public const TYPE_DEBIT = 'debit';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'qr_wallet_id',
|
||||
'qr_code_id',
|
||||
'type',
|
||||
'amount_ghs',
|
||||
'balance_after_ghs',
|
||||
'reference',
|
||||
'status',
|
||||
'description',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'amount_ghs' => 'decimal:4',
|
||||
'balance_after_ghs' => 'decimal:4',
|
||||
'metadata' => 'array',
|
||||
];
|
||||
|
||||
public function wallet(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(QrWallet::class, 'qr_wallet_id');
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function qrCode(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(QrCode::class);
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class QrWallet extends Model
|
||||
{
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
public const STATUS_SUSPENDED = 'suspended';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'credit_balance',
|
||||
'qr_codes_total',
|
||||
'scans_total',
|
||||
'status',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'credit_balance' => 'decimal:4',
|
||||
'qr_codes_total' => 'integer',
|
||||
'scans_total' => 'integer',
|
||||
];
|
||||
|
||||
public static function pricePerQr(): float
|
||||
{
|
||||
return (float) config('qr.price_per_qr_ghs', 5.0);
|
||||
}
|
||||
|
||||
public static function minTopupGhs(): float
|
||||
{
|
||||
return (float) config('qr.min_topup_ghs', 5.0);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function transactions(): HasMany
|
||||
{
|
||||
return $this->hasMany(QrTransaction::class)->latest();
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-wallet (siloing step 2): QR spends from the one UserWallet (tagged
|
||||
* 'qr'). Delegates to the billing service (lazily folds any legacy
|
||||
* credit_balance in). `spendableBalance()` is the unified balance for display.
|
||||
*/
|
||||
public function spendableBalance(): float
|
||||
{
|
||||
return $this->user
|
||||
? app(\App\Services\Qr\QrWalletBillingService::class)->balanceCedis($this->user)
|
||||
: (float) $this->credit_balance;
|
||||
}
|
||||
|
||||
public function canCreateQr(): bool
|
||||
{
|
||||
return $this->user
|
||||
? app(\App\Services\Qr\QrWalletBillingService::class)->canCreate($this->user)
|
||||
: false;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class UserPushToken extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'token',
|
||||
'platform',
|
||||
'device_name',
|
||||
'last_seen_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'last_seen_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\QrCode;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class EventProgrammeSharedNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
private readonly QrCode $event,
|
||||
private readonly QrCode $programme,
|
||||
private readonly ?string $attendeeName = null,
|
||||
) {}
|
||||
|
||||
public function via(mixed $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function toMail(mixed $notifiable): MailMessage
|
||||
{
|
||||
$eventName = $this->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications\Mini;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class MiniAlertNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $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<string> */
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['database'];
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\QrCode;
|
||||
use App\Models\User;
|
||||
|
||||
class QrCodePolicy
|
||||
{
|
||||
public function view(User $user, QrCode $qrCode): bool
|
||||
{
|
||||
return $user->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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Afia;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Afia — Ladill in-app AI assistant scoped to a product (QR Plus).
|
||||
*/
|
||||
class AfiaService
|
||||
{
|
||||
public function enabled(): bool
|
||||
{
|
||||
return (bool) config('afia.enabled', true) && (string) config('afia.api_key', '') !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array{role:string,text:string}> $history
|
||||
* @param array<string,mixed> $context
|
||||
*/
|
||||
public function chat(string $message, array $history, array $context): string
|
||||
{
|
||||
if (! $this->enabled()) {
|
||||
throw new RuntimeException('Afia is not configured.');
|
||||
}
|
||||
|
||||
$provider = (string) config('afia.provider', 'openai');
|
||||
$model = (string) config('afia.model', 'gpt-4o-mini');
|
||||
$apiKey = (string) config('afia.api_key');
|
||||
|
||||
$messages = [['role' => 'system', 'content' => $this->systemPrompt($context)]];
|
||||
foreach (array_slice($history, -8) as $turn) {
|
||||
$role = ($turn['role'] ?? 'user') === 'assistant' ? 'assistant' : 'user';
|
||||
$text = trim((string) ($turn['text'] ?? ''));
|
||||
if ($text !== '') {
|
||||
$messages[] = ['role' => $role, 'content' => $text];
|
||||
}
|
||||
}
|
||||
$messages[] = ['role' => 'user', 'content' => $message];
|
||||
|
||||
return $provider === 'anthropic'
|
||||
? $this->viaAnthropic($model, $apiKey, $messages)
|
||||
: $this->viaOpenAi($model, $apiKey, $messages);
|
||||
}
|
||||
|
||||
private function viaOpenAi(string $model, string $apiKey, array $messages): string
|
||||
{
|
||||
$res = Http::withToken($apiKey)->acceptJson()->timeout(45)
|
||||
->post('https://api.openai.com/v1/chat/completions', [
|
||||
'model' => $model,
|
||||
'temperature' => 0.3,
|
||||
'max_tokens' => 600,
|
||||
'messages' => $messages,
|
||||
]);
|
||||
|
||||
if ($res->failed()) {
|
||||
throw new RuntimeException('OpenAI request failed: '.$res->status());
|
||||
}
|
||||
|
||||
return trim((string) $res->json('choices.0.message.content', ''));
|
||||
}
|
||||
|
||||
private function viaAnthropic(string $model, string $apiKey, array $messages): string
|
||||
{
|
||||
$system = $messages[0]['content'];
|
||||
$turns = array_values(array_filter($messages, fn ($m) => $m['role'] !== 'system'));
|
||||
|
||||
$res = Http::withHeaders([
|
||||
'x-api-key' => $apiKey,
|
||||
'anthropic-version' => '2023-06-01',
|
||||
])->acceptJson()->timeout(45)->post('https://api.anthropic.com/v1/messages', [
|
||||
'model' => $model,
|
||||
'max_tokens' => 600,
|
||||
'system' => $system,
|
||||
'messages' => array_map(fn ($m) => ['role' => $m['role'], 'content' => $m['content']], $turns),
|
||||
]);
|
||||
|
||||
if ($res->failed()) {
|
||||
throw new RuntimeException('Anthropic request failed: '.$res->status());
|
||||
}
|
||||
|
||||
return trim((string) $res->json('content.0.text', ''));
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $context */
|
||||
private function systemPrompt(array $context): string
|
||||
{
|
||||
$ctx = collect($context)->map(fn ($v, $k) => "- {$k}: {$v}")->implode("\n");
|
||||
|
||||
return $this->miniSystemPrompt($ctx);
|
||||
}
|
||||
|
||||
private function miniSystemPrompt(string $ctx): string
|
||||
{
|
||||
return <<<PROMPT
|
||||
You are Afia, the assistant inside Ladill Mini (mini.ladill.com).
|
||||
Help users create payment QR codes, accept payments, and get paid out. Be concise, friendly, and actionable: short numbered steps and name the screen or section to use.
|
||||
|
||||
What Ladill Mini does and where things live:
|
||||
- Overview (Dashboard): recent payments, active payment QRs, totals collected, and wallet balance.
|
||||
- Payment QRs: create a payment QR — set a fixed amount (or let payers enter one) and a label, then publish and share the link or QR for customers to scan and pay.
|
||||
- Payments: incoming payments from your QRs — view payer, amount, and status.
|
||||
- Payouts: collected funds land in your Ladill wallet; request a withdrawal to your bank or mobile money from Payouts.
|
||||
- Fees: Ladill takes a 3.5% platform fee on payments; the remainder is yours.
|
||||
- Account & Wallet: check balance or top up at account.ladill.com/wallet.
|
||||
|
||||
Rules:
|
||||
- Only answer questions about Ladill Mini — payment QRs, payments, payouts, fees, and sharing.
|
||||
- If asked about domains, hosting, email, plain QR codes, storefronts, donations, or events, briefly say those live in other Ladill apps and suggest the app launcher.
|
||||
- Never invent payment amounts, payout amounts, or wallet balances — use the user context below or tell them where to check.
|
||||
|
||||
Current user context:
|
||||
{$ctx}
|
||||
PROMPT;
|
||||
}
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Mini;
|
||||
|
||||
use App\Models\QrSetting;
|
||||
use App\Models\User;
|
||||
use App\Services\Billing\BillingClient;
|
||||
use App\Services\Notifications\MiniNotificationService;
|
||||
use Illuminate\Http\Client\Response as HttpResponse;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
class AutoWithdrawService
|
||||
{
|
||||
public function __construct(
|
||||
private BillingClient $billing,
|
||||
private MiniNotificationService $notifications,
|
||||
) {}
|
||||
|
||||
public function attemptForUser(User $user): bool
|
||||
{
|
||||
$settings = $user->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]);
|
||||
}
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Mini;
|
||||
|
||||
use App\Models\MiniPayment;
|
||||
use App\Models\QrCode;
|
||||
use App\Services\Billing\BillingClient;
|
||||
use App\Services\Billing\PaystackService;
|
||||
use App\Services\Billing\SmsService;
|
||||
use App\Services\Notifications\MiniNotificationService;
|
||||
use App\Services\Pay\PayClient;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
class MiniPaymentService
|
||||
{
|
||||
public function __construct(
|
||||
private PayClient $pay,
|
||||
private PaystackService $paystack,
|
||||
private BillingClient $billing,
|
||||
private SmsService $sms,
|
||||
private MiniNotificationService $notifications,
|
||||
private AutoWithdrawService $autoWithdraw,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{amount: float} $data
|
||||
* @return array{payment: MiniPayment, checkout_url: string}
|
||||
*/
|
||||
public function initiate(QrCode $qrCode, array $data): array
|
||||
{
|
||||
if ($qrCode->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
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Notifications;
|
||||
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class FcmService
|
||||
{
|
||||
public const DELIVERED = 'ok';
|
||||
|
||||
public const INVALID_TOKEN = 'invalid_token';
|
||||
|
||||
public const FAILED = 'failed';
|
||||
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return filled(config('services.fcm.project_id'))
|
||||
&& filled(config('services.fcm.service_account_json'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return self::DELIVERED|self::INVALID_TOKEN|self::FAILED
|
||||
*/
|
||||
public function send(string $fcmToken, string $title, string $body, array $data = []): string
|
||||
{
|
||||
if (! $this->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<string, mixed> */
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Notifications;
|
||||
|
||||
use App\Models\MiniPayment;
|
||||
use App\Models\User;
|
||||
use App\Notifications\Mini\MiniAlertNotification;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class MiniNotificationService
|
||||
{
|
||||
public function __construct(private FcmService $fcm) {}
|
||||
|
||||
public function paymentReceived(MiniPayment $payment): void
|
||||
{
|
||||
$payment->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<string, mixed> $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<string, mixed> $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';
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Qr;
|
||||
|
||||
use App\Models\QrCode;
|
||||
use App\Models\QrScanEvent;
|
||||
use App\Models\QrWallet;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class QrAnalyticsService
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function summaryFor(QrCode $qrCode): array
|
||||
{
|
||||
$now = now();
|
||||
$events = QrScanEvent::query()->where('qr_code_id', $qrCode->id);
|
||||
|
||||
return [
|
||||
'total_scans' => (int) $qrCode->scans_total,
|
||||
'unique_scans' => (int) $qrCode->unique_scans_total,
|
||||
'scans_7d' => (clone $events)->where('scanned_at', '>=', $now->copy()->subDays(7))->count(),
|
||||
'scans_30d' => (clone $events)->where('scanned_at', '>=', $now->copy()->subDays(30))->count(),
|
||||
'last_scanned_at' => $qrCode->last_scanned_at,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, object{date: string, total: int}>
|
||||
*/
|
||||
public function dailyScans(QrCode $qrCode, int $days = 30): Collection
|
||||
{
|
||||
$start = now()->subDays($days - 1)->startOfDay();
|
||||
|
||||
$rows = QrScanEvent::query()
|
||||
->selectRaw('DATE(scanned_at) as scan_date, COUNT(*) as total')
|
||||
->where('qr_code_id', $qrCode->id)
|
||||
->where('scanned_at', '>=', $start)
|
||||
->groupBy('scan_date')
|
||||
->orderBy('scan_date')
|
||||
->get()
|
||||
->keyBy('scan_date');
|
||||
|
||||
$series = collect();
|
||||
for ($i = 0; $i < $days; $i++) {
|
||||
$date = $start->copy()->addDays($i)->toDateString();
|
||||
$series->push((object) [
|
||||
'date' => $date,
|
||||
'total' => (int) ($rows->get($date)?->total ?? 0),
|
||||
]);
|
||||
}
|
||||
|
||||
return $series;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{label: string, total: int}>
|
||||
*/
|
||||
public function breakdown(QrCode $qrCode, string $column, int $limit = 5): array
|
||||
{
|
||||
return QrScanEvent::query()
|
||||
->select($column, DB::raw('COUNT(*) as total'))
|
||||
->where('qr_code_id', $qrCode->id)
|
||||
->whereNotNull($column)
|
||||
->groupBy($column)
|
||||
->orderByDesc('total')
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(fn ($row) => [
|
||||
'label' => (string) $row->{$column},
|
||||
'total' => (int) $row->total,
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Illuminate\Database\Eloquent\Collection<int, QrScanEvent>
|
||||
*/
|
||||
public function recentScans(QrCode $qrCode, int $limit = 20)
|
||||
{
|
||||
return QrScanEvent::query()
|
||||
->where('qr_code_id', $qrCode->id)
|
||||
->latest('scanned_at')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
}
|
||||
@@ -1,556 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Qr;
|
||||
|
||||
use App\Models\QrCode;
|
||||
use App\Models\QrDocument;
|
||||
use App\Models\QrWallet;
|
||||
use App\Models\User;
|
||||
use App\Support\Qr\QrStyleDefaults;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
class QrCodeManagerService
|
||||
{
|
||||
public function __construct(
|
||||
private QrWalletBillingService $billing,
|
||||
private QrImageGeneratorService $imageGenerator,
|
||||
private QrPayloadValidator $payloadValidator,
|
||||
) {}
|
||||
|
||||
public function walletFor(User $user): QrWallet
|
||||
{
|
||||
return $user->qrWallet()->firstOrCreate(
|
||||
[],
|
||||
['credit_balance' => 0, 'status' => QrWallet::STATUS_ACTIVE],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function create(User $user, array $data): QrCode
|
||||
{
|
||||
$wallet = $this->walletFor($user);
|
||||
$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<string, mixed> $data
|
||||
*/
|
||||
public function update(QrCode $qrCode, array $data): QrCode
|
||||
{
|
||||
$content = $qrCode->content();
|
||||
$style = $qrCode->style();
|
||||
$destinationUrl = $qrCode->destination_url;
|
||||
$regenerate = false;
|
||||
|
||||
if (isset($data['label']) && trim((string) $data['label']) !== '') {
|
||||
$qrCode->label = trim((string) $data['label']);
|
||||
}
|
||||
|
||||
if (array_key_exists('is_active', $data)) {
|
||||
$qrCode->is_active = (bool) $data['is_active'];
|
||||
}
|
||||
|
||||
$typeFields = array_merge($content, $data);
|
||||
if ($this->hasContentChanges($qrCode, $data)) {
|
||||
$validated = $this->payloadValidator->validateForUpdate($qrCode, $typeFields);
|
||||
$content = $validated['content'];
|
||||
$destinationUrl = $validated['destination_url'];
|
||||
$qrCode->destination_updated_at = now();
|
||||
}
|
||||
|
||||
if ($qrCode->isDocumentType() && isset($data['document']) && $data['document'] instanceof UploadedFile) {
|
||||
$document = $this->storeDocument($qrCode->user, $data['document'], $data['label'] ?? $qrCode->label);
|
||||
$qrCode->qr_document_id = $document->id;
|
||||
$qrCode->destination_updated_at = now();
|
||||
}
|
||||
|
||||
if ($qrCode->isImageType()) {
|
||||
$newImages = $this->storeImages($qrCode->user, $data, false);
|
||||
if ($newImages !== []) {
|
||||
$content['images'] = array_merge($content['images'] ?? [], $newImages);
|
||||
$qrCode->destination_updated_at = now();
|
||||
}
|
||||
}
|
||||
|
||||
if ($qrCode->type === QrCode::TYPE_VCARD && ($data['avatar'] ?? null) instanceof UploadedFile) {
|
||||
if ($oldPath = $content['avatar_path'] ?? null) {
|
||||
Storage::disk('qr')->delete($oldPath);
|
||||
}
|
||||
$content['avatar_path'] = $this->storeVcardAvatar($qrCode->user, $data['avatar']);
|
||||
}
|
||||
|
||||
if ($qrCode->type === QrCode::TYPE_BOOK) {
|
||||
if (($data['book_file'] ?? null) instanceof UploadedFile) {
|
||||
$bookData = $this->storeBookFile($qrCode->user, $data['book_file']);
|
||||
$content['file_path'] = $bookData['path'];
|
||||
$content['file_type'] = $bookData['type'];
|
||||
$content['file_size'] = $bookData['size'];
|
||||
$qrCode->destination_updated_at = now();
|
||||
}
|
||||
if (($data['cover'] ?? null) instanceof UploadedFile) {
|
||||
$content['cover_path'] = $this->storeBookCover($qrCode->user, $data['cover']);
|
||||
}
|
||||
}
|
||||
|
||||
if ($qrCode->type === QrCode::TYPE_APP && ($data['app_icon'] ?? null) instanceof UploadedFile) {
|
||||
$content['icon_path'] = $this->storeMenuBrandImage($qrCode->user, $data['app_icon'], 'app-icons');
|
||||
}
|
||||
|
||||
if (in_array($qrCode->type, [QrCode::TYPE_MENU, QrCode::TYPE_SHOP], true)) {
|
||||
$content['sections'] = $this->injectItemImages($qrCode->user, $content['sections'] ?? [], $data, $qrCode->type);
|
||||
if (($data['menu_logo'] ?? null) instanceof UploadedFile) {
|
||||
$content['logo_path'] = $this->storeMenuBrandImage($qrCode->user, $data['menu_logo'], 'menu-logos');
|
||||
}
|
||||
if (($data['menu_cover'] ?? null) instanceof UploadedFile) {
|
||||
$content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['menu_cover'], 'menu-covers');
|
||||
}
|
||||
}
|
||||
|
||||
if ($qrCode->type === QrCode::TYPE_BUSINESS) {
|
||||
if (($data['business_logo'] ?? null) instanceof UploadedFile) {
|
||||
$content['logo_path'] = $this->storeMenuBrandImage($qrCode->user, $data['business_logo'], 'business-logos');
|
||||
}
|
||||
if (($data['business_cover'] ?? null) instanceof UploadedFile) {
|
||||
$content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['business_cover'], 'business-covers');
|
||||
}
|
||||
}
|
||||
|
||||
if ($qrCode->type === QrCode::TYPE_CHURCH) {
|
||||
if (($data['church_logo'] ?? null) instanceof UploadedFile) {
|
||||
$content['logo_path'] = $this->storeMenuBrandImage($qrCode->user, $data['church_logo'], 'church-logos');
|
||||
}
|
||||
if (($data['church_cover'] ?? null) instanceof UploadedFile) {
|
||||
$content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['church_cover'], 'church-covers');
|
||||
}
|
||||
}
|
||||
|
||||
if ($qrCode->type === QrCode::TYPE_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<int, mixed> $sections
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<int, mixed>
|
||||
*/
|
||||
private function injectItemImages(User $user, array $sections, array $data, string $type): array
|
||||
{
|
||||
$uploads = $data['item_images'] ?? [];
|
||||
if (! is_array($uploads) || $uploads === []) {
|
||||
return $sections;
|
||||
}
|
||||
|
||||
foreach ($uploads as $sIndex => $itemUploads) {
|
||||
if (! is_array($itemUploads)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($itemUploads as $iIndex => $file) {
|
||||
if (! ($file instanceof UploadedFile)) {
|
||||
continue;
|
||||
}
|
||||
if (! isset($sections[$sIndex]['items'][$iIndex])) {
|
||||
continue;
|
||||
}
|
||||
$mime = $file->getMimeType() ?: '';
|
||||
if (! str_starts_with($mime, 'image/')) {
|
||||
continue;
|
||||
}
|
||||
$subdir = $type === QrCode::TYPE_SHOP ? 'shop-items' : 'menu-items';
|
||||
$ext = $file->getClientOriginalExtension() ?: 'jpg';
|
||||
$path = $user->id . '/' . $subdir . '/' . Str::uuid()->toString() . '.' . $ext;
|
||||
$file->storeAs('', $path, 'qr');
|
||||
$sections[$sIndex]['items'][$iIndex]['image_path'] = $path;
|
||||
}
|
||||
}
|
||||
|
||||
return $sections;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $data */
|
||||
private function hasContentChanges(QrCode $qrCode, array $data): bool
|
||||
{
|
||||
$keys = match ($qrCode->type) {
|
||||
QrCode::TYPE_URL => ['destination_url'],
|
||||
QrCode::TYPE_LINK_LIST => ['links'],
|
||||
QrCode::TYPE_VCARD => ['first_name', 'last_name', 'phone', 'email', 'company', 'website', 'address', 'note', 'social'],
|
||||
QrCode::TYPE_BUSINESS => ['name', 'tagline', 'phone', 'email', 'website', 'address', 'hours'],
|
||||
QrCode::TYPE_CHURCH => ['name', 'denomination', 'description', 'phone', 'email', 'website', 'address', 'service_times', 'org_type', 'accepts_payment', 'collection_types', 'brand_color'],
|
||||
QrCode::TYPE_EVENT => ['name', 'tagline', 'description', 'location', 'starts_at', 'ends_at', 'organizer', 'website', 'brand_color', 'tiers', 'badge_fields', 'badge_size', 'registration_open'],
|
||||
QrCode::TYPE_ITINERARY => ['title', 'subtitle', 'description', 'event_date', 'location', 'brand_color', 'days'],
|
||||
QrCode::TYPE_DOCUMENT => ['allow_download'],
|
||||
QrCode::TYPE_MENU => ['menu_title', 'sections', 'accepts_payment', 'brand_color', 'shipping_type', 'shipping_fee', 'free_shipping_above'],
|
||||
QrCode::TYPE_SHOP => ['shop_title', 'sections', 'currency', 'accepts_payment', 'brand_color', 'shipping_type', 'shipping_fee', 'free_shipping_above'],
|
||||
QrCode::TYPE_APP => ['app_name', 'ios_url', 'android_url', 'web_url', 'app_icon'],
|
||||
QrCode::TYPE_BOOK => ['book_title', 'author', 'description', 'price_ghs'],
|
||||
QrCode::TYPE_WIFI => ['ssid', 'password', 'encryption', 'hidden'],
|
||||
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<string, mixed> $data
|
||||
* @return list<array{path: string, title: string}>
|
||||
*/
|
||||
private function storeImages(User $user, array $data, bool $required = true): array
|
||||
{
|
||||
$files = [];
|
||||
if (($data['image'] ?? null) instanceof UploadedFile) {
|
||||
$files[] = $data['image'];
|
||||
}
|
||||
if (is_array($data['images'] ?? null)) {
|
||||
foreach ($data['images'] as $file) {
|
||||
if ($file instanceof UploadedFile) {
|
||||
$files[] = $file;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($required && $files === []) {
|
||||
$this->payloadValidator->validateImageUpload(null);
|
||||
}
|
||||
|
||||
$stored = [];
|
||||
foreach ($files as $index => $file) {
|
||||
$this->payloadValidator->validateImageUpload($file);
|
||||
$uuid = Str::uuid()->toString();
|
||||
$ext = $file->getClientOriginalExtension() ?: 'jpg';
|
||||
$path = $user->id . '/images/' . $uuid . '.' . $ext;
|
||||
$file->storeAs('', $path, 'qr');
|
||||
$stored[] = [
|
||||
'path' => $path,
|
||||
'title' => $file->getClientOriginalName() ?: ('Image ' . ($index + 1)),
|
||||
];
|
||||
}
|
||||
|
||||
return $stored;
|
||||
}
|
||||
|
||||
private function storeVcardAvatar(User $user, UploadedFile $file): string
|
||||
{
|
||||
$mime = $file->getMimeType() ?: '';
|
||||
if (! str_starts_with($mime, 'image/')) {
|
||||
throw new RuntimeException('Avatar must be an image file.');
|
||||
}
|
||||
|
||||
$ext = $file->getClientOriginalExtension() ?: 'jpg';
|
||||
$path = $user->id . '/vcards/' . Str::uuid()->toString() . '.' . $ext;
|
||||
$file->storeAs('', $path, 'qr');
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
private function storeBookFile(User $user, UploadedFile $file): array
|
||||
{
|
||||
$ext = strtolower($file->getClientOriginalExtension() ?: '');
|
||||
$mime = $file->getMimeType() ?: '';
|
||||
|
||||
if ($ext === 'pdf' || in_array($mime, ['application/pdf', 'application/x-pdf'], true)) {
|
||||
$type = 'pdf';
|
||||
} elseif ($ext === 'epub' || str_contains($mime, 'epub')) {
|
||||
$type = 'epub';
|
||||
} else {
|
||||
throw new RuntimeException('Only PDF and EPUB files are supported for books.');
|
||||
}
|
||||
|
||||
$path = $user->id . '/books/' . Str::uuid()->toString() . '.' . $type;
|
||||
$file->storeAs('', $path, 'qr');
|
||||
|
||||
return ['path' => $path, 'type' => $type, 'size' => (int) $file->getSize()];
|
||||
}
|
||||
|
||||
private function storeMenuBrandImage(User $user, UploadedFile $file, string $subdir): string
|
||||
{
|
||||
$mime = $file->getMimeType() ?: '';
|
||||
if (! str_starts_with($mime, 'image/')) {
|
||||
throw new RuntimeException('Only image files are supported.');
|
||||
}
|
||||
|
||||
$ext = $file->getClientOriginalExtension() ?: 'jpg';
|
||||
$path = $user->id . '/' . $subdir . '/' . Str::uuid()->toString() . '.' . $ext;
|
||||
$file->storeAs('', $path, 'qr');
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
private function storeBookCover(User $user, UploadedFile $file): string
|
||||
{
|
||||
$mime = $file->getMimeType() ?: '';
|
||||
if (! str_starts_with($mime, 'image/')) {
|
||||
throw new RuntimeException('Book cover must be an image file.');
|
||||
}
|
||||
|
||||
$ext = $file->getClientOriginalExtension() ?: 'jpg';
|
||||
$path = $user->id . '/book-covers/' . Str::uuid()->toString() . '.' . $ext;
|
||||
$file->storeAs('', $path, 'qr');
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
private function storeLogo(User $user, UploadedFile $file): string
|
||||
{
|
||||
$mime = $file->getMimeType() ?: '';
|
||||
if (! str_starts_with($mime, 'image/')) {
|
||||
throw new RuntimeException('Logo must be an image file.');
|
||||
}
|
||||
|
||||
$path = $user->id . '/logos/' . Str::uuid()->toString() . '.' . ($file->getClientOriginalExtension() ?: 'png');
|
||||
$file->storeAs('', $path, 'qr');
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
private function generateUniqueShortCode(): string
|
||||
{
|
||||
$length = (int) config('qr.short_code_length', 8);
|
||||
|
||||
for ($attempt = 0; $attempt < 20; $attempt++) {
|
||||
$code = Str::lower(Str::random($length));
|
||||
if (! QrCode::query()->where('short_code', $code)->exists()) {
|
||||
return $code;
|
||||
}
|
||||
}
|
||||
|
||||
throw new RuntimeException('Could not generate a unique QR short code.');
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,488 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Qr;
|
||||
|
||||
use App\Models\QrCode as QrCodeModel;
|
||||
use App\Support\Qr\QrFrameStyleCatalog;
|
||||
use App\Support\Qr\QrModuleStyleCatalog;
|
||||
use App\Support\Qr\QrStyleDefaults;
|
||||
use chillerlan\QRCode\Common\EccLevel;
|
||||
use chillerlan\QRCode\Data\QRMatrix;
|
||||
use chillerlan\QRCode\QROptions;
|
||||
use chillerlan\QRCode\QRCode;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class QrImageGeneratorService
|
||||
{
|
||||
public function generateAndStore(QrCodeModel $qrCode): void
|
||||
{
|
||||
$url = $qrCode->encodedPayload();
|
||||
$style = $qrCode->style();
|
||||
$basePath = $qrCode->user_id . '/codes/' . $qrCode->id;
|
||||
|
||||
$pngBinary = $this->renderPng($url, $style);
|
||||
$svgMarkup = $this->renderSvg($url, $style);
|
||||
|
||||
$pngPath = $basePath . '/qr.png';
|
||||
$svgPath = $basePath . '/qr.svg';
|
||||
|
||||
Storage::disk('qr')->put($pngPath, $pngBinary);
|
||||
Storage::disk('qr')->put($svgPath, $svgMarkup);
|
||||
|
||||
$qrCode->update([
|
||||
'png_path' => $pngPath,
|
||||
'svg_path' => $svgPath,
|
||||
]);
|
||||
}
|
||||
|
||||
public function ensureValidImages(QrCodeModel $qrCode): QrCodeModel
|
||||
{
|
||||
if ($this->pngIsValid($qrCode->png_path)) {
|
||||
return $qrCode;
|
||||
}
|
||||
|
||||
$this->generateAndStore($qrCode);
|
||||
|
||||
return $qrCode->fresh();
|
||||
}
|
||||
|
||||
public function previewDataUri(QrCodeModel $qrCode): string
|
||||
{
|
||||
$qrCode = $this->ensureValidImages($qrCode);
|
||||
|
||||
if ($qrCode->png_path && Storage::disk('qr')->exists($qrCode->png_path)) {
|
||||
$bytes = Storage::disk('qr')->get($qrCode->png_path);
|
||||
if ($this->isValidPngBinary($bytes)) {
|
||||
return 'data:image/png;base64,' . base64_encode($bytes);
|
||||
}
|
||||
}
|
||||
|
||||
$png = $this->renderPng($qrCode->encodedPayload(), $qrCode->style());
|
||||
$this->generateAndStore($qrCode);
|
||||
|
||||
return 'data:image/png;base64,' . base64_encode($png);
|
||||
}
|
||||
|
||||
public function logoDataUri(string $path): ?string
|
||||
{
|
||||
if (! Storage::disk('qr')->exists($path)) {
|
||||
return null;
|
||||
}
|
||||
$content = Storage::disk('qr')->get($path);
|
||||
if ($content === null) {
|
||||
return null;
|
||||
}
|
||||
$mimeType = Storage::disk('qr')->mimeType($path);
|
||||
if (! $mimeType) {
|
||||
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||
$mimeType = match ($ext) {
|
||||
'png' => 'image/png',
|
||||
'jpg', 'jpeg' => 'image/jpeg',
|
||||
'svg' => 'image/svg+xml',
|
||||
'gif' => 'image/gif',
|
||||
'webp' => 'image/webp',
|
||||
default => 'image/png',
|
||||
};
|
||||
}
|
||||
|
||||
return 'data:' . $mimeType . ';base64,' . base64_encode($content);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $style
|
||||
*/
|
||||
public function renderPng(string $content, array $style): string
|
||||
{
|
||||
$style = QrStyleDefaults::mergeForRender($style);
|
||||
$options = $this->buildOptions($style, QRCode::OUTPUT_IMAGE_PNG);
|
||||
$binary = (new QRCode($options))->render($content);
|
||||
|
||||
if ($style['logo_path'] && Storage::disk('qr')->exists($style['logo_path'])) {
|
||||
$binary = $this->applyLogo($binary, Storage::disk('qr')->path($style['logo_path']));
|
||||
}
|
||||
|
||||
return $this->applyFrame($binary, (string) ($style['frame_style'] ?? 'none'), $style);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $style
|
||||
*/
|
||||
public function renderSvg(string $content, array $style): string
|
||||
{
|
||||
$style = QrStyleDefaults::mergeForRender($style);
|
||||
$options = $this->buildOptions($style, QRCode::OUTPUT_MARKUP_SVG);
|
||||
|
||||
return (new QRCode($options))->render($content);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $style */
|
||||
private function buildOptions(array $style, string $outputType): QROptions
|
||||
{
|
||||
$fg = $this->hexToRgb((string) $style['foreground']);
|
||||
$bg = $this->hexToRgb((string) $style['background']);
|
||||
$ecc = match ($style['error_correction']) {
|
||||
'L' => EccLevel::L,
|
||||
'Q' => EccLevel::Q,
|
||||
'H' => EccLevel::H,
|
||||
default => EccLevel::M,
|
||||
};
|
||||
|
||||
$hasLogo = ! empty($style['logo_path']);
|
||||
$module = QrModuleStyleCatalog::optionsFor((string) $style['module_style']);
|
||||
$moduleUsesCircular = (bool) $module['circular'];
|
||||
$finderOuter = (string) ($style['finder_outer'] ?? 'square');
|
||||
$finderInner = (string) ($style['finder_inner'] ?? 'square');
|
||||
$finderUsesCircular = $finderOuter !== 'square' || $finderInner === 'dot';
|
||||
$usesCircular = $moduleUsesCircular || $finderUsesCircular;
|
||||
$connectPaths = $module['connect_paths'] && $outputType === QRCode::OUTPUT_MARKUP_SVG;
|
||||
|
||||
$circleRadius = (float) $module['circle_radius'];
|
||||
if ($finderOuter === 'circle') {
|
||||
$circleRadius = max($circleRadius, 0.5);
|
||||
} elseif ($finderOuter === 'rounded') {
|
||||
$circleRadius = max($circleRadius, 0.38);
|
||||
}
|
||||
|
||||
$keepAsSquare = $this->resolveKeepAsSquare($moduleUsesCircular, $finderOuter, $finderInner);
|
||||
|
||||
return new QROptions([
|
||||
'outputType' => $outputType,
|
||||
'outputBase64' => false,
|
||||
'scale' => (int) $style['scale'],
|
||||
'eccLevel' => $ecc,
|
||||
'addQuietzone' => true,
|
||||
'quietzoneSize' => (int) $style['margin'],
|
||||
'bgColor' => $bg,
|
||||
'drawCircularModules' => $usesCircular,
|
||||
'circleRadius' => $circleRadius,
|
||||
'connectPaths' => $connectPaths,
|
||||
'gdImageUseUpscale' => true,
|
||||
'keepAsSquare' => $keepAsSquare,
|
||||
'addLogoSpace' => $hasLogo,
|
||||
'logoSpaceWidth' => $hasLogo ? 13 : null,
|
||||
'logoSpaceHeight' => $hasLogo ? 13 : null,
|
||||
'moduleValues' => [
|
||||
QRMatrix::M_DATA_DARK => $fg,
|
||||
QRMatrix::M_FINDER_DARK => $fg,
|
||||
QRMatrix::M_ALIGNMENT_DARK => $fg,
|
||||
QRMatrix::M_TIMING_DARK => $fg,
|
||||
QRMatrix::M_FORMAT_DARK => $fg,
|
||||
QRMatrix::M_VERSION_DARK => $fg,
|
||||
QRMatrix::M_FINDER_DOT => $fg,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return list<int> */
|
||||
private function resolveKeepAsSquare(bool $moduleUsesCircular, string $finderOuter, string $finderInner): array
|
||||
{
|
||||
$finderUsesCircular = ($finderOuter !== 'square') || ($finderInner === 'dot') || ($finderInner === 'rounded');
|
||||
|
||||
if (! $moduleUsesCircular && ! $finderUsesCircular) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$keep = [
|
||||
// Structural modules must always stay square for reliable scanning.
|
||||
QRMatrix::M_ALIGNMENT_DARK,
|
||||
QRMatrix::M_TIMING_DARK,
|
||||
QRMatrix::M_FORMAT_DARK,
|
||||
QRMatrix::M_VERSION_DARK,
|
||||
// White separator (M_FINDER light modules) must always stay square.
|
||||
// Removing it causes the circular dark-ring modules to lose their solid
|
||||
// white backdrop, which produces a broken / empty-looking eye pattern.
|
||||
QRMatrix::M_FINDER,
|
||||
];
|
||||
|
||||
// Data modules stay square when only the finder style is circular.
|
||||
if (! $moduleUsesCircular) {
|
||||
$keep[] = QRMatrix::M_DATA_DARK;
|
||||
}
|
||||
|
||||
// Outer finder frame: square keeps the ring solid; non-square renders it as dots.
|
||||
if ($finderOuter === 'square') {
|
||||
$keep[] = QRMatrix::M_FINDER_DARK;
|
||||
}
|
||||
|
||||
// Inner finder dot: 'dot' and 'rounded' both get circular treatment.
|
||||
if ($finderInner !== 'dot' && $finderInner !== 'rounded') {
|
||||
$keep[] = QRMatrix::M_FINDER_DOT;
|
||||
}
|
||||
|
||||
return array_values(array_unique($keep));
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $style */
|
||||
private function applyFrame(string $pngBinary, string $frameStyle, array $style = []): string
|
||||
{
|
||||
$frame = QrFrameStyleCatalog::all()[$frameStyle] ?? QrFrameStyleCatalog::all()['none'];
|
||||
$borderPx = (int) $frame['border_px'];
|
||||
$mode = (string) ($frame['mode'] ?? 'border');
|
||||
|
||||
if ($borderPx === 0 || ! extension_loaded('gd')) {
|
||||
return $pngBinary;
|
||||
}
|
||||
|
||||
$source = @imagecreatefromstring($pngBinary);
|
||||
if (! $source) {
|
||||
return $pngBinary;
|
||||
}
|
||||
|
||||
$width = imagesx($source);
|
||||
$height = imagesy($source);
|
||||
$labelHeight = in_array($mode, ['label', 'pill'], true) ? max(44, (int) round($height * 0.18)) : 0;
|
||||
$canvasWidth = $width + ($borderPx * 2);
|
||||
$canvasHeight = $height + ($borderPx * 2) + $labelHeight;
|
||||
$frameColorHex = trim((string) ($style['frame_color'] ?? '#000000'));
|
||||
if (! preg_match('/^#[0-9a-fA-F]{6}$/', $frameColorHex)) {
|
||||
$frameColorHex = '#000000';
|
||||
}
|
||||
|
||||
$canvas = imagecreatetruecolor($canvasWidth, $canvasHeight);
|
||||
$white = imagecolorallocate($canvas, 255, 255, 255);
|
||||
|
||||
// For border mode the padding area IS the frame — fill with frame colour.
|
||||
// For label/pill modes the background stays white; only the CTA element uses frame colour.
|
||||
if ($mode === 'border') {
|
||||
[$fr, $fg, $fb] = $this->hexToRgb($frameColorHex);
|
||||
$frameBg = imagecolorallocate($canvas, $fr, $fg, $fb);
|
||||
imagefilledrectangle($canvas, 0, 0, $canvasWidth, $canvasHeight, $frameBg);
|
||||
} else {
|
||||
imagefilledrectangle($canvas, 0, 0, $canvasWidth, $canvasHeight, $white);
|
||||
}
|
||||
|
||||
imagecopy($canvas, $source, $borderPx, $borderPx, 0, 0, $width, $height);
|
||||
|
||||
if ($labelHeight > 0) {
|
||||
$customText = trim((string) ($style['frame_text'] ?? ''));
|
||||
$ctaText = $customText !== '' ? $customText : (string) ($frame['cta'] ?? 'SCAN ME');
|
||||
$this->drawFrameLabel($canvas, $ctaText, $borderPx, $width, $height, $labelHeight, $mode, $frameColorHex);
|
||||
}
|
||||
|
||||
ob_start();
|
||||
imagepng($canvas);
|
||||
$result = (string) ob_get_clean();
|
||||
imagedestroy($source);
|
||||
imagedestroy($canvas);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function drawFrameLabel(\GdImage $canvas, string $text, int $borderPx, int $qrWidth, int $qrHeight, int $labelHeight, string $mode, string $frameColorHex = '#000000'): void
|
||||
{
|
||||
$canvasWidth = imagesx($canvas);
|
||||
$canvasHeight = imagesy($canvas);
|
||||
[$fr, $fg, $fb] = $this->hexToRgb($frameColorHex);
|
||||
$frameColor = imagecolorallocate($canvas, $fr, $fg, $fb);
|
||||
// Choose black or white text depending on frame colour luminance
|
||||
$luminance = (0.2126 * $fr + 0.7152 * $fg + 0.0722 * $fb) / 255;
|
||||
$textOnFrame = $luminance > 0.35
|
||||
? imagecolorallocate($canvas, 0, 0, 0)
|
||||
: imagecolorallocate($canvas, 255, 255, 255);
|
||||
$dividerColor = imagecolorallocate($canvas, $fr, $fg, $fb);
|
||||
$labelTop = $borderPx + $qrHeight;
|
||||
$labelBottom = $canvasHeight - max(8, (int) round($borderPx * 0.6));
|
||||
|
||||
if ($mode === 'pill') {
|
||||
$pillMargin = max(12, (int) round($borderPx * 0.9));
|
||||
$pillTop = $labelTop + max(7, (int) round($labelHeight * 0.18));
|
||||
$pillBottom = $labelBottom - max(5, (int) round($labelHeight * 0.12));
|
||||
imagefilledrectangle($canvas, $pillMargin + 10, $pillTop, $canvasWidth - $pillMargin - 10, $pillBottom, $frameColor);
|
||||
imagefilledellipse($canvas, $pillMargin + 10, (int) (($pillTop + $pillBottom) / 2), $pillBottom - $pillTop, $pillBottom - $pillTop, $frameColor);
|
||||
imagefilledellipse($canvas, $canvasWidth - $pillMargin - 10, (int) (($pillTop + $pillBottom) / 2), $pillBottom - $pillTop, $pillBottom - $pillTop, $frameColor);
|
||||
$this->drawCenteredString($canvas, $text, $textOnFrame, 5, $pillTop, $pillBottom);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
imageline($canvas, $borderPx + 8, $labelTop + 3, $borderPx + $qrWidth - 8, $labelTop + 3, $dividerColor);
|
||||
$this->drawCenteredString($canvas, $text, $frameColor, 5, $labelTop + 7, $labelBottom);
|
||||
}
|
||||
|
||||
private function drawCenteredString(\GdImage $canvas, string $text, int $color, int $font, int $top, int $bottom): void
|
||||
{
|
||||
$text = strtoupper($text);
|
||||
$font = max(1, min(5, $font));
|
||||
$textWidth = imagefontwidth($font) * strlen($text);
|
||||
$textHeight = imagefontheight($font);
|
||||
$x = max(0, (int) round((imagesx($canvas) - $textWidth) / 2));
|
||||
$y = max($top, (int) round($top + (($bottom - $top - $textHeight) / 2)));
|
||||
imagestring($canvas, $font, $x, $y, $text, $color);
|
||||
}
|
||||
|
||||
/** @return array{0: int, 1: int, 2: int} */
|
||||
private function hexToRgb(string $hex): array
|
||||
{
|
||||
$hex = ltrim(trim($hex), '#');
|
||||
if (strlen($hex) === 3) {
|
||||
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
|
||||
}
|
||||
|
||||
if (strlen($hex) !== 6 || ! ctype_xdigit($hex)) {
|
||||
return [0, 0, 0];
|
||||
}
|
||||
|
||||
return [
|
||||
hexdec(substr($hex, 0, 2)),
|
||||
hexdec(substr($hex, 2, 2)),
|
||||
hexdec(substr($hex, 4, 2)),
|
||||
];
|
||||
}
|
||||
|
||||
private function applyLogo(string $pngBinary, string $logoPath): string
|
||||
{
|
||||
if (! extension_loaded('gd')) {
|
||||
return $pngBinary;
|
||||
}
|
||||
|
||||
$qr = imagecreatefromstring($pngBinary);
|
||||
$logo = @imagecreatefromstring((string) file_get_contents($logoPath));
|
||||
|
||||
if (! $qr || ! $logo) {
|
||||
return $pngBinary;
|
||||
}
|
||||
|
||||
$qrW = imagesx($qr);
|
||||
$qrH = imagesy($qr);
|
||||
$logoW = imagesx($logo);
|
||||
$logoH = imagesy($logo);
|
||||
$target = (int) round(min($qrW, $qrH) * 0.22);
|
||||
$resized = imagecreatetruecolor($target, $target);
|
||||
imagealphablending($resized, false);
|
||||
imagesavealpha($resized, true);
|
||||
$transparent = imagecolorallocatealpha($resized, 255, 255, 255, 127);
|
||||
imagefilledrectangle($resized, 0, 0, $target, $target, $transparent);
|
||||
imagecopyresampled($resized, $logo, 0, 0, 0, 0, $target, $target, $logoW, $logoH);
|
||||
|
||||
$pad = (int) round($target * 0.12);
|
||||
$bgSize = $target + ($pad * 2);
|
||||
$bgX = (int) (($qrW - $bgSize) / 2);
|
||||
$bgY = (int) (($qrH - $bgSize) / 2);
|
||||
$white = imagecolorallocate($qr, 255, 255, 255);
|
||||
imagefilledrectangle($qr, $bgX, $bgY, $bgX + $bgSize, $bgY + $bgSize, $white);
|
||||
imagecopy($qr, $resized, $bgX + $pad, $bgY + $pad, 0, 0, $target, $target);
|
||||
|
||||
ob_start();
|
||||
imagepng($qr);
|
||||
$result = (string) ob_get_clean();
|
||||
imagedestroy($qr);
|
||||
imagedestroy($logo);
|
||||
imagedestroy($resized);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function pngIsValid(?string $path): bool
|
||||
{
|
||||
if (! $path || ! Storage::disk('qr')->exists($path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->isValidPngBinary(Storage::disk('qr')->get($path));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a client-supplied QR SVG before storing it. QR SVGs are paths,
|
||||
* rects, gradients, clip-paths and an embedded data: logo — never scripts.
|
||||
* Strips script/foreignObject, inline event handlers, javascript: URIs and
|
||||
* any non-data external image/href references. Returns null if it isn't an
|
||||
* SVG. Downloads are served as attachments, so this is defence-in-depth.
|
||||
*/
|
||||
public function sanitizeSvg(string $svg): ?string
|
||||
{
|
||||
$svg = trim($svg);
|
||||
if (! preg_match('/<svg[\s>]/i', $svg)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Drop XML declaration / doctype.
|
||||
$svg = preg_replace('/<\?xml.*?\?>/is', '', $svg);
|
||||
$svg = preg_replace('/<!DOCTYPE.*?>/is', '', $svg);
|
||||
|
||||
// Remove dangerous elements entirely (with or without content).
|
||||
$svg = preg_replace('#<\s*(script|foreignObject|iframe|style)\b[^>]*>.*?<\s*/\s*\1\s*>#is', '', $svg);
|
||||
$svg = preg_replace('#<\s*(script|foreignObject|iframe|style)\b[^>]*/?>#is', '', $svg);
|
||||
|
||||
// Strip inline event handlers (onload, onclick, …).
|
||||
$svg = preg_replace('/\son[a-z]+\s*=\s*"[^"]*"/i', '', $svg);
|
||||
$svg = preg_replace("/\son[a-z]+\s*=\s*'[^']*'/i", '', $svg);
|
||||
|
||||
// Neutralise javascript: in any href / xlink:href.
|
||||
$svg = preg_replace('/((?:xlink:)?href)\s*=\s*"\s*javascript:[^"]*"/i', '$1="#"', $svg);
|
||||
$svg = preg_replace("/((?:xlink:)?href)\s*=\s*'\s*javascript:[^']*'/i", '$1="#"', $svg);
|
||||
|
||||
// Remove external (non-data:) image references; keep embedded data: logos.
|
||||
$svg = preg_replace('/<image\b(?:(?!href)[^>])*(?:xlink:)?href\s*=\s*"(?!data:)[^"]*"[^>]*>/is', '', $svg);
|
||||
|
||||
$svg = trim($svg);
|
||||
if (! str_contains($svg, '<svg')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Guarantee namespaces + an XML prolog so the file renders in strict SVG
|
||||
// viewers (Illustrator, Inkscape, macOS Preview, print pipelines), not just
|
||||
// browsers. qr-code-styling's DOM serialization can omit xmlns:xlink, which
|
||||
// browsers tolerate but standalone viewers reject (logo/refs fail to render).
|
||||
if (preg_match('/<svg\b[^>]*>/i', $svg, $m)) {
|
||||
$open = $m[0];
|
||||
$fixed = $open;
|
||||
if (! preg_match('/\sxmlns\s*=/i', $fixed)) {
|
||||
$fixed = preg_replace('/<svg\b/i', '<svg xmlns="http://www.w3.org/2000/svg"', $fixed, 1);
|
||||
}
|
||||
if (str_contains($svg, 'xlink:') && ! preg_match('/\sxmlns:xlink\s*=/i', $fixed)) {
|
||||
$fixed = preg_replace('/<svg\b/i', '<svg xmlns:xlink="http://www.w3.org/1999/xlink"', $fixed, 1);
|
||||
}
|
||||
if ($fixed !== $open) {
|
||||
$svg = substr_replace($svg, $fixed, strpos($svg, $open), strlen($open));
|
||||
}
|
||||
}
|
||||
|
||||
if (! preg_match('/^\s*<\?xml/i', $svg)) {
|
||||
$svg = '<?xml version="1.0" encoding="UTF-8"?>' . "\n" . $svg;
|
||||
}
|
||||
|
||||
return $svg;
|
||||
}
|
||||
|
||||
public function isValidPngBinary(string $bytes): bool
|
||||
{
|
||||
if ($bytes === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (str_starts_with($bytes, "\x89PNG\r\n\x1a\n")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Legacy bug: PNG was stored as a base64 string instead of raw bytes.
|
||||
if (str_starts_with($bytes, 'iVBORw0KGgo')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function normalizeStoredPng(?string $path): ?string
|
||||
{
|
||||
if (! $path || ! Storage::disk('qr')->exists($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$bytes = Storage::disk('qr')->get($path);
|
||||
|
||||
if ($this->isValidPngBinary($bytes)) {
|
||||
return $bytes;
|
||||
}
|
||||
|
||||
if (str_starts_with($bytes, 'iVBORw0KGgo')) {
|
||||
$decoded = base64_decode($bytes, true);
|
||||
if ($decoded !== false && $this->isValidPngBinary($decoded)) {
|
||||
Storage::disk('qr')->put($path, $decoded);
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,663 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Qr;
|
||||
|
||||
use App\Models\QrCode;
|
||||
use App\Support\Qr\QrDateFormatter;
|
||||
use App\Support\Qr\QrTypeCatalog;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use RuntimeException;
|
||||
|
||||
class QrPayloadValidator
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $input
|
||||
* @return array{content: array<string, mixed>, destination_url: ?string}
|
||||
*/
|
||||
public function validateForCreate(string $type, array $input): array
|
||||
{
|
||||
if (! QrTypeCatalog::isValid($type)) {
|
||||
throw new RuntimeException('Invalid QR type selected.');
|
||||
}
|
||||
|
||||
return match ($type) {
|
||||
QrCode::TYPE_URL => $this->validateUrl($input),
|
||||
QrCode::TYPE_DOCUMENT => $this->validateDocument($input),
|
||||
QrCode::TYPE_LINK_LIST => $this->validateLinkList($input),
|
||||
QrCode::TYPE_VCARD => $this->validateVcard($input),
|
||||
QrCode::TYPE_BUSINESS => $this->validateBusiness($input),
|
||||
QrCode::TYPE_CHURCH => $this->validateChurch($input),
|
||||
QrCode::TYPE_EVENT => $this->validateEvent($input),
|
||||
QrCode::TYPE_ITINERARY => $this->validateItinerary($input),
|
||||
QrCode::TYPE_IMAGE => ['content' => [], 'destination_url' => null],
|
||||
QrCode::TYPE_MENU => $this->validateMenu($input),
|
||||
QrCode::TYPE_SHOP => $this->validateShop($input),
|
||||
QrCode::TYPE_APP => $this->validateApp($input),
|
||||
QrCode::TYPE_BOOK => $this->validateBook($input),
|
||||
QrCode::TYPE_WIFI => $this->validateWifi($input),
|
||||
QrCode::TYPE_PAYMENT => $this->validatePayment($input),
|
||||
default => throw new RuntimeException('Unsupported QR type.'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $input
|
||||
* @return array{content: array<string, mixed>, destination_url: ?string}
|
||||
*/
|
||||
public function validateForUpdate(QrCode $qrCode, array $input): array
|
||||
{
|
||||
$merged = array_merge($qrCode->content(), $input);
|
||||
|
||||
if ($qrCode->isUrlType() && empty($merged['destination_url']) && ! empty($merged['url'])) {
|
||||
$merged['destination_url'] = $merged['url'];
|
||||
}
|
||||
|
||||
return $this->validateForCreate($qrCode->type, $merged);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $input */
|
||||
private function validateDocument(array $input): array
|
||||
{
|
||||
$allowDownload = filter_var($input['allow_download'] ?? true, FILTER_VALIDATE_BOOL);
|
||||
|
||||
return ['content' => ['allow_download' => $allowDownload], 'destination_url' => null];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $input */
|
||||
private function validateUrl(array $input): array
|
||||
{
|
||||
$url = $this->requireUrl($input['destination_url'] ?? null, 'Enter a valid destination URL.');
|
||||
|
||||
return ['content' => ['url' => $url], 'destination_url' => $url];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $input */
|
||||
private function validateLinkList(array $input): array
|
||||
{
|
||||
$links = $this->normalizeLinks($input['links'] ?? []);
|
||||
|
||||
if ($links === []) {
|
||||
throw new RuntimeException('Add at least one link.');
|
||||
}
|
||||
|
||||
return ['content' => ['links' => $links], 'destination_url' => null];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $input */
|
||||
private function validateVcard(array $input): array
|
||||
{
|
||||
$first = trim((string) ($input['first_name'] ?? ''));
|
||||
$last = trim((string) ($input['last_name'] ?? ''));
|
||||
|
||||
if ($first === '' && $last === '') {
|
||||
throw new RuntimeException('Enter a first or last name for the vCard.');
|
||||
}
|
||||
|
||||
$social = [];
|
||||
foreach (['linkedin', 'twitter', 'instagram', 'facebook', 'tiktok', 'youtube', 'whatsapp', 'snapchat'] as $platform) {
|
||||
$raw = trim((string) (($input['social'] ?? [])[$platform] ?? ''));
|
||||
if ($raw !== '') {
|
||||
$social[$platform] = static::normalizeSocialUrl($platform, $raw);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'content' => [
|
||||
'first_name' => $first,
|
||||
'last_name' => $last,
|
||||
'phone' => trim((string) ($input['phone'] ?? '')),
|
||||
'email' => trim((string) ($input['email'] ?? '')),
|
||||
'company' => trim((string) ($input['company'] ?? '')),
|
||||
'website' => trim((string) ($input['website'] ?? '')),
|
||||
'address' => trim((string) ($input['address'] ?? '')),
|
||||
'note' => trim((string) ($input['note'] ?? '')),
|
||||
'avatar_path' => $input['avatar_path'] ?? null,
|
||||
'social' => $social,
|
||||
],
|
||||
'destination_url' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $input */
|
||||
private function validateBusiness(array $input): array
|
||||
{
|
||||
$name = trim((string) ($input['name'] ?? ''));
|
||||
|
||||
if ($name === '') {
|
||||
throw new RuntimeException('Enter a business name.');
|
||||
}
|
||||
|
||||
$social = [];
|
||||
foreach (['linkedin', 'twitter', 'instagram', 'facebook', 'tiktok', 'youtube', 'whatsapp', 'snapchat'] as $platform) {
|
||||
$raw = trim((string) (($input['social'] ?? [])[$platform] ?? ''));
|
||||
if ($raw !== '') {
|
||||
$social[$platform] = static::normalizeSocialUrl($platform, $raw);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'content' => [
|
||||
'name' => $name,
|
||||
'tagline' => trim((string) ($input['tagline'] ?? '')),
|
||||
'phone' => trim((string) ($input['phone'] ?? '')),
|
||||
'email' => trim((string) ($input['email'] ?? '')),
|
||||
'website' => trim((string) ($input['website'] ?? '')),
|
||||
'address' => trim((string) ($input['address'] ?? '')),
|
||||
'hours' => trim((string) ($input['hours'] ?? '')),
|
||||
'brand_color' => $this->normalizeBrandColor($input['brand_color'] ?? null),
|
||||
'logo_path' => $input['logo_path'] ?? null,
|
||||
'cover_path' => $input['cover_path'] ?? null,
|
||||
'social' => $social,
|
||||
],
|
||||
'destination_url' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $input */
|
||||
private function validateChurch(array $input): array
|
||||
{
|
||||
$name = trim((string) ($input['name'] ?? ''));
|
||||
if ($name === '') {
|
||||
throw new RuntimeException('Enter the church name.');
|
||||
}
|
||||
|
||||
$orgTypes = ['church', 'school', 'mosque', 'ngo', 'club'];
|
||||
$orgType = in_array($input['org_type'] ?? 'church', $orgTypes) ? $input['org_type'] : 'church';
|
||||
|
||||
// Normalise legacy lowercase slugs → display strings
|
||||
$legacyMap = ['offering' => 'Offering', 'tithe' => 'Tithe', 'donation' => 'Donation', 'harvest' => 'Harvest'];
|
||||
$collectionTypes = array_values(array_unique(array_filter(
|
||||
array_map(fn ($t) => $legacyMap[strtolower(trim((string) $t))] ?? ucwords(trim((string) $t)), (array) ($input['collection_types'] ?? [])),
|
||||
fn ($t) => $t !== '' && strlen($t) <= 60
|
||||
)));
|
||||
if (empty($collectionTypes)) {
|
||||
$collectionTypes = ['Offering', 'Tithe', 'Donation', 'Harvest'];
|
||||
}
|
||||
|
||||
return [
|
||||
'content' => [
|
||||
'name' => $name,
|
||||
'denomination' => trim((string) ($input['denomination'] ?? '')),
|
||||
'description' => trim((string) ($input['description'] ?? '')),
|
||||
'phone' => trim((string) ($input['phone'] ?? '')),
|
||||
'email' => trim((string) ($input['email'] ?? '')),
|
||||
'website' => trim((string) ($input['website'] ?? '')),
|
||||
'address' => trim((string) ($input['address'] ?? '')),
|
||||
'service_times' => trim((string) ($input['service_times'] ?? '')),
|
||||
'brand_color' => $this->normalizeBrandColor($input['brand_color'] ?? null) ?? '#1a3a5c',
|
||||
'logo_path' => $input['logo_path'] ?? null,
|
||||
'cover_path' => $input['cover_path'] ?? null,
|
||||
'org_type' => $orgType,
|
||||
'accepts_payment' => filter_var($input['accepts_payment'] ?? false, FILTER_VALIDATE_BOOL),
|
||||
'currency' => 'GHS',
|
||||
'collection_types' => $collectionTypes,
|
||||
],
|
||||
'destination_url' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $input */
|
||||
private function validateEvent(array $input): array
|
||||
{
|
||||
$name = trim((string) ($input['name'] ?? ''));
|
||||
if ($name === '') {
|
||||
throw new RuntimeException('Enter the event name.');
|
||||
}
|
||||
|
||||
// Ticket tiers: [{ name, price, capacity }]
|
||||
$tiers = [];
|
||||
foreach ((array) ($input['tiers'] ?? []) as $tier) {
|
||||
if (! is_array($tier)) {
|
||||
continue;
|
||||
}
|
||||
$tierName = trim((string) ($tier['name'] ?? ''));
|
||||
if ($tierName === '') {
|
||||
continue;
|
||||
}
|
||||
$price = round((float) ($tier['price'] ?? 0), 2);
|
||||
$capacity = (int) ($tier['capacity'] ?? 0);
|
||||
$tiers[] = [
|
||||
'name' => mb_substr($tierName, 0, 80),
|
||||
'price' => max(0, $price),
|
||||
'capacity' => max(0, $capacity), // 0 = unlimited
|
||||
];
|
||||
}
|
||||
if (empty($tiers)) {
|
||||
$tiers = [['name' => 'General Admission', 'price' => 0.0, 'capacity' => 0]];
|
||||
}
|
||||
|
||||
// Extra registration/badge fields the organiser wants captured.
|
||||
$badgeFields = [];
|
||||
foreach ((array) ($input['badge_fields'] ?? []) as $field) {
|
||||
$label = trim((string) (is_array($field) ? ($field['label'] ?? '') : $field));
|
||||
if ($label !== '' && strlen($label) <= 60) {
|
||||
$badgeFields[] = mb_substr($label, 0, 60);
|
||||
}
|
||||
}
|
||||
|
||||
// Mode: sell tickets, collect cash contributions (weddings, non-profits),
|
||||
// or a free event (registration only — no tickets, no contributions).
|
||||
$mode = in_array($input['mode'] ?? 'ticketing', ['ticketing', 'contributions', 'free'], true)
|
||||
? ($input['mode'] ?? 'ticketing')
|
||||
: 'ticketing';
|
||||
|
||||
// Contribution categories: ['Wedding Gift', 'Donation', …]
|
||||
$categories = [];
|
||||
foreach ((array) ($input['contribution_categories'] ?? []) as $cat) {
|
||||
$label = trim((string) (is_array($cat) ? ($cat['name'] ?? '') : $cat));
|
||||
if ($label !== '') {
|
||||
$categories[] = mb_substr($label, 0, 80);
|
||||
}
|
||||
}
|
||||
if ($mode === 'contributions' && empty($categories)) {
|
||||
$categories = ['Contribution'];
|
||||
}
|
||||
|
||||
// Free events collect neither tickets nor contributions — a single free
|
||||
// registration. Forced deterministically so switching from a paid setup
|
||||
// can never leave a chargeable tier behind.
|
||||
if ($mode === 'free') {
|
||||
$tiers = [['name' => 'Registration', 'price' => 0.0, 'capacity' => 0]];
|
||||
$categories = [];
|
||||
}
|
||||
|
||||
// Contributions always take payment; ticketing only for paid tiers; free never.
|
||||
$acceptsPayment = match ($mode) {
|
||||
'contributions' => true,
|
||||
'free' => false,
|
||||
default => $this->eventHasPaidTier($tiers),
|
||||
};
|
||||
|
||||
return [
|
||||
'content' => [
|
||||
'name' => mb_substr($name, 0, 120),
|
||||
'tagline' => trim((string) ($input['tagline'] ?? '')),
|
||||
'description' => trim((string) ($input['description'] ?? '')),
|
||||
'location' => trim((string) ($input['location'] ?? '')),
|
||||
'starts_at' => QrDateFormatter::normalize((string) ($input['starts_at'] ?? '')),
|
||||
'ends_at' => QrDateFormatter::normalize((string) ($input['ends_at'] ?? '')),
|
||||
'organizer' => trim((string) ($input['organizer'] ?? '')),
|
||||
'website' => trim((string) ($input['website'] ?? '')),
|
||||
'brand_color' => $this->normalizeBrandColor($input['brand_color'] ?? null) ?? '#4f46e5',
|
||||
'logo_path' => $input['logo_path'] ?? null,
|
||||
'cover_path' => $input['cover_path'] ?? null,
|
||||
'currency' => 'GHS',
|
||||
'mode' => $mode,
|
||||
'tiers' => array_values($tiers),
|
||||
'contribution_categories' => array_values($categories),
|
||||
'badge_fields' => array_values($badgeFields),
|
||||
'badge_size' => in_array($input['badge_size'] ?? '4x3', ['4x3', '4x6', 'cr80'], true) ? ($input['badge_size'] ?? '4x3') : '4x3',
|
||||
'accepts_payment' => $acceptsPayment,
|
||||
'registration_open' => filter_var($input['registration_open'] ?? true, FILTER_VALIDATE_BOOL),
|
||||
'programme_qr_id' => ($pid = (int) ($input['programme_qr_id'] ?? 0)) > 0 ? $pid : null,
|
||||
],
|
||||
'destination_url' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<int, array{price: float}> $tiers */
|
||||
private function eventHasPaidTier(array $tiers): bool
|
||||
{
|
||||
foreach ($tiers as $tier) {
|
||||
if ((float) ($tier['price'] ?? 0) > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $input */
|
||||
private function validateItinerary(array $input): array
|
||||
{
|
||||
$title = trim((string) ($input['title'] ?? ''));
|
||||
if ($title === '') {
|
||||
throw new RuntimeException('Enter the itinerary title.');
|
||||
}
|
||||
|
||||
// Days: [{ label, date, items: [{ time, title, description, location, host }] }]
|
||||
$days = [];
|
||||
foreach ((array) ($input['days'] ?? []) as $day) {
|
||||
if (! is_array($day)) {
|
||||
continue;
|
||||
}
|
||||
$items = [];
|
||||
foreach ((array) ($day['items'] ?? []) as $item) {
|
||||
if (! is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
$itemTitle = trim((string) ($item['title'] ?? ''));
|
||||
if ($itemTitle === '') {
|
||||
continue;
|
||||
}
|
||||
$items[] = [
|
||||
'time' => mb_substr(trim((string) ($item['time'] ?? '')), 0, 40),
|
||||
'title' => mb_substr($itemTitle, 0, 140),
|
||||
'description' => mb_substr(trim((string) ($item['description'] ?? '')), 0, 400),
|
||||
'location' => mb_substr(trim((string) ($item['location'] ?? '')), 0, 120),
|
||||
'host' => mb_substr(trim((string) ($item['host'] ?? '')), 0, 120),
|
||||
];
|
||||
}
|
||||
if (empty($items) && trim((string) ($day['label'] ?? '')) === '') {
|
||||
continue;
|
||||
}
|
||||
$days[] = [
|
||||
'label' => mb_substr(trim((string) ($day['label'] ?? '')), 0, 80),
|
||||
'date' => QrDateFormatter::normalize((string) ($day['date'] ?? '')),
|
||||
'items' => array_values($items),
|
||||
];
|
||||
}
|
||||
if (empty($days)) {
|
||||
throw new RuntimeException('Add at least one programme item.');
|
||||
}
|
||||
|
||||
return [
|
||||
'content' => [
|
||||
'title' => mb_substr($title, 0, 120),
|
||||
'subtitle' => trim((string) ($input['subtitle'] ?? '')),
|
||||
'description' => trim((string) ($input['description'] ?? '')),
|
||||
'event_date' => QrDateFormatter::normalize((string) ($input['event_date'] ?? '')),
|
||||
'location' => trim((string) ($input['location'] ?? '')),
|
||||
'brand_color' => $this->normalizeBrandColor($input['brand_color'] ?? null) ?? '#b45309',
|
||||
'cover_path' => $input['cover_path'] ?? null,
|
||||
'days' => array_values($days),
|
||||
],
|
||||
'destination_url' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $input */
|
||||
private function validateMenu(array $input): array
|
||||
{
|
||||
$title = trim((string) ($input['menu_title'] ?? 'Menu'));
|
||||
$sections = $this->normalizeMenuSections($input['sections'] ?? []);
|
||||
$acceptsPayment = filter_var($input['accepts_payment'] ?? false, FILTER_VALIDATE_BOOL);
|
||||
|
||||
$shippingType = in_array($input['shipping_type'] ?? 'none', ['none', 'flat'], true)
|
||||
? (string) ($input['shipping_type'] ?? 'none')
|
||||
: 'none';
|
||||
$shippingFee = $shippingType === 'flat' ? round(max(0, (float) ($input['shipping_fee'] ?? 0)), 2) : 0.0;
|
||||
$freeShippingAbove = round(max(0, (float) ($input['free_shipping_above'] ?? 0)), 2);
|
||||
|
||||
if ($sections === []) {
|
||||
throw new RuntimeException('Add at least one menu section with items.');
|
||||
}
|
||||
|
||||
return [
|
||||
'content' => [
|
||||
'title' => $title,
|
||||
'sections' => $sections,
|
||||
'accepts_payment' => $acceptsPayment,
|
||||
'shipping_type' => $shippingType,
|
||||
'shipping_fee' => $shippingFee,
|
||||
'free_shipping_above' => $freeShippingAbove,
|
||||
'brand_color' => $this->normalizeBrandColor($input['brand_color'] ?? null),
|
||||
'logo_path' => $input['logo_path'] ?? null,
|
||||
'cover_path' => $input['cover_path'] ?? null,
|
||||
],
|
||||
'destination_url' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $input */
|
||||
private function validateShop(array $input): array
|
||||
{
|
||||
$title = trim((string) ($input['shop_title'] ?? $input['menu_title'] ?? 'Shop'));
|
||||
$currency = trim((string) ($input['currency'] ?? 'GHS'));
|
||||
$sections = $this->normalizeMenuSections($input['sections'] ?? []);
|
||||
$acceptsPayment = filter_var($input['accepts_payment'] ?? false, FILTER_VALIDATE_BOOL);
|
||||
|
||||
$shippingType = in_array($input['shipping_type'] ?? 'none', ['none', 'flat'], true)
|
||||
? (string) ($input['shipping_type'] ?? 'none')
|
||||
: 'none';
|
||||
$shippingFee = $shippingType === 'flat' ? round(max(0, (float) ($input['shipping_fee'] ?? 0)), 2) : 0.0;
|
||||
$freeShippingAbove = round(max(0, (float) ($input['free_shipping_above'] ?? 0)), 2);
|
||||
|
||||
if ($sections === []) {
|
||||
throw new RuntimeException('Add at least one category with products.');
|
||||
}
|
||||
|
||||
return [
|
||||
'content' => [
|
||||
'title' => $title,
|
||||
'currency' => $currency,
|
||||
'sections' => $sections,
|
||||
'accepts_payment' => $acceptsPayment,
|
||||
'shipping_type' => $shippingType,
|
||||
'shipping_fee' => $shippingFee,
|
||||
'free_shipping_above' => $freeShippingAbove,
|
||||
'brand_color' => $this->normalizeBrandColor($input['brand_color'] ?? null),
|
||||
'logo_path' => $input['logo_path'] ?? null,
|
||||
'cover_path' => $input['cover_path'] ?? null,
|
||||
],
|
||||
'destination_url' => null,
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeBrandColor(mixed $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
$hex = trim((string) $value);
|
||||
|
||||
return preg_match('/^#[0-9A-Fa-f]{6}$/', $hex) ? $hex : null;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $input */
|
||||
private function validateApp(array $input): array
|
||||
{
|
||||
$name = trim((string) ($input['app_name'] ?? ''));
|
||||
$ios = trim((string) ($input['ios_url'] ?? ''));
|
||||
$android = trim((string) ($input['android_url'] ?? ''));
|
||||
$web = trim((string) ($input['web_url'] ?? ''));
|
||||
|
||||
if ($name === '') {
|
||||
throw new RuntimeException('Enter an app name.');
|
||||
}
|
||||
|
||||
if ($ios === '' && $android === '' && $web === '') {
|
||||
throw new RuntimeException('Add at least one app store or website link.');
|
||||
}
|
||||
|
||||
foreach (['ios' => $ios, 'android' => $android, 'web' => $web] as $label => $url) {
|
||||
if ($url !== '' && ! filter_var($url, FILTER_VALIDATE_URL)) {
|
||||
throw new RuntimeException("Enter a valid {$label} URL.");
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'content' => [
|
||||
'name' => $name,
|
||||
'ios_url' => $ios,
|
||||
'android_url' => $android,
|
||||
'web_url' => $web,
|
||||
'icon_path' => $input['icon_path'] ?? null,
|
||||
],
|
||||
'destination_url' => $web ?: ($ios ?: $android),
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $input */
|
||||
private function validateBook(array $input): array
|
||||
{
|
||||
$title = trim((string) ($input['book_title'] ?? ''));
|
||||
$author = trim((string) ($input['author'] ?? ''));
|
||||
$price = (float) ($input['price_ghs'] ?? 0);
|
||||
|
||||
if ($title === '') {
|
||||
throw new RuntimeException('Enter the book title.');
|
||||
}
|
||||
if ($author === '') {
|
||||
throw new RuntimeException('Enter the author name.');
|
||||
}
|
||||
if ($price <= 0) {
|
||||
throw new RuntimeException('Enter a price greater than zero.');
|
||||
}
|
||||
|
||||
return [
|
||||
'content' => [
|
||||
'book_title' => $title,
|
||||
'author' => $author,
|
||||
'description' => trim((string) ($input['description'] ?? '')),
|
||||
'price_ghs' => round($price, 2),
|
||||
'cover_path' => $input['cover_path'] ?? null,
|
||||
'file_path' => $input['file_path'] ?? null,
|
||||
'file_type' => $input['file_type'] ?? null,
|
||||
'file_size' => (int) ($input['file_size'] ?? 0),
|
||||
],
|
||||
'destination_url' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $input */
|
||||
private function validateWifi(array $input): array
|
||||
{
|
||||
$ssid = trim((string) ($input['ssid'] ?? ''));
|
||||
if ($ssid === '') {
|
||||
throw new RuntimeException('Enter a WiFi network name (SSID).');
|
||||
}
|
||||
|
||||
$encryption = strtoupper(trim((string) ($input['encryption'] ?? 'WPA')));
|
||||
if (! in_array($encryption, ['WPA', 'WEP', 'NOPASS'], true)) {
|
||||
$encryption = 'WPA';
|
||||
}
|
||||
|
||||
return [
|
||||
'content' => [
|
||||
'ssid' => $ssid,
|
||||
'password' => (string) ($input['password'] ?? ''),
|
||||
'encryption' => $encryption,
|
||||
'hidden' => filter_var($input['hidden'] ?? false, FILTER_VALIDATE_BOOL),
|
||||
],
|
||||
'destination_url' => null,
|
||||
];
|
||||
}
|
||||
|
||||
public static function normalizeSocialUrl(string $platform, string $value): string
|
||||
{
|
||||
// Already a full URL — leave as-is
|
||||
if (str_starts_with($value, 'http://') || str_starts_with($value, 'https://')) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$handle = ltrim($value, '@');
|
||||
|
||||
return match ($platform) {
|
||||
'linkedin' => 'https://linkedin.com/in/' . $handle,
|
||||
'twitter' => 'https://x.com/' . $handle,
|
||||
'instagram' => 'https://instagram.com/' . $handle,
|
||||
'facebook' => 'https://facebook.com/' . $handle,
|
||||
'tiktok' => 'https://tiktok.com/@' . $handle,
|
||||
'youtube' => 'https://youtube.com/@' . $handle,
|
||||
'snapchat' => 'https://snapchat.com/add/' . $handle,
|
||||
'whatsapp' => 'https://wa.me/' . preg_replace('/\D+/', '', $value),
|
||||
default => $value,
|
||||
};
|
||||
}
|
||||
|
||||
private function requireUrl(mixed $value, string $message): string
|
||||
{
|
||||
$url = trim((string) $value);
|
||||
if ($url === '' || ! filter_var($url, FILTER_VALIDATE_URL)) {
|
||||
throw new RuntimeException($message);
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
/** @return list<array{title: string, url: string}> */
|
||||
private function normalizeLinks(mixed $links): array
|
||||
{
|
||||
if (! is_array($links)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
foreach ($links as $link) {
|
||||
if (! is_array($link)) {
|
||||
continue;
|
||||
}
|
||||
$title = trim((string) ($link['title'] ?? ''));
|
||||
$url = trim((string) ($link['url'] ?? ''));
|
||||
if ($title === '' || $url === '' || ! filter_var($url, FILTER_VALIDATE_URL)) {
|
||||
continue;
|
||||
}
|
||||
$normalized[] = ['title' => $title, 'url' => $url];
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize menu/shop sections. Preserves existing image_path on items so that
|
||||
* the manager can inject freshly uploaded paths after validation.
|
||||
*
|
||||
* @return list<array{name: string, items: list<array{name: string, description: string, price: string, image_path: ?string}>}>
|
||||
*/
|
||||
private function normalizeMenuSections(mixed $sections): array
|
||||
{
|
||||
if (! is_array($sections)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
foreach ($sections as $section) {
|
||||
if (! is_array($section)) {
|
||||
continue;
|
||||
}
|
||||
$name = trim((string) ($section['name'] ?? ''));
|
||||
$items = [];
|
||||
foreach (($section['items'] ?? []) as $item) {
|
||||
if (! is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
$itemName = trim((string) ($item['name'] ?? ''));
|
||||
if ($itemName === '') {
|
||||
continue;
|
||||
}
|
||||
$items[] = [
|
||||
'name' => $itemName,
|
||||
'description' => trim((string) ($item['description'] ?? '')),
|
||||
'price' => trim((string) ($item['price'] ?? '')),
|
||||
'image_path' => ($item['image_path'] ?? null) ?: null,
|
||||
];
|
||||
}
|
||||
if ($name !== '' && $items !== []) {
|
||||
$normalized[] = ['name' => $name, 'items' => $items];
|
||||
}
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
public function validateImageUpload(?UploadedFile $file): void
|
||||
{
|
||||
if (! $file instanceof UploadedFile) {
|
||||
throw new RuntimeException('Upload at least one image.');
|
||||
}
|
||||
|
||||
$mime = $file->getMimeType() ?: '';
|
||||
if (! str_starts_with($mime, 'image/')) {
|
||||
throw new RuntimeException('Only image files are supported.');
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Qr;
|
||||
|
||||
class QrPdfExporter
|
||||
{
|
||||
public function fromPng(string $pngBinary, string $title): string
|
||||
{
|
||||
if (! extension_loaded('gd')) {
|
||||
throw new \RuntimeException('PDF export requires the GD extension.');
|
||||
}
|
||||
|
||||
$image = imagecreatefromstring($pngBinary);
|
||||
if ($image === false) {
|
||||
throw new \RuntimeException('Could not read QR image for PDF export.');
|
||||
}
|
||||
|
||||
$width = imagesx($image);
|
||||
$height = imagesy($image);
|
||||
$canvas = imagecreatetruecolor($width, $height);
|
||||
$white = imagecolorallocate($canvas, 255, 255, 255);
|
||||
imagefilledrectangle($canvas, 0, 0, $width, $height, $white);
|
||||
imagecopy($canvas, $image, 0, 0, 0, 0, $width, $height);
|
||||
imagedestroy($image);
|
||||
|
||||
ob_start();
|
||||
imagejpeg($canvas, null, 95);
|
||||
$jpeg = (string) ob_get_clean();
|
||||
imagedestroy($canvas);
|
||||
|
||||
return $this->buildPdf($jpeg, $title, $width, $height);
|
||||
}
|
||||
|
||||
private function buildPdf(string $jpeg, string $title, int $imgW, int $imgH): string
|
||||
{
|
||||
$pageW = 595.28;
|
||||
$pageH = 841.89;
|
||||
$margin = 48;
|
||||
$maxQr = min($pageW - ($margin * 2), 320);
|
||||
$scale = min($maxQr / max(1, $imgW), $maxQr / max(1, $imgH));
|
||||
$drawW = $imgW * $scale;
|
||||
$drawH = $imgH * $scale;
|
||||
$x = ($pageW - $drawW) / 2;
|
||||
$y = 120;
|
||||
$safeTitle = $this->pdfEscape(substr($title, 0, 80));
|
||||
|
||||
$objects = [];
|
||||
$objects[] = "1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n";
|
||||
$objects[] = "2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n";
|
||||
$objects[] = sprintf(
|
||||
"3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 %.2F %.2F] /Resources << /Font << /F1 4 0 R >> /XObject << /Im1 5 0 R >> >> /Contents 6 0 R >> endobj\n",
|
||||
$pageW,
|
||||
$pageH,
|
||||
);
|
||||
$objects[] = "4 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >> endobj\n";
|
||||
$objects[] = sprintf(
|
||||
"5 0 obj << /Type /XObject /Subtype /Image /Width %d /Height %d /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length %d >> stream\n%s\nendstream\nendobj\n",
|
||||
$imgW,
|
||||
$imgH,
|
||||
strlen($jpeg),
|
||||
$jpeg,
|
||||
);
|
||||
|
||||
$content = "BT /F1 16 Tf 48 " . ($pageH - 72) . " Td ({$safeTitle}) Tj ET\n";
|
||||
$content .= sprintf("q %.4F 0 0 %.4F %.2F %.2F cm /Im1 Do Q\n", $drawW, $drawH, $x, $pageH - $y - $drawH);
|
||||
$content .= "BT /F1 10 Tf 48 48 Td (Generated by Ladill QR Codes) Tj ET\n";
|
||||
$objects[] = sprintf("6 0 obj << /Length %d >> stream\n%s\nendstream\nendobj\n", strlen($content), $content);
|
||||
|
||||
$pdf = "%PDF-1.4\n";
|
||||
$offsets = [0];
|
||||
|
||||
foreach ($objects as $object) {
|
||||
$offsets[] = strlen($pdf);
|
||||
$pdf .= $object;
|
||||
}
|
||||
|
||||
$xrefPos = strlen($pdf);
|
||||
$pdf .= "xref\n0 " . count($offsets) . "\n";
|
||||
$pdf .= "0000000000 65535 f \n";
|
||||
for ($i = 1; $i < count($offsets); $i++) {
|
||||
$pdf .= sprintf("%010d 00000 n \n", $offsets[$i]);
|
||||
}
|
||||
$pdf .= "trailer << /Size " . count($offsets) . " /Root 1 0 R >>\n";
|
||||
$pdf .= "startxref\n{$xrefPos}\n%%EOF";
|
||||
|
||||
return $pdf;
|
||||
}
|
||||
|
||||
private function pdfEscape(string $text): string
|
||||
{
|
||||
return str_replace(['\\', '(', ')'], ['\\\\', '\\(', '\\)'], $text);
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Qr;
|
||||
|
||||
use App\Models\QrCode;
|
||||
use App\Models\QrScanEvent;
|
||||
use App\Models\QrWallet;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class QrScanRecorder
|
||||
{
|
||||
public function record(QrCode $qrCode, Request $request): QrScanEvent
|
||||
{
|
||||
$parsed = $this->parseUserAgent((string) $request->userAgent());
|
||||
$ipHash = $this->hashIp((string) $request->ip());
|
||||
$windowHours = (int) config('qr.scan_unique_window_hours', 24);
|
||||
$isUnique = ! QrScanEvent::query()
|
||||
->where('qr_code_id', $qrCode->id)
|
||||
->where('ip_hash', $ipHash)
|
||||
->where('scanned_at', '>=', now()->subHours($windowHours))
|
||||
->exists();
|
||||
|
||||
$event = QrScanEvent::create([
|
||||
'qr_code_id' => $qrCode->id,
|
||||
'scanned_at' => now(),
|
||||
'ip_hash' => $ipHash,
|
||||
'user_agent' => Str::limit((string) $request->userAgent(), 500, ''),
|
||||
'device_type' => $parsed['device_type'],
|
||||
'browser' => $parsed['browser'],
|
||||
'os' => $parsed['os'],
|
||||
'referrer' => Str::limit((string) $request->headers->get('referer'), 255, ''),
|
||||
'is_unique' => $isUnique,
|
||||
]);
|
||||
|
||||
$qrCode->increment('scans_total');
|
||||
if ($isUnique) {
|
||||
$qrCode->increment('unique_scans_total');
|
||||
}
|
||||
$qrCode->update(['last_scanned_at' => now()]);
|
||||
|
||||
QrWallet::query()
|
||||
->where('user_id', $qrCode->user_id)
|
||||
->increment('scans_total');
|
||||
|
||||
return $event;
|
||||
}
|
||||
|
||||
private function hashIp(string $ip): string
|
||||
{
|
||||
return hash('sha256', $ip . '|' . (string) config('app.key'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{device_type: string, browser: string, os: string}
|
||||
*/
|
||||
private function parseUserAgent(string $ua): array
|
||||
{
|
||||
$uaLower = strtolower($ua);
|
||||
|
||||
$device = str_contains($uaLower, 'mobile') || str_contains($uaLower, 'android') || str_contains($uaLower, 'iphone')
|
||||
? 'mobile'
|
||||
: (str_contains($uaLower, 'tablet') || str_contains($uaLower, 'ipad') ? 'tablet' : 'desktop');
|
||||
|
||||
$browser = match (true) {
|
||||
str_contains($uaLower, 'edg/') => 'Edge',
|
||||
str_contains($uaLower, 'chrome/') && ! str_contains($uaLower, 'edg/') => 'Chrome',
|
||||
str_contains($uaLower, 'safari/') && ! str_contains($uaLower, 'chrome/') => 'Safari',
|
||||
str_contains($uaLower, 'firefox/') => 'Firefox',
|
||||
default => 'Other',
|
||||
};
|
||||
|
||||
$os = match (true) {
|
||||
str_contains($uaLower, 'iphone') || str_contains($uaLower, 'ipad') => 'iOS',
|
||||
str_contains($uaLower, 'android') => 'Android',
|
||||
str_contains($uaLower, 'windows') => 'Windows',
|
||||
str_contains($uaLower, 'mac os') || str_contains($uaLower, 'macintosh') => 'macOS',
|
||||
str_contains($uaLower, 'linux') => 'Linux',
|
||||
default => 'Other',
|
||||
};
|
||||
|
||||
return [
|
||||
'device_type' => $device,
|
||||
'browser' => $browser,
|
||||
'os' => $os,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Qr;
|
||||
|
||||
use App\Models\QrCode;
|
||||
use App\Models\QrTransaction;
|
||||
use App\Models\QrWallet;
|
||||
use App\Models\User;
|
||||
use App\Services\Billing\BillingClient;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* QR Plus billing — consumes the platform UserWallet via the Billing API.
|
||||
*/
|
||||
class QrWalletBillingService
|
||||
{
|
||||
public function __construct(
|
||||
private BillingClient $billing,
|
||||
) {}
|
||||
|
||||
public function balanceCedis(User $user): float
|
||||
{
|
||||
return $this->billing->balanceMinor($user->public_id) / 100;
|
||||
}
|
||||
|
||||
public function canCreate(User $user): bool
|
||||
{
|
||||
$priceMinor = (int) round(QrWallet::pricePerQr() * 100);
|
||||
|
||||
return $this->billing->canAfford($user->public_id, $priceMinor);
|
||||
}
|
||||
|
||||
public function debitForQrCreation(QrWallet $wallet, QrCode $qrCode): QrTransaction
|
||||
{
|
||||
$price = QrWallet::pricePerQr();
|
||||
$priceMinor = (int) round($price * 100);
|
||||
$user = $wallet->user;
|
||||
$reference = 'QR-DEBIT-'.strtoupper(Str::random(12));
|
||||
|
||||
return DB::transaction(function () use ($wallet, $user, $qrCode, $price, $priceMinor, $reference) {
|
||||
$ok = $this->billing->debit(
|
||||
$user->public_id,
|
||||
$priceMinor,
|
||||
'qr',
|
||||
'qr_create',
|
||||
$reference,
|
||||
$qrCode->id,
|
||||
sprintf('Created QR code: %s', $qrCode->label),
|
||||
);
|
||||
|
||||
if (! $ok) {
|
||||
throw new RuntimeException('Insufficient wallet balance.');
|
||||
}
|
||||
|
||||
$wallet->increment('qr_codes_total');
|
||||
$balanceAfter = $this->billing->balanceMinor($user->public_id) / 100;
|
||||
|
||||
return QrTransaction::create([
|
||||
'user_id' => $wallet->user_id,
|
||||
'qr_wallet_id' => $wallet->id,
|
||||
'qr_code_id' => $qrCode->id,
|
||||
'type' => QrTransaction::TYPE_DEBIT,
|
||||
'amount_ghs' => round($price, 4),
|
||||
'balance_after_ghs' => $balanceAfter,
|
||||
'reference' => $reference,
|
||||
'status' => 'completed',
|
||||
'description' => sprintf('Created QR code: %s', $qrCode->label),
|
||||
'metadata' => ['qr_code_id' => $qrCode->id],
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Events;
|
||||
|
||||
use App\Models\QrCode;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Generates ZPL II label code for Zebra-style badge/label printers.
|
||||
* One ^XA…^XZ label block per attendee, sized to the chosen badge size at 203 dpi.
|
||||
*/
|
||||
class EventBadgeZpl
|
||||
{
|
||||
/** @param Collection<int, \App\Models\QrEventRegistration> $registrations */
|
||||
public static function forRegistrations(QrCode $qrCode, Collection $registrations): string
|
||||
{
|
||||
$content = $qrCode->content();
|
||||
$eventName = self::sanitize($content['name'] ?? $qrCode->label);
|
||||
$size = $content['badge_size'] ?? '4x3';
|
||||
|
||||
// Label dimensions in dots @ 203 dpi.
|
||||
[$widthDots, $heightDots] = match ($size) {
|
||||
'4x6' => [812, 1218],
|
||||
'cr80' => [685, 431],
|
||||
default => [812, 609], // 4x3
|
||||
};
|
||||
|
||||
$labels = [];
|
||||
foreach ($registrations as $reg) {
|
||||
$name = self::sanitize($reg->attendee_name);
|
||||
$tier = self::sanitize($reg->tier_name);
|
||||
$extra = collect($reg->badge_fields ?? [])
|
||||
->map(fn ($v, $k) => self::sanitize($k . ': ' . $v))
|
||||
->implode(' | ');
|
||||
|
||||
$zpl = "^XA\n";
|
||||
$zpl .= "^PW{$widthDots}\n";
|
||||
$zpl .= "^LL{$heightDots}\n";
|
||||
$zpl .= "^CI28\n"; // UTF-8
|
||||
// Event name (top)
|
||||
$zpl .= "^FO40,40^A0N,40,40^FB" . ($widthDots - 80) . ",1,0,C^FD{$eventName}^FS\n";
|
||||
// Attendee name (large, centered)
|
||||
$zpl .= "^FO40,150^A0N,80,80^FB" . ($widthDots - 80) . ",2,0,C^FD{$name}^FS\n";
|
||||
// Tier
|
||||
$zpl .= "^FO40,320^A0N,40,40^FB" . ($widthDots - 80) . ",1,0,C^FD{$tier}^FS\n";
|
||||
// Extra fields
|
||||
if ($extra !== '') {
|
||||
$zpl .= "^FO40,375^A0N,28,28^FB" . ($widthDots - 80) . ",2,0,C^FD{$extra}^FS\n";
|
||||
}
|
||||
// QR of the badge code (bottom)
|
||||
$qrX = (int) (($widthDots / 2) - 70);
|
||||
$zpl .= "^FO{$qrX}," . ($heightDots - 200) . "^BQN,2,5^FDLA,{$reg->badge_code}^FS\n";
|
||||
// Badge code text
|
||||
$zpl .= "^FO40," . ($heightDots - 60) . "^A0N,34,34^FB" . ($widthDots - 80) . ",1,0,C^FD{$reg->badge_code}^FS\n";
|
||||
$zpl .= "^XZ\n";
|
||||
|
||||
$labels[] = $zpl;
|
||||
}
|
||||
|
||||
return implode("\n", $labels);
|
||||
}
|
||||
|
||||
private static function sanitize(string $value): string
|
||||
{
|
||||
// Escape ZPL control chars (^ and ~) and collapse whitespace.
|
||||
$value = str_replace(['^', '~'], [' ', ' '], $value);
|
||||
|
||||
return trim(preg_replace('/\s+/', ' ', $value) ?? '');
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Qr;
|
||||
|
||||
use App\Models\QrCode;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Generates ZPL II label code for Zebra-style badge/label printers.
|
||||
* One ^XA…^XZ label block per attendee, sized to the chosen badge size at 203 dpi.
|
||||
*/
|
||||
class EventBadgeZpl
|
||||
{
|
||||
/** @param Collection<int, \App\Models\QrEventRegistration> $registrations */
|
||||
public static function forRegistrations(QrCode $qrCode, Collection $registrations): string
|
||||
{
|
||||
$content = $qrCode->content();
|
||||
$eventName = self::sanitize($content['name'] ?? $qrCode->label);
|
||||
$size = $content['badge_size'] ?? '4x3';
|
||||
|
||||
// Label dimensions in dots @ 203 dpi.
|
||||
[$widthDots, $heightDots] = match ($size) {
|
||||
'4x6' => [812, 1218],
|
||||
'cr80' => [685, 431],
|
||||
default => [812, 609], // 4x3
|
||||
};
|
||||
|
||||
$labels = [];
|
||||
foreach ($registrations as $reg) {
|
||||
$name = self::sanitize($reg->attendee_name);
|
||||
$tier = self::sanitize($reg->tier_name);
|
||||
$extra = collect($reg->badge_fields ?? [])
|
||||
->map(fn ($v, $k) => self::sanitize($k . ': ' . $v))
|
||||
->implode(' | ');
|
||||
|
||||
$zpl = "^XA\n";
|
||||
$zpl .= "^PW{$widthDots}\n";
|
||||
$zpl .= "^LL{$heightDots}\n";
|
||||
$zpl .= "^CI28\n"; // UTF-8
|
||||
// Event name (top)
|
||||
$zpl .= "^FO40,40^A0N,40,40^FB" . ($widthDots - 80) . ",1,0,C^FD{$eventName}^FS\n";
|
||||
// Attendee name (large, centered)
|
||||
$zpl .= "^FO40,150^A0N,80,80^FB" . ($widthDots - 80) . ",2,0,C^FD{$name}^FS\n";
|
||||
// Tier
|
||||
$zpl .= "^FO40,320^A0N,40,40^FB" . ($widthDots - 80) . ",1,0,C^FD{$tier}^FS\n";
|
||||
// Extra fields
|
||||
if ($extra !== '') {
|
||||
$zpl .= "^FO40,375^A0N,28,28^FB" . ($widthDots - 80) . ",2,0,C^FD{$extra}^FS\n";
|
||||
}
|
||||
// QR of the badge code (bottom)
|
||||
$qrX = (int) (($widthDots / 2) - 70);
|
||||
$zpl .= "^FO{$qrX}," . ($heightDots - 200) . "^BQN,2,5^FDLA,{$reg->badge_code}^FS\n";
|
||||
// Badge code text
|
||||
$zpl .= "^FO40," . ($heightDots - 60) . "^A0N,34,34^FB" . ($widthDots - 80) . ",1,0,C^FD{$reg->badge_code}^FS\n";
|
||||
$zpl .= "^XZ\n";
|
||||
|
||||
$labels[] = $zpl;
|
||||
}
|
||||
|
||||
return implode("\n", $labels);
|
||||
}
|
||||
|
||||
private static function sanitize(string $value): string
|
||||
{
|
||||
// Escape ZPL control chars (^ and ~) and collapse whitespace.
|
||||
$value = str_replace(['^', '~'], [' ', ' '], $value);
|
||||
|
||||
return trim(preg_replace('/\s+/', ' ', $value) ?? '');
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Qr;
|
||||
|
||||
class QrCornerStyleCatalog
|
||||
{
|
||||
/** @return array<string, array{label: string}> */
|
||||
public static function outerStyles(): array
|
||||
{
|
||||
return [
|
||||
'square' => ['label' => 'Square eye'],
|
||||
'rounded' => ['label' => 'Rounded'],
|
||||
'circle' => ['label' => 'Circular'],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, array{label: string}> */
|
||||
public static function innerStyles(): array
|
||||
{
|
||||
return [
|
||||
'square' => ['label' => 'Square'],
|
||||
'rounded' => ['label' => 'Rounded'],
|
||||
'dot' => ['label' => 'Dot'],
|
||||
];
|
||||
}
|
||||
|
||||
public static function isValidOuter(string $style): bool
|
||||
{
|
||||
return isset(self::outerStyles()[$style]);
|
||||
}
|
||||
|
||||
public static function isValidInner(string $style): bool
|
||||
{
|
||||
return isset(self::innerStyles()[$style]);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Qr;
|
||||
|
||||
class QrCoverImageSpec
|
||||
{
|
||||
/** Wide hero banners (business, church, event, itinerary, menu, shop). */
|
||||
public const BANNER = '1920×1080 px (16:9 landscape)';
|
||||
|
||||
/** Book product cover on the landing page. */
|
||||
public const BOOK = '800×1067 px (3:4 portrait)';
|
||||
|
||||
public static function label(string $variant = 'banner'): string
|
||||
{
|
||||
return match ($variant) {
|
||||
'book' => self::BOOK,
|
||||
default => self::BANNER,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Qr;
|
||||
|
||||
use Carbon\Carbon;
|
||||
|
||||
class QrDateFormatter
|
||||
{
|
||||
public static function forInput(?string $value): string
|
||||
{
|
||||
if ($value === null || trim($value) === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
return Carbon::parse($value)->format('Y-m-d');
|
||||
} catch (\Throwable) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
public static function forDisplay(?string $value): string
|
||||
{
|
||||
if ($value === null || trim($value) === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) {
|
||||
try {
|
||||
return Carbon::createFromFormat('Y-m-d', $value)->format('D, j M Y');
|
||||
} catch (\Throwable) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
public static function normalize(?string $value, int $maxLength = 60): string
|
||||
{
|
||||
if ($value === null || trim($value) === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
try {
|
||||
return Carbon::parse($value)->format('Y-m-d');
|
||||
} catch (\Throwable) {
|
||||
return mb_substr($value, 0, $maxLength);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Qr;
|
||||
|
||||
class QrFrameStyleCatalog
|
||||
{
|
||||
/** @return array<string, array{label: string, description: string, border_px: int, mode: string, cta?: string, visible?: bool}> */
|
||||
public static function all(): array
|
||||
{
|
||||
return [
|
||||
'none' => [
|
||||
'label' => 'None',
|
||||
'description' => 'Code only',
|
||||
'border_px' => 0,
|
||||
'mode' => 'none',
|
||||
],
|
||||
'thin' => [
|
||||
'label' => 'Border',
|
||||
'description' => 'Simple white sticker edge',
|
||||
'border_px' => 8,
|
||||
'mode' => 'border',
|
||||
],
|
||||
'bold' => [
|
||||
'label' => 'Wide border',
|
||||
'description' => 'Legacy wide border',
|
||||
'border_px' => 16,
|
||||
'mode' => 'border',
|
||||
'visible' => false,
|
||||
],
|
||||
'scan_me' => [
|
||||
'label' => 'Scan me',
|
||||
'description' => 'CTA sticker below code',
|
||||
'border_px' => 14,
|
||||
'mode' => 'label',
|
||||
'cta' => 'SCAN ME',
|
||||
],
|
||||
'tap_to_scan' => [
|
||||
'label' => 'Tap to scan',
|
||||
'description' => 'Rounded CTA sticker',
|
||||
'border_px' => 14,
|
||||
'mode' => 'pill',
|
||||
'cta' => 'TAP TO SCAN',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, array{label: string, description: string, border_px: int, mode: string, cta?: string, visible?: bool}> */
|
||||
public static function visible(): array
|
||||
{
|
||||
return array_filter(self::all(), fn (array $style): bool => ($style['visible'] ?? true) === true);
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public static function keys(): array
|
||||
{
|
||||
return array_keys(self::all());
|
||||
}
|
||||
|
||||
public static function isValid(string $style): bool
|
||||
{
|
||||
return isset(self::all()[$style]);
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Qr;
|
||||
|
||||
class QrModuleStyleCatalog
|
||||
{
|
||||
/** @return array<string, array{label: string, description: string, circular: bool, circle_radius: float, connect_paths: bool, scan_risk: string, visible?: bool}> */
|
||||
public static function all(): array
|
||||
{
|
||||
return [
|
||||
'square' => [
|
||||
'label' => 'Standard',
|
||||
'description' => 'Traditional square QR modules',
|
||||
'circular' => false,
|
||||
'circle_radius' => 0.4,
|
||||
'connect_paths' => false,
|
||||
'scan_risk' => 'low',
|
||||
],
|
||||
'soft' => [
|
||||
'label' => 'Rounded',
|
||||
'description' => 'Soft rounded modules',
|
||||
'circular' => true,
|
||||
'circle_radius' => 0.36,
|
||||
'connect_paths' => false,
|
||||
'scan_risk' => 'medium',
|
||||
'visible' => false,
|
||||
],
|
||||
'dots' => [
|
||||
'label' => 'Dots',
|
||||
'description' => 'Round dot modules',
|
||||
'circular' => true,
|
||||
'circle_radius' => 0.45,
|
||||
'connect_paths' => false,
|
||||
'scan_risk' => 'medium',
|
||||
],
|
||||
'bubble' => [
|
||||
'label' => 'Large dots',
|
||||
'description' => 'Bolder round dot modules',
|
||||
'circular' => true,
|
||||
'circle_radius' => 0.52,
|
||||
'connect_paths' => false,
|
||||
'scan_risk' => 'high',
|
||||
'visible' => false,
|
||||
],
|
||||
'fluid' => [
|
||||
'label' => 'Fluid',
|
||||
'description' => 'Legacy decorative style',
|
||||
'circular' => true,
|
||||
'circle_radius' => 0.38,
|
||||
'connect_paths' => true,
|
||||
'scan_risk' => 'high',
|
||||
'visible' => false,
|
||||
],
|
||||
'bold' => [
|
||||
'label' => 'Bold',
|
||||
'description' => 'Legacy thick square modules',
|
||||
'circular' => false,
|
||||
'circle_radius' => 0.4,
|
||||
'connect_paths' => false,
|
||||
'scan_risk' => 'low',
|
||||
'visible' => false,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, array{label: string, description: string, circular: bool, circle_radius: float, connect_paths: bool, scan_risk: string, visible?: bool}> */
|
||||
public static function visible(): array
|
||||
{
|
||||
return array_filter(self::all(), fn (array $style): bool => ($style['visible'] ?? true) === true);
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public static function keys(): array
|
||||
{
|
||||
return array_keys(self::all());
|
||||
}
|
||||
|
||||
public static function isValid(string $style): bool
|
||||
{
|
||||
return isset(self::all()[$style]);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public static function optionsFor(string $style): array
|
||||
{
|
||||
return self::all()[$style] ?? self::all()['square'];
|
||||
}
|
||||
|
||||
public static function isDecorative(string $style): bool
|
||||
{
|
||||
return in_array(self::optionsFor($style)['scan_risk'], ['medium', 'high'], true);
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Qr;
|
||||
|
||||
class QrScanReliability
|
||||
{
|
||||
/** @param array<string, mixed> $style */
|
||||
public static function contrastRatio(array $style): float
|
||||
{
|
||||
$fg = self::relativeLuminance((string) ($style['foreground'] ?? '#000000'));
|
||||
$bg = self::relativeLuminance((string) ($style['background'] ?? '#ffffff'));
|
||||
|
||||
$lighter = max($fg, $bg);
|
||||
$darker = min($fg, $bg);
|
||||
|
||||
return ($lighter + 0.05) / ($darker + 0.05);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $style
|
||||
* @return array{level: string, messages: list<string>}
|
||||
*/
|
||||
public static function assess(array $style): array
|
||||
{
|
||||
$style = QrStyleDefaults::merge($style);
|
||||
$messages = [];
|
||||
$level = 'good';
|
||||
|
||||
$contrast = self::contrastRatio($style);
|
||||
$isClassic = self::isClassicBlackOnWhite($style);
|
||||
$moduleStyle = (string) ($style['module_style'] ?? 'square');
|
||||
$moduleMeta = QrModuleStyleCatalog::optionsFor($moduleStyle);
|
||||
|
||||
if ($moduleMeta['scan_risk'] === 'medium') {
|
||||
$messages[] = 'This module style may not scan on every phone camera. Square is the safest choice.';
|
||||
$level = 'fair';
|
||||
}
|
||||
|
||||
if ($moduleMeta['scan_risk'] === 'high') {
|
||||
$messages[] = 'Decorative styles like ' . $moduleMeta['label'] . ' often fail on Samsung Camera. Use square for print and signage.';
|
||||
$level = 'poor';
|
||||
}
|
||||
|
||||
if (! $isClassic) {
|
||||
$messages[] = 'Colored QR codes often fail on Samsung Camera and other basic scanners. Black on white works everywhere.';
|
||||
$level = $level === 'good' ? 'fair' : 'poor';
|
||||
}
|
||||
|
||||
if ($contrast < 4.5) {
|
||||
$messages[] = sprintf(
|
||||
'Contrast is low (%.1f:1). Aim for at least 4.5:1 — dark foreground, white background.',
|
||||
$contrast,
|
||||
);
|
||||
$level = 'poor';
|
||||
}
|
||||
|
||||
if ($moduleMeta['scan_risk'] !== 'low' && ! $isClassic) {
|
||||
$level = 'poor';
|
||||
}
|
||||
|
||||
if ($messages === []) {
|
||||
$messages[] = 'Black square modules on white give the best compatibility with Samsung, iPhone, and printed codes.';
|
||||
}
|
||||
|
||||
return ['level' => $level, 'messages' => $messages];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $style */
|
||||
public static function isClassicBlackOnWhite(array $style): bool
|
||||
{
|
||||
$fg = strtolower(ltrim((string) ($style['foreground'] ?? ''), '#'));
|
||||
$bg = strtolower(ltrim((string) ($style['background'] ?? ''), '#'));
|
||||
|
||||
$fg = strlen($fg) === 3 ? $fg[0] . $fg[0] . $fg[1] . $fg[1] . $fg[2] . $fg[2] : $fg;
|
||||
$bg = strlen($bg) === 3 ? $bg[0] . $bg[0] . $bg[1] . $bg[1] . $bg[2] . $bg[2] : $bg;
|
||||
|
||||
return in_array($fg, ['000000', '0f172a', '111111', '1a1a1a'], true)
|
||||
&& in_array($bg, ['ffffff', 'fff'], true);
|
||||
}
|
||||
|
||||
private static function relativeLuminance(string $hex): float
|
||||
{
|
||||
$hex = ltrim(trim($hex), '#');
|
||||
if (strlen($hex) === 3) {
|
||||
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
|
||||
}
|
||||
|
||||
if (strlen($hex) !== 6 || ! ctype_xdigit($hex)) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$channels = [];
|
||||
foreach ([0, 2, 4] as $offset) {
|
||||
$value = hexdec(substr($hex, $offset, 2)) / 255;
|
||||
$channels[] = $value <= 0.03928
|
||||
? $value / 12.92
|
||||
: (($value + 0.055) / 1.055) ** 2.4;
|
||||
}
|
||||
|
||||
return (0.2126 * $channels[0]) + (0.7152 * $channels[1]) + (0.0722 * $channels[2]);
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Qr;
|
||||
|
||||
class QrStyleDefaults
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public static function defaults(): array
|
||||
{
|
||||
return [
|
||||
'foreground' => '#000000',
|
||||
'background' => '#ffffff',
|
||||
'error_correction' => 'M',
|
||||
'margin' => 4,
|
||||
'module_style' => 'square',
|
||||
'finder_outer' => 'square',
|
||||
'finder_inner' => 'square',
|
||||
'frame_style' => 'none',
|
||||
'frame_text' => '',
|
||||
'frame_color' => '#000000',
|
||||
'scale' => 8,
|
||||
'logo_path' => null,
|
||||
'gradient_type' => 'none',
|
||||
'gradient_color1' => '#000000',
|
||||
'gradient_color2' => '#7c3aed',
|
||||
'gradient_rotation' => 45,
|
||||
'logo_size' => 0.3,
|
||||
'logo_margin' => 5,
|
||||
'logo_white_bg' => false,
|
||||
'logo_shape' => 'none',
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed>|null $style */
|
||||
public static function merge(?array $style): array
|
||||
{
|
||||
$merged = array_merge(self::defaults(), $style ?? []);
|
||||
|
||||
if (! in_array($merged['error_correction'], ['L', 'M', 'Q', 'H'], true)) {
|
||||
$merged['error_correction'] = 'M';
|
||||
}
|
||||
|
||||
if (! in_array($merged['module_style'], QrModuleStyleCatalog::keys(), true)) {
|
||||
$merged['module_style'] = 'square';
|
||||
}
|
||||
|
||||
if (! QrCornerStyleCatalog::isValidOuter((string) ($merged['finder_outer'] ?? 'square'))) {
|
||||
$merged['finder_outer'] = 'square';
|
||||
}
|
||||
|
||||
if (! QrCornerStyleCatalog::isValidInner((string) ($merged['finder_inner'] ?? 'square'))) {
|
||||
$merged['finder_inner'] = 'square';
|
||||
}
|
||||
|
||||
if (! QrFrameStyleCatalog::isValid((string) ($merged['frame_style'] ?? 'none'))) {
|
||||
$merged['frame_style'] = 'none';
|
||||
}
|
||||
|
||||
$merged['frame_text'] = substr(trim((string) ($merged['frame_text'] ?? '')), 0, 100);
|
||||
$frameColor = trim((string) ($merged['frame_color'] ?? '#000000'));
|
||||
$merged['frame_color'] = preg_match('/^#[0-9a-fA-F]{6}$/', $frameColor) ? $frameColor : '#000000';
|
||||
|
||||
if (! in_array($merged['gradient_type'], ['none', 'linear', 'radial'], true)) {
|
||||
$merged['gradient_type'] = 'none';
|
||||
}
|
||||
|
||||
$merged['logo_size'] = max(0.1, min(0.4, (float) ($merged['logo_size'] ?? 0.3)));
|
||||
$merged['logo_margin'] = max(0, min(15, (int) ($merged['logo_margin'] ?? 5)));
|
||||
if (! in_array($merged['logo_shape'], ['none', 'rounded', 'circle'], true)) {
|
||||
$merged['logo_shape'] = 'none';
|
||||
}
|
||||
$merged['logo_white_bg'] = (bool) ($merged['logo_white_bg'] ?? false);
|
||||
|
||||
if ($merged['module_style'] === 'bold') {
|
||||
$merged['scale'] = min(16, (int) $merged['scale'] + 2);
|
||||
}
|
||||
|
||||
$merged['margin'] = max(0, min(10, (int) $merged['margin']));
|
||||
$merged['scale'] = max(4, min(16, (int) $merged['scale']));
|
||||
|
||||
return $merged;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed>|null $style */
|
||||
public static function mergeForRender(?array $style): array
|
||||
{
|
||||
return QrStyleNormalizer::normalize(self::merge($style));
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $style */
|
||||
public static function recommendedEcc(array $style): string
|
||||
{
|
||||
$ecc = (string) ($style['error_correction'] ?? 'M');
|
||||
$order = ['L' => 0, 'M' => 1, 'Q' => 2, 'H' => 3];
|
||||
$minimum = 'M';
|
||||
$moduleStyle = (string) ($style['module_style'] ?? 'square');
|
||||
|
||||
if (in_array($moduleStyle, ['bubble', 'fluid'], true)) {
|
||||
$minimum = 'H';
|
||||
} elseif (QrModuleStyleCatalog::isDecorative($moduleStyle)) {
|
||||
$minimum = 'Q';
|
||||
}
|
||||
|
||||
if (! empty($style['logo_path'])) {
|
||||
$minimum = 'H';
|
||||
}
|
||||
|
||||
if (QrScanReliability::contrastRatio($style) < 4.5) {
|
||||
$minimum = 'H';
|
||||
}
|
||||
|
||||
return ($order[$minimum] ?? 1) > ($order[$ecc] ?? 1) ? $minimum : $ecc;
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Qr;
|
||||
|
||||
class QrStyleNormalizer
|
||||
{
|
||||
/**
|
||||
* Silently tune styles so more phone cameras (including Samsung) can read the code.
|
||||
*
|
||||
* @param array<string, mixed> $style
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function normalize(array $style): array
|
||||
{
|
||||
$style = self::ensureContrast($style);
|
||||
$style = self::ensureQuietZone($style);
|
||||
$style = self::ensureRenderScale($style);
|
||||
|
||||
$style['error_correction'] = QrStyleDefaults::recommendedEcc($style);
|
||||
|
||||
return $style;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $style */
|
||||
private static function ensureContrast(array $style): array
|
||||
{
|
||||
$target = 4.5;
|
||||
$attempts = 0;
|
||||
|
||||
while (QrScanReliability::contrastRatio($style) < $target && $attempts < 16) {
|
||||
$fg = self::hexToRgb((string) $style['foreground']);
|
||||
$bg = self::hexToRgb((string) $style['background']);
|
||||
|
||||
if (self::relativeLuminance($fg) > self::relativeLuminance($bg)) {
|
||||
$style['foreground'] = self::rgbToHex(self::darkenToward($fg, 0.18));
|
||||
} else {
|
||||
$style['foreground'] = self::rgbToHex(self::darkenToward($fg, 0.15));
|
||||
$style['background'] = self::rgbToHex(self::lightenToward($bg, 0.15));
|
||||
}
|
||||
|
||||
$attempts++;
|
||||
}
|
||||
|
||||
if (QrScanReliability::contrastRatio($style) < $target) {
|
||||
$style['foreground'] = '#000000';
|
||||
$style['background'] = '#ffffff';
|
||||
}
|
||||
|
||||
return $style;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $style */
|
||||
private static function ensureQuietZone(array $style): array
|
||||
{
|
||||
$margin = (int) ($style['margin'] ?? 4);
|
||||
$moduleStyle = (string) ($style['module_style'] ?? 'square');
|
||||
|
||||
if (QrModuleStyleCatalog::isDecorative($moduleStyle)) {
|
||||
$margin = max($margin, 5);
|
||||
}
|
||||
|
||||
$style['margin'] = max(4, min(10, $margin));
|
||||
|
||||
return $style;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $style */
|
||||
private static function ensureRenderScale(array $style): array
|
||||
{
|
||||
$scale = (int) ($style['scale'] ?? 8);
|
||||
$module = QrModuleStyleCatalog::optionsFor((string) ($style['module_style'] ?? 'square'));
|
||||
|
||||
if ($module['circular'] ?? false) {
|
||||
$scale = max($scale, 10);
|
||||
}
|
||||
|
||||
if (! empty($style['logo_path'])) {
|
||||
$scale = max($scale, 10);
|
||||
}
|
||||
|
||||
$style['scale'] = max(6, min(16, $scale));
|
||||
|
||||
return $style;
|
||||
}
|
||||
|
||||
/** @return array{0: int, 1: int, 2: int} */
|
||||
private static function hexToRgb(string $hex): array
|
||||
{
|
||||
$hex = ltrim(trim($hex), '#');
|
||||
if (strlen($hex) === 3) {
|
||||
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
|
||||
}
|
||||
|
||||
if (strlen($hex) !== 6 || ! ctype_xdigit($hex)) {
|
||||
return [0, 0, 0];
|
||||
}
|
||||
|
||||
return [hexdec(substr($hex, 0, 2)), hexdec(substr($hex, 2, 2)), hexdec(substr($hex, 4, 2))];
|
||||
}
|
||||
|
||||
/** @param array{0: int, 1: int, 2: int} $rgb */
|
||||
private static function rgbToHex(array $rgb): string
|
||||
{
|
||||
return sprintf('#%02x%02x%02x', $rgb[0], $rgb[1], $rgb[2]);
|
||||
}
|
||||
|
||||
/** @param array{0: int, 1: int, 2: int} $rgb */
|
||||
private static function darkenToward(array $rgb, float $amount): array
|
||||
{
|
||||
return [
|
||||
(int) max(0, $rgb[0] * (1 - $amount)),
|
||||
(int) max(0, $rgb[1] * (1 - $amount)),
|
||||
(int) max(0, $rgb[2] * (1 - $amount)),
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array{0: int, 1: int, 2: int} $rgb */
|
||||
private static function lightenToward(array $rgb, float $amount): array
|
||||
{
|
||||
return [
|
||||
(int) min(255, $rgb[0] + ((255 - $rgb[0]) * $amount)),
|
||||
(int) min(255, $rgb[1] + ((255 - $rgb[1]) * $amount)),
|
||||
(int) min(255, $rgb[2] + ((255 - $rgb[2]) * $amount)),
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array{0: int, 1: int, 2: int} $rgb */
|
||||
private static function relativeLuminance(array $rgb): float
|
||||
{
|
||||
$channels = [];
|
||||
foreach ($rgb as $value) {
|
||||
$v = $value / 255;
|
||||
$channels[] = $v <= 0.03928 ? $v / 12.92 : (($v + 0.055) / 1.055) ** 2.4;
|
||||
}
|
||||
|
||||
return (0.2126 * $channels[0]) + (0.7152 * $channels[1]) + (0.0722 * $channels[2]);
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Qr;
|
||||
|
||||
use App\Models\QrCode;
|
||||
|
||||
/**
|
||||
* Ladill Mini — static payment QR codes only.
|
||||
*/
|
||||
class QrTypeCatalog
|
||||
{
|
||||
/** @return list<string> */
|
||||
public static function paymentTypes(): array
|
||||
{
|
||||
return [QrCode::TYPE_PAYMENT];
|
||||
}
|
||||
|
||||
/** @return array<string, array{label: string, description: string, category: string, icon: string}> */
|
||||
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<string> */
|
||||
public static function keys(): array
|
||||
{
|
||||
return array_keys(self::all());
|
||||
}
|
||||
|
||||
public static function label(string $type): string
|
||||
{
|
||||
return self::all()[$type]['label'] ?? ucfirst(str_replace('_', ' ', $type));
|
||||
}
|
||||
|
||||
public static function isValid(string $type): bool
|
||||
{
|
||||
return isset(self::all()[$type]);
|
||||
}
|
||||
|
||||
public static function iconUrl(string $icon): string
|
||||
{
|
||||
$path = public_path('images/qr-icons/'.$icon);
|
||||
$url = '/images/qr-icons/'.$icon;
|
||||
|
||||
if (is_file($path)) {
|
||||
return $url.'?v='.filemtime($path);
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Qr;
|
||||
|
||||
class QrWifiPayload
|
||||
{
|
||||
/**
|
||||
* Build a WiFi QR payload (ZXing MECARD format) for one-tap network join on scan.
|
||||
*
|
||||
* @param array<string, mixed> $content
|
||||
*/
|
||||
public static function encode(array $content): string
|
||||
{
|
||||
$encryption = strtoupper(trim((string) ($content['encryption'] ?? 'WPA')));
|
||||
$auth = $encryption === 'NOPASS' ? 'nopass' : $encryption;
|
||||
if (! in_array($auth, ['WPA', 'WEP', 'nopass'], true)) {
|
||||
$auth = 'WPA';
|
||||
}
|
||||
|
||||
$ssid = self::escape(trim((string) ($content['ssid'] ?? '')));
|
||||
$payload = 'WIFI:T:' . $auth . ';S:' . $ssid;
|
||||
|
||||
if ($auth !== 'nopass') {
|
||||
$password = trim((string) ($content['password'] ?? ''));
|
||||
if ($password !== '') {
|
||||
$payload .= ';P:' . self::escape($password);
|
||||
}
|
||||
}
|
||||
|
||||
if (filter_var($content['hidden'] ?? false, FILTER_VALIDATE_BOOL)) {
|
||||
$payload .= ';H:true';
|
||||
}
|
||||
|
||||
return $payload . ';;';
|
||||
}
|
||||
|
||||
private static function escape(string $value): string
|
||||
{
|
||||
return str_replace(
|
||||
['\\', ';', ',', '"', ':'],
|
||||
['\\\\', '\\;', '\\,', '\\"', '\\:'],
|
||||
$value
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('qr_wallets', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -1,22 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('qr_codes', function (Blueprint $table) {
|
||||
$table->string('short_code', 32)->change();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('qr_codes', function (Blueprint $table) {
|
||||
$table->string('short_code', 16)->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,27 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('qr_settings', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -1,39 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('mini_payments', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -1,22 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('mini_payments', function (Blueprint $table) {
|
||||
$table->unsignedBigInteger('pay_order_id')->nullable()->after('id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('mini_payments', function (Blueprint $table) {
|
||||
$table->dropColumn('pay_order_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('user_push_tokens', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -1,22 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('qr_settings', function (Blueprint $table) {
|
||||
$table->unsignedBigInteger('auto_withdraw_amount_minor')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('qr_settings', function (Blueprint $table) {
|
||||
$table->dropColumn('auto_withdraw_amount_minor');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
@props(['variant' => 'banner'])
|
||||
|
||||
<p {{ $attributes->merge(['class' => 'mt-1 text-[10px] leading-snug text-slate-400']) }}>
|
||||
Recommended: {{ \App\Support\Qr\QrCoverImageSpec::label($variant) }}. JPG, PNG, or WebP.
|
||||
</p>
|
||||
@@ -1,30 +0,0 @@
|
||||
@props([
|
||||
'id',
|
||||
'title',
|
||||
'description',
|
||||
])
|
||||
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||
<button type="button"
|
||||
@click="toggleSection('{{ $id }}')"
|
||||
class="flex w-full items-start gap-4 px-5 py-4 text-left transition hover:bg-slate-50/80">
|
||||
<span class="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-slate-100 text-slate-600">
|
||||
{!! $icon ?? '' !!}
|
||||
</span>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block text-sm font-semibold text-slate-900">{{ $title }}</span>
|
||||
<span class="mt-0.5 block text-xs leading-relaxed text-slate-500">{{ $description }}</span>
|
||||
</span>
|
||||
<svg class="mt-1 h-5 w-5 shrink-0 text-slate-400 transition"
|
||||
:class="openSection === '{{ $id }}' ? 'rotate-180' : ''"
|
||||
fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div x-show="openSection === '{{ $id }}'" x-cloak class="border-t border-slate-100">
|
||||
<div class="px-5 py-5">
|
||||
{{ $slot }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,68 +0,0 @@
|
||||
<x-user-layout>
|
||||
<x-slot name="title">Overview</x-slot>
|
||||
@php $fmt = fn ($m) => 'GHS '.number_format($m / 100, 2); @endphp
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-slate-900">Overview</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Today's takings, your payment QRs, and recent incoming payments.</p>
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-slate-400">Today's takings</p>
|
||||
<p class="mt-1.5 text-2xl font-semibold text-slate-900">{{ $fmt($todayMinor) }}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-slate-400">Payments today</p>
|
||||
<p class="mt-1.5 text-2xl font-semibold text-slate-900">{{ number_format($todayCount) }}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-slate-400">Payment QRs</p>
|
||||
<p class="mt-1.5 text-2xl font-semibold text-slate-900">{{ $paymentQrCount }}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-indigo-100 bg-indigo-50/60 p-5">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-indigo-600">Wallet balance</p>
|
||||
<p class="mt-1.5 text-2xl font-semibold text-slate-900">{{ $fmt($balanceMinor) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white">
|
||||
<div class="flex items-center justify-between border-b border-slate-100 px-6 py-4">
|
||||
<h2 class="font-semibold text-slate-900">Recent payments</h2>
|
||||
<a href="{{ route('mini.payments.index') }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-800">View all</a>
|
||||
</div>
|
||||
@if($recentPayments->isEmpty())
|
||||
<p class="px-6 py-8 text-sm text-slate-500">No payments yet. Print your payment QR and start accepting payments.</p>
|
||||
@else
|
||||
<div class="divide-y divide-slate-100">
|
||||
@foreach($recentPayments as $payment)
|
||||
<div class="flex items-center justify-between px-6 py-4">
|
||||
<div>
|
||||
<p class="font-medium text-slate-900">{{ $payment->payer_name ?: 'Walk-in customer' }}</p>
|
||||
<p class="text-xs text-slate-500">{{ $payment->qrCode?->label }} · {{ $payment->paid_at?->diffForHumans() }}</p>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-emerald-700">{{ $fmt($payment->merchant_amount_minor) }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-6">
|
||||
<h2 class="font-semibold text-slate-900">Quick links</h2>
|
||||
<div class="mt-4 space-y-3">
|
||||
<a href="{{ route('mini.payment-qrs.create') }}" class="flex items-center justify-between rounded-xl border border-slate-100 px-4 py-3 text-sm hover:bg-slate-50">
|
||||
<span class="font-medium text-slate-700">Create payment QR</span>
|
||||
<span class="text-slate-400">→</span>
|
||||
</a>
|
||||
<a href="{{ route('mini.payment-qrs.index') }}" class="flex items-center justify-between rounded-xl border border-slate-100 px-4 py-3 text-sm hover:bg-slate-50">
|
||||
<span class="font-medium text-slate-700">My payment QRs</span>
|
||||
<span class="text-slate-400">→</span>
|
||||
</a>
|
||||
<a href="{{ route('mini.payouts') }}" class="flex items-center justify-between rounded-xl border border-slate-100 px-4 py-3 text-sm hover:bg-slate-50">
|
||||
<span class="font-medium text-slate-700">Payouts & withdrawals</span>
|
||||
<span class="text-slate-400">→</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-user-layout>
|
||||
@@ -1,48 +0,0 @@
|
||||
<x-user-layout>
|
||||
<x-slot name="title">Create Payment QR</x-slot>
|
||||
<div class="mx-auto max-w-2xl space-y-6">
|
||||
<div class="flex items-center gap-3 py-1 lg:hidden">
|
||||
<a href="{{ route('mini.payment-qrs.index') }}"
|
||||
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-slate-200 bg-white text-slate-500 shadow-sm transition hover:text-slate-900">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5"/>
|
||||
</svg>
|
||||
</a>
|
||||
<img src="{{ asset('images/logo/ladillmini-logo.svg') }}?v={{ @filemtime(public_path('images/logo/ladillmini-logo.svg')) ?: '1' }}"
|
||||
alt="Ladill Mini" class="h-5 w-auto shrink-0">
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-semibold text-slate-900">Create payment QR</span>
|
||||
</div>
|
||||
<div class="hidden lg:block">
|
||||
<a href="{{ route('mini.payment-qrs.index') }}" class="text-sm text-slate-500 hover:text-slate-700">← Back to payment QRs</a>
|
||||
<h1 class="mt-2 text-xl font-semibold text-slate-900">Create payment QR</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Customers scan, enter an amount, and pay. Your QR is generated automatically — no styling needed.</p>
|
||||
</div>
|
||||
@if(session('error'))
|
||||
<div class="rounded-xl border border-red-100 bg-red-50 px-4 py-3 text-sm text-red-700">{{ session('error') }}</div>
|
||||
@endif
|
||||
<form method="post" action="{{ route('mini.payment-qrs.store') }}" class="space-y-5 rounded-2xl border border-slate-200 bg-white p-6">
|
||||
@csrf
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">QR label</label>
|
||||
<input type="text" name="label" value="{{ old('label') }}" required maxlength="120" placeholder="e.g. Main till"
|
||||
class="mt-1 w-full rounded-xl border-slate-200 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<p class="mt-1 text-xs text-slate-500">Internal name — shown in your dashboard only.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Business name</label>
|
||||
<input type="text" name="business_name" value="{{ old('business_name') }}" required maxlength="120" placeholder="e.g. Kofi's Shop"
|
||||
class="mt-1 w-full rounded-xl border-slate-200 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<p class="mt-1 text-xs text-slate-500">Shown on the customer payment screen.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Branch / till (optional)</label>
|
||||
<input type="text" name="branch_label" value="{{ old('branch_label') }}" maxlength="80" placeholder="e.g. Osu branch"
|
||||
class="mt-1 w-full rounded-xl border-slate-200 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
</div>
|
||||
<button type="submit" class="btn-primary w-full">
|
||||
<svg class="h-4 w-4 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg>
|
||||
Create payment QR
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</x-user-layout>
|
||||
@@ -1,40 +0,0 @@
|
||||
<x-user-layout>
|
||||
<x-slot name="title">My Payment QR</x-slot>
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-slate-900">My Payment QR</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Static QRs to print or display — one per till or branch.</p>
|
||||
</div>
|
||||
<x-btn.create :href="route('mini.payment-qrs.create')">New payment QR</x-btn.create>
|
||||
</div>
|
||||
@if(session('success'))
|
||||
<div class="rounded-xl border border-emerald-100 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">{{ session('success') }}</div>
|
||||
@endif
|
||||
@if($qrCodes->isEmpty())
|
||||
<div class="rounded-2xl border border-dashed border-slate-200 bg-white px-6 py-12 text-center">
|
||||
<p class="text-sm text-slate-500">No payment QRs yet.</p>
|
||||
<a href="{{ route('mini.payment-qrs.create') }}" class="mt-3 inline-block text-sm font-semibold text-indigo-600 hover:text-indigo-800">Create your first payment QR</a>
|
||||
</div>
|
||||
@else
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
@foreach($qrCodes as $qr)
|
||||
@php $c = $qr->content(); @endphp
|
||||
<a href="{{ route('mini.payment-qrs.show', $qr) }}" class="rounded-2xl border border-slate-200 bg-white p-5 transition hover:border-indigo-200 hover:shadow-sm">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="font-semibold text-slate-900">{{ $qr->label }}</p>
|
||||
<p class="mt-0.5 text-sm text-slate-500">{{ $c['business_name'] ?? '' }}</p>
|
||||
@if(!empty($c['branch_label']))
|
||||
<p class="mt-1 text-xs text-slate-400">{{ $c['branch_label'] }}</p>
|
||||
@endif
|
||||
</div>
|
||||
<img src="{{ $previewDataUris[$qr->id] ?? '' }}" alt="" class="h-16 w-16 rounded-lg border border-slate-100 bg-white object-contain p-1">
|
||||
</div>
|
||||
<p class="mt-4 truncate text-xs text-indigo-600">{{ $qr->publicUrl() }}</p>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-user-layout>
|
||||
@@ -1,38 +0,0 @@
|
||||
@php
|
||||
$businessName = $qrCode->content()['business_name'] ?? $qrCode->label;
|
||||
@endphp
|
||||
|
||||
<x-modal name="delete-payment-qr" maxWidth="md">
|
||||
<div class="px-5 pb-6 pt-2 sm:px-6 sm:pb-6 sm:pt-4">
|
||||
<div class="flex flex-col items-center text-center sm:items-start sm:text-left">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-red-100 text-red-600">
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="mt-4 text-lg font-semibold text-slate-900">Delete payment QR?</h2>
|
||||
<p class="mt-2 text-sm leading-relaxed text-slate-500">
|
||||
<span class="font-medium text-slate-700">{{ $qrCode->label }}</span>
|
||||
@if($businessName !== $qrCode->label)
|
||||
<span class="text-slate-400">· {{ $businessName }}</span>
|
||||
@endif
|
||||
will be removed permanently. The payment link will stop working and printed codes for this till will no longer accept payments.
|
||||
</p>
|
||||
<p class="mt-3 break-all rounded-xl bg-slate-50 px-3 py-2 text-xs text-slate-500">{{ $qrCode->publicUrl() }}</p>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ route('mini.payment-qrs.destroy', $qrCode) }}" class="mt-6 flex flex-col-reverse gap-2.5 sm:flex-row sm:justify-end">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="button"
|
||||
@click="$dispatch('close-modal', 'delete-payment-qr')"
|
||||
class="rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 transition hover:bg-slate-50">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="rounded-xl bg-red-600 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-red-700">
|
||||
Delete payment QR
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</x-modal>
|
||||
@@ -1,37 +0,0 @@
|
||||
@php
|
||||
$shareUrl = $qrCode->publicUrl();
|
||||
$shareEnc = urlencode($shareUrl);
|
||||
$shareText = urlencode($qrCode->label . ' — pay with QR');
|
||||
@endphp
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<a href="{{ route('mini.payment-qrs.download', [$qrCode, 'png']) }}"
|
||||
class="rounded-lg border border-slate-200 px-3 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50">PNG</a>
|
||||
<a href="{{ route('mini.payment-qrs.download', [$qrCode, 'svg']) }}"
|
||||
class="rounded-lg border border-slate-200 px-3 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50">SVG</a>
|
||||
<a href="{{ route('mini.payment-qrs.download', [$qrCode, 'pdf']) }}"
|
||||
class="rounded-lg border border-slate-200 px-3 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50">PDF</a>
|
||||
<div class="relative" x-data="{ open: false, copied: false }" @click.outside="open = false">
|
||||
<button type="button"
|
||||
@click="if(navigator.share){navigator.share({title:@js($qrCode->label),url:@js($shareUrl)}).catch(()=>{open=!open})}else{open=!open}"
|
||||
class="rounded-lg border border-slate-200 px-3 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50">
|
||||
Share
|
||||
</button>
|
||||
<div x-show="open" x-cloak
|
||||
x-transition:enter="transition ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
class="absolute right-0 z-50 mt-2 w-52 origin-top-right rounded-xl border border-slate-200 bg-white p-1.5 shadow-xl">
|
||||
<a href="https://wa.me/?text={{ $shareText }}%20{{ $shareEnc }}" target="_blank" rel="noopener"
|
||||
@click="open = false"
|
||||
class="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-700 transition hover:bg-slate-50">
|
||||
WhatsApp
|
||||
</a>
|
||||
<button type="button"
|
||||
@click="navigator.clipboard.writeText(@js($shareUrl)).then(() => { copied = true; setTimeout(() => { copied = false; open = false }, 1500) })"
|
||||
class="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition hover:bg-slate-50"
|
||||
:class="copied ? 'text-emerald-600' : 'text-slate-700'">
|
||||
<span x-text="copied ? 'Copied!' : 'Copy link'">Copy link</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,35 +0,0 @@
|
||||
@php
|
||||
$showDownloads = $showDownloads ?? true;
|
||||
@endphp
|
||||
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200/80 bg-white shadow-sm">
|
||||
<div class="relative flex items-center justify-center bg-gradient-to-br from-indigo-50/60 via-white to-violet-50/40 px-8 py-10">
|
||||
<div class="w-full max-w-xs">
|
||||
<div class="overflow-hidden rounded-2xl bg-white p-4 shadow-2xl ring-1 ring-black/5">
|
||||
<img src="{{ $previewDataUri }}"
|
||||
alt="Payment QR code for {{ $qrCode->label }}"
|
||||
class="aspect-square w-full object-contain">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($showDownloads)
|
||||
<div class="border-t border-slate-100 bg-slate-50/50 px-6 py-5">
|
||||
<p class="mb-3 text-[10px] font-semibold uppercase tracking-[0.1em] text-slate-400">Download</p>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<a href="{{ route('mini.payment-qrs.download', [$qrCode, 'png']) }}"
|
||||
class="group flex flex-col items-center gap-1.5 rounded-xl border border-slate-200 bg-white py-3.5 text-center transition hover:border-indigo-300 hover:bg-indigo-50">
|
||||
<span class="text-xs font-semibold text-slate-600 transition group-hover:text-indigo-700">PNG</span>
|
||||
</a>
|
||||
<a href="{{ route('mini.payment-qrs.download', [$qrCode, 'svg']) }}"
|
||||
class="group flex flex-col items-center gap-1.5 rounded-xl border border-slate-200 bg-white py-3.5 text-center transition hover:border-violet-300 hover:bg-violet-50">
|
||||
<span class="text-xs font-semibold text-slate-600 transition group-hover:text-violet-700">SVG</span>
|
||||
</a>
|
||||
<a href="{{ route('mini.payment-qrs.download', [$qrCode, 'pdf']) }}"
|
||||
class="group flex flex-col items-center gap-1.5 rounded-xl border border-slate-200 bg-white py-3.5 text-center transition hover:border-rose-300 hover:bg-rose-50">
|
||||
<span class="text-xs font-semibold text-slate-600 transition group-hover:text-rose-700">PDF</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@@ -1,81 +0,0 @@
|
||||
<x-user-layout>
|
||||
<x-slot name="title">{{ $qrCode->label }}</x-slot>
|
||||
@php $c = $qrCode->content(); @endphp
|
||||
<div class="mx-auto max-w-6xl space-y-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-3 py-1 lg:hidden">
|
||||
<a href="{{ route('mini.payment-qrs.index') }}"
|
||||
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-slate-200 bg-white text-slate-500 shadow-sm transition hover:text-slate-900">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</a>
|
||||
<img src="{{ asset('images/logo/ladillmini-logo.svg') }}?v={{ @filemtime(public_path('images/logo/ladillmini-logo.svg')) ?: '1' }}"
|
||||
alt="Ladill Mini" class="h-5 w-auto shrink-0">
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-semibold text-slate-900">{{ $qrCode->label }}</span>
|
||||
</div>
|
||||
|
||||
<div class="hidden lg:block">
|
||||
<a href="{{ route('mini.payment-qrs.index') }}" class="text-sm text-slate-500 hover:text-slate-700">← All payment QRs</a>
|
||||
<h1 class="mt-2 text-xl font-semibold text-slate-900">{{ $qrCode->label }}</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ $c['business_name'] ?? '' }}@if(!empty($c['branch_label'])) · {{ $c['branch_label'] }}@endif</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@include('mini.payment-qrs.partials.header-actions', ['qrCode' => $qrCode])
|
||||
</div>
|
||||
|
||||
@if(session('success'))
|
||||
<div class="rounded-xl border border-emerald-100 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">{{ session('success') }}</div>
|
||||
@endif
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-[380px_1fr]">
|
||||
<div class="lg:sticky lg:top-6 lg:self-start">
|
||||
@include('mini.payment-qrs.partials.preview-card', [
|
||||
'qrCode' => $qrCode,
|
||||
'previewDataUri' => $previewDataUri,
|
||||
'showDownloads' => false,
|
||||
])
|
||||
<p class="mt-4 break-all text-center text-sm text-indigo-600">{{ $qrCode->publicUrl() }}</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<form method="post" action="{{ route('mini.payment-qrs.update', $qrCode) }}" class="space-y-4 rounded-2xl border border-slate-200 bg-white p-6">
|
||||
@csrf
|
||||
@method('PATCH')
|
||||
<h2 class="font-semibold text-slate-900">Settings</h2>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Label</label>
|
||||
<input type="text" name="label" value="{{ old('label', $qrCode->label) }}" class="mt-1 w-full rounded-xl border-slate-200 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Business name</label>
|
||||
<input type="text" name="business_name" value="{{ old('business_name', $c['business_name'] ?? '') }}" class="mt-1 w-full rounded-xl border-slate-200 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Branch / till</label>
|
||||
<input type="text" name="branch_label" value="{{ old('branch_label', $c['branch_label'] ?? '') }}" class="mt-1 w-full rounded-xl border-slate-200 text-sm">
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="is_active" value="1" @checked($qrCode->is_active) class="rounded border-slate-300 text-indigo-600">
|
||||
QR is active (accepts payments)
|
||||
</label>
|
||||
<button type="submit" class="btn-primary">Save changes</button>
|
||||
</form>
|
||||
|
||||
<div class="rounded-2xl border border-red-100 bg-red-50/40 p-6">
|
||||
<h2 class="font-semibold text-red-700">Danger zone</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">Remove this payment QR and deactivate its link.</p>
|
||||
<button type="button"
|
||||
@click="$dispatch('open-modal', 'delete-payment-qr')"
|
||||
class="mt-4 rounded-xl border border-red-200 bg-white px-4 py-2 text-sm font-semibold text-red-700 transition hover:bg-red-50">
|
||||
Delete payment QR
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@include('mini.payment-qrs.partials.delete-modal', ['qrCode' => $qrCode])
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-user-layout>
|
||||
@@ -1,54 +0,0 @@
|
||||
<x-user-layout>
|
||||
<x-slot name="title">Payments</x-slot>
|
||||
@php $fmt = fn ($m) => 'GHS '.number_format($m / 100, 2); @endphp
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-slate-900">Payments</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Live feed of incoming customer payments across all your payment QRs.</p>
|
||||
</div>
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
||||
@if($payments->isEmpty())
|
||||
<p class="px-6 py-10 text-sm text-slate-500">No payments yet.</p>
|
||||
@else
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-slate-100 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
<tr>
|
||||
<th class="px-6 py-3">When</th>
|
||||
<th class="px-6 py-3">Payer</th>
|
||||
<th class="px-6 py-3">QR / till</th>
|
||||
<th class="px-6 py-3">Note</th>
|
||||
<th class="px-6 py-3">Amount</th>
|
||||
<th class="px-6 py-3">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
@foreach($payments as $payment)
|
||||
<tr>
|
||||
<td class="px-6 py-4 text-slate-600">{{ $payment->paid_at?->format('M j, g:i A') ?? $payment->created_at->format('M j, g:i A') }}</td>
|
||||
<td class="px-6 py-4">
|
||||
<p class="font-medium text-slate-900">{{ $payment->payer_name ?: 'Walk-in customer' }}</p>
|
||||
@if($payment->payer_email)
|
||||
<p class="text-xs text-slate-500">{{ $payment->payer_email }}</p>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-6 py-4 text-slate-600">{{ $payment->qrCode?->label }}</td>
|
||||
<td class="px-6 py-4 text-slate-500">{{ $payment->payer_note ?: '—' }}</td>
|
||||
<td class="px-6 py-4 font-semibold text-slate-900">{{ $fmt($payment->status === \App\Models\MiniPayment::STATUS_PAID ? $payment->merchant_amount_minor : $payment->amount_minor) }}</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class="inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium {{ $payment->status === \App\Models\MiniPayment::STATUS_PAID ? 'bg-emerald-50 text-emerald-700' : ($payment->status === \App\Models\MiniPayment::STATUS_FAILED ? 'bg-red-50 text-red-700' : 'bg-amber-50 text-amber-700') }}">
|
||||
{{ ucfirst($payment->status) }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@if($payments->hasPages())
|
||||
<div class="border-t border-slate-100 px-6 py-4">{{ $payments->links() }}</div>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</x-user-layout>
|
||||
@@ -1,27 +0,0 @@
|
||||
<x-user-layout>
|
||||
<x-slot name="title">Payouts</x-slot>
|
||||
@php $fmt = fn ($m) => 'GHS '.number_format($m / 100, 2); @endphp
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-slate-900">Payouts</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Takings settle into your Ladill wallet (3.5% fee), then withdraw to bank or MoMo.</p>
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-slate-400">Total received (net)</p>
|
||||
<p class="mt-1.5 text-2xl font-semibold text-slate-900">{{ $fmt($revenueMinor) }}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-indigo-100 bg-indigo-50/60 p-5">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-indigo-600">Available in wallet</p>
|
||||
<p class="mt-1.5 text-2xl font-semibold text-slate-900">{{ $fmt($balanceMinor) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-slate-50 px-6 py-4 text-sm text-slate-600">
|
||||
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.
|
||||
</div>
|
||||
<a href="{{ $accountWalletUrl }}" class="btn-primary">
|
||||
Open wallet & withdraw
|
||||
<span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</div>
|
||||
</x-user-layout>
|
||||
@@ -1,69 +0,0 @@
|
||||
<x-user-layout>
|
||||
<x-slot name="title">Settings</x-slot>
|
||||
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Settings</h1>
|
||||
<p class="mt-0.5 text-sm text-slate-500">Notification preferences for your payment QRs.</p>
|
||||
|
||||
@if (session('success'))
|
||||
<div class="mt-4 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">{{ session('success') }}</div>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('account.settings.update') }}" class="mt-6 space-y-4">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Notifications</h2>
|
||||
<p class="mt-1 text-xs text-slate-500">Choose what we email you about payments and your account.</p>
|
||||
|
||||
<div class="mt-4">
|
||||
<label for="notify_email" class="block text-[11px] font-medium text-slate-500">Notifications email</label>
|
||||
<input type="email" id="notify_email" name="notify_email"
|
||||
value="{{ old('notify_email', $settings->notify_email ?? $account->email) }}"
|
||||
class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
|
||||
@error('notify_email') <p class="mt-1 text-xs text-rose-600">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<label class="mt-4 flex items-center justify-between gap-4">
|
||||
<span>
|
||||
<span class="block text-sm font-medium text-slate-800">Incoming payments</span>
|
||||
<span class="block text-xs text-slate-400">Email when a customer pays via your payment QR.</span>
|
||||
</span>
|
||||
<input type="checkbox" name="notify_registrations" value="1"
|
||||
@checked(old('notify_registrations', $settings->notify_registrations ?? true))
|
||||
class="h-5 w-5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
|
||||
</label>
|
||||
|
||||
<label class="mt-4 flex items-center justify-between gap-4">
|
||||
<span>
|
||||
<span class="block text-sm font-medium text-slate-800">Payouts & settlements</span>
|
||||
<span class="block text-xs text-slate-400">Email when takings settle into your wallet.</span>
|
||||
</span>
|
||||
<input type="checkbox" name="notify_payouts" value="1"
|
||||
@checked(old('notify_payouts', $settings->notify_payouts ?? true))
|
||||
class="h-5 w-5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
|
||||
</label>
|
||||
|
||||
<label class="mt-4 flex items-center justify-between gap-4">
|
||||
<span>
|
||||
<span class="block text-sm font-medium text-slate-800">Ladill Mini updates</span>
|
||||
<span class="block text-xs text-slate-400">Occasional emails about new features and improvements.</span>
|
||||
</span>
|
||||
<input type="checkbox" name="product_updates" value="1"
|
||||
@checked(old('product_updates', $settings->product_updates ?? true))
|
||||
class="h-5 w-5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary">
|
||||
Save settings
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="mt-6 text-xs text-slate-400">
|
||||
Profile, password, and security are managed on
|
||||
<a href="{{ ladill_account_url('account-settings') }}" class="font-medium text-indigo-600 hover:text-indigo-800">account.ladill.com</a>.
|
||||
</p>
|
||||
</div>
|
||||
</x-user-layout>
|
||||
@@ -1 +0,0 @@
|
||||
@include('auth.signed-out')
|
||||
@@ -1,178 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
@include('partials.favicon')
|
||||
<title>{{ $qrCode->label }}</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html, body { height: 100%; }
|
||||
|
||||
body {
|
||||
background: #f1f5f9;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100dvh;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
#toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 1.25rem;
|
||||
height: 3.25rem;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
flex-shrink: 0;
|
||||
z-index: 20;
|
||||
}
|
||||
.tb-label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
max-width: 65%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tb-download {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.9rem;
|
||||
border-radius: 9999px;
|
||||
background: #f1f5f9;
|
||||
border: 1px solid #cbd5e1;
|
||||
color: #334155;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.tb-download:hover { background: #e2e8f0; }
|
||||
.tb-download svg { width: 0.875rem; height: 0.875rem; flex-shrink: 0; }
|
||||
|
||||
#scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 1.25rem 0.75rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
#loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.875rem;
|
||||
color: #94a3b8;
|
||||
font-size: 0.8125rem;
|
||||
padding: 4rem 0;
|
||||
width: 100%;
|
||||
}
|
||||
.spinner {
|
||||
width: 2rem; height: 2rem;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-top-color: #94a3b8;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.75s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.pdf-page {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.12);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
#bottom {
|
||||
flex-shrink: 0;
|
||||
height: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.72rem;
|
||||
color: #94a3b8;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="toolbar">
|
||||
<span class="tb-label">{{ $qrCode->label }}</span>
|
||||
@if($allowDownload)
|
||||
<a href="{{ route('qr.public.file', $qrCode->short_code) }}" download class="tb-download">
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3"/>
|
||||
</svg>
|
||||
Download
|
||||
</a>
|
||||
@else
|
||||
<span></span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div id="scroll">
|
||||
<div id="loading">
|
||||
<div class="spinner"></div>
|
||||
Loading document…
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="bottom"><span id="page-info"></span></div>
|
||||
|
||||
<script type="module">
|
||||
import * as pdfjsLib from 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.0.379/pdf.min.mjs';
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.0.379/pdf.worker.min.mjs';
|
||||
|
||||
const fileUrl = @json($fileUrl);
|
||||
const scroll = document.getElementById('scroll');
|
||||
const loading = document.getElementById('loading');
|
||||
const pageInfo = document.getElementById('page-info');
|
||||
|
||||
async function main() {
|
||||
const pdf = await pdfjsLib.getDocument(fileUrl).promise;
|
||||
const n = pdf.numPages;
|
||||
|
||||
pageInfo.textContent = n + ' page' + (n !== 1 ? 's' : '');
|
||||
loading.remove();
|
||||
|
||||
const maxW = scroll.clientWidth - 24;
|
||||
|
||||
for (let i = 1; i <= n; i++) {
|
||||
const pg = await pdf.getPage(i);
|
||||
const vp0 = pg.getViewport({ scale: 1 });
|
||||
const scale = Math.min(maxW / vp0.width, 2);
|
||||
const vp = pg.getViewport({ scale });
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.className = 'pdf-page';
|
||||
canvas.width = vp.width * dpr;
|
||||
canvas.height = vp.height * dpr;
|
||||
canvas.style.width = vp.width + 'px';
|
||||
canvas.style.height = vp.height + 'px';
|
||||
|
||||
scroll.appendChild(canvas);
|
||||
|
||||
await pg.render({ canvasContext: canvas.getContext('2d'), viewport: pg.getViewport({ scale: scale * dpr }) }).promise;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
loading.innerHTML = '<p style="color:#ef4444;text-align:center;padding:1rem">Failed to load document.</p>';
|
||||
console.error(err);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ $isContribution ? 'Thank you' : "You're registered" }} — {{ $evName }}</title>
|
||||
@include('partials.favicon')
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-100 flex items-center justify-center px-4 py-10">
|
||||
<div class="w-full max-w-sm">
|
||||
<div class="overflow-hidden rounded-3xl bg-white shadow-xl">
|
||||
<div class="px-6 py-7 text-center" style="background:linear-gradient(160deg, {{ $evColor }} 0%, {{ $evColor }}cc 100%);">
|
||||
<div class="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-white/20">
|
||||
<svg class="h-8 w-8 text-white" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5"/></svg>
|
||||
</div>
|
||||
<h1 class="text-xl font-black text-white">{{ $isContribution ? 'Thank you!' : "You're registered!" }}</h1>
|
||||
<p class="mt-1 text-sm text-white/85">{{ $evName }}</p>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-6 text-center">
|
||||
@if($isContribution)
|
||||
<p class="text-sm text-slate-500">Hi <span class="font-semibold text-slate-800">{{ $registration->attendee_name }}</span>, your contribution has been received.</p>
|
||||
|
||||
<div class="mt-5 rounded-2xl border border-slate-200 bg-slate-50 px-5 py-6">
|
||||
<p class="text-[10px] font-bold uppercase tracking-widest text-slate-400">{{ $registration->tier_name }}</p>
|
||||
<p class="mt-1 text-3xl font-black" style="color:{{ $evColor }}">{{ $registration->currency }} {{ number_format($registration->amountCedis(), 2) }}</p>
|
||||
<p class="mt-3 text-[10px] font-bold uppercase tracking-widest text-slate-400">Reference</p>
|
||||
<p class="font-mono text-sm font-bold tracking-[0.15em] text-slate-700">{{ $registration->badge_code }}</p>
|
||||
</div>
|
||||
|
||||
@if(($registration->badge_fields ?? []))
|
||||
<div class="mt-5 space-y-2 text-left text-sm">
|
||||
@foreach(($registration->badge_fields ?? []) as $label => $value)
|
||||
<div class="flex justify-between border-b border-slate-100 pb-2"><span class="text-slate-400">{{ $label }}</span><span class="font-semibold text-slate-800">{{ $value }}</span></div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<p class="mt-5 text-xs text-slate-400">A receipt was sent to {{ $registration->attendee_email }}.</p>
|
||||
@else
|
||||
<p class="text-sm text-slate-500">Hi <span class="font-semibold text-slate-800">{{ $registration->attendee_name }}</span>, your spot is confirmed.</p>
|
||||
|
||||
<div class="mt-5 rounded-2xl border border-slate-200 bg-slate-50 px-5 py-5">
|
||||
<img src="{{ $badgeUrl }}" alt="Badge QR" class="mx-auto h-40 w-40 rounded-xl bg-white p-2 shadow-sm">
|
||||
<p class="mt-3 text-[10px] font-bold uppercase tracking-widest text-slate-400">Badge code</p>
|
||||
<p class="font-mono text-2xl font-black tracking-[0.2em]" style="color:{{ $evColor }}">{{ $registration->badge_code }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 space-y-2 text-left text-sm">
|
||||
<div class="flex justify-between border-b border-slate-100 pb-2"><span class="text-slate-400">Ticket</span><span class="font-semibold text-slate-800">{{ $registration->tier_name }}</span></div>
|
||||
@if($registration->isPaid())
|
||||
<div class="flex justify-between border-b border-slate-100 pb-2"><span class="text-slate-400">Paid</span><span class="font-semibold text-slate-800">{{ $registration->currency }} {{ number_format($registration->amountCedis(), 2) }}</span></div>
|
||||
@endif
|
||||
@foreach(($registration->badge_fields ?? []) as $label => $value)
|
||||
<div class="flex justify-between border-b border-slate-100 pb-2"><span class="text-slate-400">{{ $label }}</span><span class="font-semibold text-slate-800">{{ $value }}</span></div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<p class="mt-5 text-xs text-slate-400">Show this badge code at check-in. A confirmation was sent to {{ $registration->attendee_email }}.</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,18 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
@include('partials.favicon')
|
||||
<title>QR code inactive</title>
|
||||
<style>
|
||||
body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; font-family: system-ui, sans-serif; background: #f8fafc; color: #334155; padding: 2rem; text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div>
|
||||
<h1 style="font-size: 1.25rem; margin: 0 0 0.5rem;">This QR code is not active</h1>
|
||||
<p style="margin: 0; font-size: 0.875rem; color: #64748b;">The owner has paused this link. Try again later.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,26 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Payment confirmed</title>
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600,700&display=swap" rel="stylesheet" />
|
||||
@vite(['resources/css/app.css'])
|
||||
</head>
|
||||
<body class="min-h-screen bg-emerald-50/40 font-sans antialiased">
|
||||
<main class="mx-auto flex min-h-screen max-w-md flex-col justify-center px-4 py-10 text-center">
|
||||
<div class="rounded-3xl border border-emerald-100 bg-white p-8 shadow-sm">
|
||||
<div class="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-emerald-100 text-emerald-600">
|
||||
<svg class="h-7 w-7" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg>
|
||||
</div>
|
||||
<h1 class="mt-4 text-xl font-bold text-slate-900">Payment successful</h1>
|
||||
<p class="mt-2 text-sm text-slate-600">
|
||||
{{ $payment->currency }} {{ number_format($payment->amount_minor / 100, 2) }} paid to
|
||||
{{ $qrCode->content()['business_name'] ?? $qrCode->label }}.
|
||||
</p>
|
||||
<p class="mt-4 text-xs text-slate-500">Reference: {{ $payment->reference }}</p>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="csrf-token" content="{{ $csrf }}">
|
||||
<title>Pay {{ $businessName }}</title>
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600,700&display=swap" rel="stylesheet" />
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-50 font-sans text-slate-900 antialiased"
|
||||
x-data="miniPaymentLanding({
|
||||
payUrl: @js($payUrl),
|
||||
csrf: @js($csrf),
|
||||
amount: @js(old('amount')),
|
||||
errorMsg: @js(session('error')),
|
||||
})">
|
||||
|
||||
{{-- Mobile: Ladill Mini branding + fixed payment sheet --}}
|
||||
<div class="md:hidden">
|
||||
<div class="fixed inset-0 overflow-hidden bg-slate-50">
|
||||
<div class="flex h-full flex-col items-center justify-center px-6 pb-56 text-center pt-[max(2.5rem,env(safe-area-inset-top))]">
|
||||
<img src="{{ asset('images/launcher-icons/mini.svg') }}?v={{ @filemtime(public_path('images/launcher-icons/mini.svg')) ?: '1' }}"
|
||||
alt="Ladill Mini" class="h-14 w-14 object-contain">
|
||||
<h1 class="mt-4 text-xl font-bold text-slate-900">{{ $businessName }}</h1>
|
||||
@if(!empty($content['branch_label']))
|
||||
<p class="mt-1 text-sm text-slate-500">{{ $content['branch_label'] }}</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mini-payment-bottom-bleed md:hidden"
|
||||
:style="sheetBleedStyle"
|
||||
aria-hidden="true"></div>
|
||||
|
||||
<div class="mini-payment-sheet rounded-t-3xl border-t border-slate-200/80 bg-white px-5 pt-3 shadow-[0_-12px_40px_rgba(15,23,42,0.12)] md:hidden"
|
||||
:style="paymentSheetStyle">
|
||||
<div class="mx-auto mb-4 h-1 w-10 rounded-full bg-slate-200"></div>
|
||||
|
||||
<div x-show="errorMsg" x-cloak class="mb-4 rounded-xl bg-red-50 px-4 py-3 text-sm text-red-700" x-text="errorMsg"></div>
|
||||
|
||||
<label for="amount-mobile" class="sr-only">Amount ({{ $currency }})</label>
|
||||
<div class="relative">
|
||||
<span class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-4 text-lg font-semibold text-slate-400">{{ $currency }}</span>
|
||||
<input type="number" id="amount-mobile" x-model="amount" step="0.01" min="0.01" required autofocus
|
||||
inputmode="decimal"
|
||||
@keydown.enter.prevent="submitPay()"
|
||||
class="w-full rounded-2xl border-slate-200 py-4 pl-16 pr-4 text-3xl font-bold tracking-tight text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
|
||||
placeholder="0.00">
|
||||
</div>
|
||||
|
||||
<button type="button" @click="submitPay()" :disabled="loading"
|
||||
class="mt-4 flex w-full items-center justify-center gap-2 rounded-2xl bg-indigo-600 py-4 text-base font-semibold text-white hover:bg-indigo-700 disabled:opacity-60">
|
||||
<svg x-show="loading" x-cloak class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
<span x-text="loading ? 'Opening payment…' : 'Pay'">Pay</span>
|
||||
</button>
|
||||
|
||||
<p class="mt-3 text-center text-[11px] text-slate-400">Secured by Paystack. Powered by Ladill Pay</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Desktop: centered card --}}
|
||||
<main class="mx-auto hidden min-h-screen max-w-sm flex-col justify-center px-4 py-8 md:flex">
|
||||
<div class="rounded-3xl border border-slate-200/80 bg-white p-6 shadow-sm">
|
||||
<div class="text-center">
|
||||
@if(!empty($content['logo_path']))
|
||||
<img src="{{ route('qr.public.payment.logo', $qrCode->short_code) }}" alt="" class="mx-auto h-14 w-14 rounded-2xl object-cover">
|
||||
@endif
|
||||
<h1 class="mt-3 text-lg font-bold text-slate-900">{{ $businessName }}</h1>
|
||||
@if(!empty($content['branch_label']))
|
||||
<p class="mt-0.5 text-sm text-slate-500">{{ $content['branch_label'] }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div x-show="errorMsg" x-cloak class="mt-4 rounded-xl bg-red-50 px-4 py-3 text-sm text-red-700" x-text="errorMsg"></div>
|
||||
|
||||
<div class="mt-6">
|
||||
<label for="amount-desktop" class="sr-only">Amount ({{ $currency }})</label>
|
||||
<div class="relative">
|
||||
<span class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-4 text-lg font-semibold text-slate-400">{{ $currency }}</span>
|
||||
<input type="number" id="amount-desktop" x-model="amount" step="0.01" min="0.01" required
|
||||
inputmode="decimal"
|
||||
@keydown.enter.prevent="submitPay()"
|
||||
class="w-full rounded-2xl border-slate-200 py-4 pl-16 pr-4 text-3xl font-bold tracking-tight text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
|
||||
placeholder="0.00">
|
||||
</div>
|
||||
<button type="button" @click="submitPay()" :disabled="loading"
|
||||
class="mt-4 flex w-full items-center justify-center gap-2 rounded-2xl bg-indigo-600 py-4 text-base font-semibold text-white hover:bg-indigo-700 disabled:opacity-60">
|
||||
<svg x-show="loading" x-cloak class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
<span x-text="loading ? 'Opening payment…' : 'Pay'">Pay</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="mt-4 text-center text-[11px] text-slate-400">Secured by Paystack. Powered by Ladill Pay</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@include('partials.paystack-sheet')
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,176 +0,0 @@
|
||||
<x-user-layout>
|
||||
@php $isContribution = ($qrCode->content()['mode'] ?? 'ticketing') === 'contributions'; @endphp
|
||||
<x-slot name="title">{{ $isContribution ? 'Contributions' : 'Attendees' }} — {{ $qrCode->label }}</x-slot>
|
||||
|
||||
<div class="mx-auto max-w-5xl space-y-6"
|
||||
x-data="{
|
||||
selected: [],
|
||||
toggle(id) { const i = this.selected.indexOf(id); i === -1 ? this.selected.push(id) : this.selected.splice(i, 1); },
|
||||
get qs() { return this.selected.map(id => 'ids[]=' + id).join('&'); },
|
||||
printSelected() { window.open(@js(route('events.badges', $qrCode)) + '?auto=1&' + this.qs, '_blank'); },
|
||||
zplSelected() { window.location = @js(route('events.badges.zpl', $qrCode)) + '?' + this.qs; }
|
||||
}">
|
||||
|
||||
{{-- Flash --}}
|
||||
@foreach(['success', 'error'] as $flash)
|
||||
@if(session($flash))
|
||||
<div class="rounded-xl border px-4 py-3 text-sm {{ $flash === 'success' ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-red-200 bg-red-50 text-red-700' }}">
|
||||
{{ session($flash) }}
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
{{-- Header --}}
|
||||
<div>
|
||||
<a href="{{ route('events.show', $qrCode) }}" class="inline-flex items-center gap-1 text-sm text-slate-500 hover:text-slate-700">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5"/></svg>
|
||||
{{ $qrCode->label }}
|
||||
</a>
|
||||
<h1 class="mt-1.5 text-2xl font-bold text-slate-900">{{ $isContribution ? 'Contributions' : 'Attendees' }}</h1>
|
||||
</div>
|
||||
|
||||
{{-- Share programme outline --}}
|
||||
@if($programme)
|
||||
<div class="flex flex-col gap-3 rounded-2xl border border-indigo-100 bg-indigo-50/60 p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-indigo-100 text-indigo-600">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25Z"/></svg>
|
||||
</span>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-slate-900">Programme outline</p>
|
||||
<p class="text-xs text-slate-500">Email & text the <span class="font-medium text-slate-600">{{ $programme->label }}</span> programme link to all confirmed attendees.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form method="POST" action="{{ route('events.attendees.share-programme', $qrCode) }}"
|
||||
x-data="{ busy: false }"
|
||||
@submit="async ($event) => {
|
||||
if (busy) return;
|
||||
if (! await $store.ladillConfirm.ask({
|
||||
title: 'Send programme?',
|
||||
message: 'Send the programme outline to all confirmed attendees by email and SMS?',
|
||||
confirmLabel: 'Send',
|
||||
variant: 'primary',
|
||||
})) { $event.preventDefault(); return; }
|
||||
busy = true;
|
||||
}"
|
||||
class="shrink-0">
|
||||
@csrf
|
||||
<button type="submit" :disabled="busy"
|
||||
class="btn-primary w-full sm:w-auto">
|
||||
<span x-text="busy ? 'Sending…' : 'Share with attendees'">Share with attendees</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Stats --}}
|
||||
<div class="grid {{ $isContribution ? 'grid-cols-2' : 'grid-cols-3' }} gap-3">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4">
|
||||
<p class="text-2xl font-bold text-slate-900">{{ number_format($stats['total']) }}</p>
|
||||
<p class="text-xs text-slate-500">{{ $isContribution ? 'Contributions' : 'Registered' }}</p>
|
||||
</div>
|
||||
@unless($isContribution)
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4">
|
||||
<p class="text-2xl font-bold text-slate-900">{{ number_format($stats['checked_in']) }}</p>
|
||||
<p class="text-xs text-slate-500">Checked in</p>
|
||||
</div>
|
||||
@endunless
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4">
|
||||
<p class="text-2xl font-bold text-slate-900">GHS {{ number_format($stats['revenue'], 2) }}</p>
|
||||
<p class="text-xs text-slate-500">{{ $isContribution ? 'Total raised' : 'Revenue' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Toolbar --}}
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<form method="GET" class="flex items-center gap-2">
|
||||
<input type="text" name="search" value="{{ request('search') }}" placeholder="Search name, email, code…"
|
||||
class="rounded-xl border border-slate-200 px-3.5 py-2 text-sm focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
||||
<select name="status" onchange="this.form.submit()" class="rounded-xl border border-slate-200 px-3 py-2 text-sm text-slate-600 focus:border-indigo-400 focus:outline-none">
|
||||
<option value="">All</option>
|
||||
<option value="confirmed" @selected(request('status') === 'confirmed')>Confirmed</option>
|
||||
<option value="pending" @selected(request('status') === 'pending')>Pending</option>
|
||||
</select>
|
||||
</form>
|
||||
@unless($isContribution)
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span x-show="selected.length" x-cloak class="text-xs font-medium text-slate-500"><span x-text="selected.length"></span> selected</span>
|
||||
<button type="button" x-show="selected.length" x-cloak @click="printSelected()"
|
||||
class="btn-primary btn-primary-sm">Print selected</button>
|
||||
<button type="button" x-show="selected.length" x-cloak @click="zplSelected()"
|
||||
class="rounded-xl border border-slate-200 px-3.5 py-2 text-xs font-semibold text-slate-600 hover:bg-slate-50 transition">ZPL selected</button>
|
||||
<a href="{{ route('events.badges', $qrCode) }}?auto=1" target="_blank"
|
||||
class="rounded-xl bg-slate-900 px-3.5 py-2 text-xs font-semibold text-white hover:bg-slate-800 transition">Print all badges</a>
|
||||
<a href="{{ route('events.badges.zpl', $qrCode) }}"
|
||||
class="rounded-xl border border-slate-200 px-3.5 py-2 text-xs font-semibold text-slate-600 hover:bg-slate-50 transition">Download ZPL</a>
|
||||
</div>
|
||||
@endunless
|
||||
</div>
|
||||
|
||||
{{-- Table --}}
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead class="border-b border-slate-100 bg-slate-50/70 text-left text-xs uppercase tracking-wide text-slate-400">
|
||||
<tr>
|
||||
@unless($isContribution)<th class="px-4 py-3 w-8"></th>@endunless
|
||||
<th class="px-4 py-3">{{ $isContribution ? 'Contributor' : 'Attendee' }}</th>
|
||||
<th class="px-4 py-3">{{ $isContribution ? 'Category' : 'Ticket' }}</th>
|
||||
@unless($isContribution)<th class="px-4 py-3">Badge</th>@endunless
|
||||
<th class="px-4 py-3">Status</th>
|
||||
@unless($isContribution)<th class="px-4 py-3 text-right">Check-in</th>@endunless
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
@forelse($registrations as $reg)
|
||||
<tr class="hover:bg-slate-50/60">
|
||||
@unless($isContribution)
|
||||
<td class="px-4 py-3">
|
||||
@if($reg->status === \App\Models\QrEventRegistration::STATUS_CONFIRMED)
|
||||
<input type="checkbox" :checked="selected.includes({{ $reg->id }})" @change="toggle({{ $reg->id }})"
|
||||
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
|
||||
@endif
|
||||
</td>
|
||||
@endunless
|
||||
<td class="px-4 py-3">
|
||||
<p class="font-semibold text-slate-800">{{ $reg->attendee_name }}</p>
|
||||
<p class="text-xs text-slate-400">{{ $reg->attendee_email }}</p>
|
||||
@foreach(($reg->badge_fields ?? []) as $k => $v)
|
||||
<span class="mr-1 inline-block rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-500">{{ $k }}: {{ $v }}</span>
|
||||
@endforeach
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="font-medium text-slate-700">{{ $reg->tier_name }}</span>
|
||||
@if($reg->isPaid())<p class="text-xs text-slate-400">GHS {{ number_format($reg->amountCedis(), 2) }}</p>@endif
|
||||
</td>
|
||||
@unless($isContribution)
|
||||
<td class="px-4 py-3"><code class="rounded bg-slate-100 px-2 py-0.5 text-xs">{{ $reg->badge_code }}</code></td>
|
||||
@endunless
|
||||
<td class="px-4 py-3">
|
||||
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {{ $reg->status === 'confirmed' ? 'bg-emerald-50 text-emerald-700' : ($reg->status === 'pending' ? 'bg-amber-50 text-amber-700' : 'bg-slate-100 text-slate-500') }}">
|
||||
{{ ucfirst($reg->status) }}
|
||||
</span>
|
||||
</td>
|
||||
@unless($isContribution)
|
||||
<td class="px-4 py-3 text-right">
|
||||
<form method="POST" action="{{ route('events.attendees.check-in', [$qrCode, $reg]) }}">
|
||||
@csrf @method('PATCH')
|
||||
<button type="submit" class="rounded-lg border px-2.5 py-1 text-xs font-semibold transition {{ $reg->checked_in_at ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-slate-200 text-slate-500 hover:bg-slate-50' }}">
|
||||
{{ $reg->checked_in_at ? '✓ In' : 'Check in' }}
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
@endunless
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="{{ $isContribution ? 3 : 6 }}" class="px-4 py-12 text-center text-sm text-slate-400">{{ $isContribution ? 'No contributions yet.' : 'No registrations yet.' }}</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@if($registrations->hasPages())
|
||||
<div class="border-t border-slate-100 px-4 py-3">{{ $registrations->links() }}</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</x-user-layout>
|
||||
@@ -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
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
@include('partials.favicon')
|
||||
<title>Badges — {{ $evName }}</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #e5e7eb; color: #0f172a; }
|
||||
.toolbar { position: sticky; top: 0; display: flex; gap: 10px; justify-content: center; padding: 14px; background: #fff; border-bottom: 1px solid #e2e8f0; }
|
||||
.toolbar button { border: none; border-radius: 10px; padding: 9px 18px; font-size: 13px; font-weight: 700; cursor: pointer; }
|
||||
.btn-print { background: {{ $evColor }}; color: #fff; }
|
||||
.btn-close { background: #f1f5f9; color: #475569; }
|
||||
.sheet { display: flex; flex-wrap: wrap; gap: 12px; justify-content: center; padding: 24px; }
|
||||
.badge {
|
||||
width: {{ $bw }}; height: {{ $bh }};
|
||||
background: #fff; border: 1px solid #cbd5e1; border-radius: 10px; overflow: hidden;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.badge-head { background: {{ $evColor }}; color: #fff; padding: 8px 10px; display: flex; align-items: center; gap: 7px; }
|
||||
.badge-head img { height: 22px; width: 22px; object-fit: contain; background: #fff; border-radius: 4px; padding: 1px; }
|
||||
.badge-head .ev { font-size: 11px; font-weight: 800; letter-spacing: .02em; line-height: 1.1; }
|
||||
.badge-body { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; padding: 6px 10px; }
|
||||
.badge-name { font-size: 22px; font-weight: 900; line-height: 1.05; }
|
||||
.badge-tier { margin-top: 3px; font-size: 11px; font-weight: 700; color: {{ $evColor }}; text-transform: uppercase; letter-spacing: .05em; }
|
||||
.badge-fields { margin-top: 4px; font-size: 9.5px; color: #64748b; }
|
||||
.badge-foot { display: flex; align-items: center; justify-content: space-between; padding: 6px 10px; border-top: 1px solid #e2e8f0; }
|
||||
.badge-foot code { font-family: ui-monospace, monospace; font-size: 11px; font-weight: 700; letter-spacing: .12em; }
|
||||
.badge-foot img { height: 44px; width: 44px; }
|
||||
@media print {
|
||||
.toolbar { display: none; }
|
||||
body { background: #fff; }
|
||||
.sheet { padding: 0; gap: 0; }
|
||||
.badge { border: none; border-radius: 0; page-break-after: always; break-after: page; }
|
||||
@page { size: {{ $bw }} {{ $bh }}; margin: 0; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body @if($auto) onload="window.print()" @endif>
|
||||
<div class="toolbar">
|
||||
<button class="btn-print" onclick="window.print()">Print {{ count($registrations) }} badge{{ count($registrations) === 1 ? '' : 's' }}</button>
|
||||
<button class="btn-close" onclick="window.close()">Close</button>
|
||||
</div>
|
||||
|
||||
<div class="sheet">
|
||||
@forelse($registrations as $reg)
|
||||
@php $badgeQr = 'https://api.qrserver.com/v1/create-qr-code/?size=120x120&margin=0&data=' . urlencode($reg->badge_code); @endphp
|
||||
<div class="badge">
|
||||
<div class="badge-head">
|
||||
@if($hasLogo)<img src="{{ $logoUrl }}" alt="">@endif
|
||||
<span class="ev">{{ $evName }}</span>
|
||||
</div>
|
||||
<div class="badge-body">
|
||||
<div class="badge-name">{{ $reg->attendee_name }}</div>
|
||||
<div class="badge-tier">{{ $reg->tier_name }}</div>
|
||||
@if(!empty($reg->badge_fields))
|
||||
<div class="badge-fields">{{ collect($reg->badge_fields)->map(fn($v,$k) => $v)->implode(' · ') }}</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="badge-foot">
|
||||
<code>{{ $reg->badge_code }}</code>
|
||||
<img src="{{ $badgeQr }}" alt="{{ $reg->badge_code }}">
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<p style="padding:40px;color:#64748b;">No confirmed attendees to print.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,303 +0,0 @@
|
||||
<x-user-layout>
|
||||
@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
|
||||
<x-slot name="title">{{ $isProgramme ? 'New programme' : 'Create event' }}</x-slot>
|
||||
|
||||
<div class="mx-auto max-w-6xl space-y-6"
|
||||
x-data="qrCustomizer({
|
||||
previewUrl: @js(route('events.style-preview')),
|
||||
csrf: @js(csrf_token()),
|
||||
shortCode: 'preview',
|
||||
style: @js($defaultStyle),
|
||||
balance: @js((float) $wallet->spendableBalance()),
|
||||
price: @js((float) $pricePerQr),
|
||||
topupUrl: @js($topupUrl),
|
||||
type: @js($createType),
|
||||
wizard: @js(! $isProgramme),
|
||||
})">
|
||||
|
||||
@if(session('error'))
|
||||
<div class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ session('error') }}</div>
|
||||
@endif
|
||||
|
||||
{{-- Mobile page header --}}
|
||||
<div class="flex items-center gap-3 py-1 lg:hidden">
|
||||
<a href="{{ $isProgramme ? route('programmes.index') : route('events.index') }}"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-xl border border-slate-200 bg-white shadow-sm text-slate-500 hover:text-slate-900 transition">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</a>
|
||||
<span class="text-sm font-semibold text-slate-900">{{ $isProgramme ? 'New programme' : 'Create event' }}</span>
|
||||
</div>
|
||||
|
||||
{{-- Desktop page header --}}
|
||||
<div class="hidden lg:block">
|
||||
<a href="{{ $isProgramme ? route('programmes.index') : route('events.index') }}" class="inline-flex items-center gap-1 text-sm text-slate-500 hover:text-slate-700">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5"/></svg>
|
||||
{{ $isProgramme ? 'Programmes' : 'Events' }}
|
||||
</a>
|
||||
<h1 class="mt-2 text-2xl font-bold text-slate-900">{{ $isProgramme ? 'New programme' : 'Create event' }}</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
{{ $isProgramme ? 'Build a schedule outline and share it with attendees.' : 'Set up your event, then design the QR attendees will scan.' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@unless($isProgramme)
|
||||
{{-- Event creation wizard steps --}}
|
||||
<div class="rounded-2xl border border-slate-200 bg-white px-4 py-4 sm:px-6">
|
||||
<ol class="flex items-center justify-between gap-2">
|
||||
@foreach(['Event details', 'QR code', 'Review & create'] as $i => $label)
|
||||
@php $n = $i + 1; @endphp
|
||||
<li class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs font-bold transition"
|
||||
:class="step >= {{ $n }} ? 'bg-indigo-600 text-white' : 'bg-slate-100 text-slate-400'">{{ $n }}</span>
|
||||
<span class="hidden truncate text-xs font-semibold sm:block"
|
||||
:class="step >= {{ $n }} ? 'text-slate-900' : 'text-slate-400'">{{ $label }}</span>
|
||||
@if($n < 3)
|
||||
<span class="mx-1 hidden h-px flex-1 bg-slate-200 sm:block"></span>
|
||||
@endif
|
||||
</li>
|
||||
@endforeach
|
||||
</ol>
|
||||
</div>
|
||||
@endunless
|
||||
|
||||
<form x-ref="createForm" action="{{ route('events.store') }}" method="POST" enctype="multipart/form-data"
|
||||
class="grid grid-cols-1 gap-8"
|
||||
:class="(!wizard || step >= 2) ? 'lg:grid-cols-[380px_1fr]' : ''"
|
||||
@submit="wizard ? wizardSubmit($event) : submitOrTopup($event)">
|
||||
@csrf
|
||||
<input type="hidden" name="type" value="{{ $createType }}">
|
||||
|
||||
{{-- QR preview (step 2 for events, always for programmes) --}}
|
||||
<div class="lg:sticky lg:top-6 lg:self-start"
|
||||
x-show="!wizard || step === 2 || step === 3"
|
||||
x-cloak
|
||||
:class="(!wizard || step >= 2) ? 'block' : 'hidden'">
|
||||
<div class="hidden lg:block">
|
||||
@include('qr-codes.partials.qr-preview-card', ['showDownloads' => false])
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-5 pb-4 lg:pb-0"
|
||||
:class="wizard && step >= 2 ? 'lg:col-start-2' : (wizard && step === 1 ? 'lg:col-span-full' : '')">
|
||||
|
||||
{{-- Step 1 / Programme: Event details --}}
|
||||
<div x-show="!wizard || step === 1" class="overflow-hidden rounded-2xl border border-slate-200/80 bg-white shadow-sm">
|
||||
<div class="border-b border-slate-100 bg-slate-50/70 px-5 py-4">
|
||||
<h2 class="text-sm font-semibold text-slate-900">{{ $isProgramme ? 'Programme details' : 'Event details' }}</h2>
|
||||
<p class="mt-0.5 text-xs text-slate-400">{{ $isProgramme ? 'Title, schedule, and venue for this outline.' : 'Name, dates, ticketing, and registration settings.' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 p-5">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-600">Label</label>
|
||||
<input type="text" name="label" value="{{ old('label') }}" required maxlength="120"
|
||||
class="mt-1.5 w-full rounded-xl border border-slate-200 bg-white px-3.5 py-2.5 text-sm placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30"
|
||||
placeholder="{{ $isProgramme ? 'e.g. Annual conference programme' : 'e.g. Tech summit 2026' }}">
|
||||
</div>
|
||||
|
||||
<div x-data="{
|
||||
slug: @js(old('custom_short_code', '')),
|
||||
status: '',
|
||||
timer: null,
|
||||
checkUrl: @js(route('events.check-slug')),
|
||||
baseUrl: @js(\App\Models\QrCode::publicBaseUrl() . '/q'),
|
||||
check() {
|
||||
clearTimeout(this.timer);
|
||||
const s = this.slug.trim();
|
||||
if (s === '') { this.status = ''; return; }
|
||||
if (!/^[a-z0-9][a-z0-9-]{1,18}[a-z0-9]$/.test(s)) { this.status = 'invalid'; return; }
|
||||
this.status = 'checking';
|
||||
this.timer = setTimeout(() => {
|
||||
fetch(this.checkUrl + '?code=' + encodeURIComponent(s))
|
||||
.then(r => r.json())
|
||||
.then(d => { this.status = d.available ? 'available' : 'taken'; })
|
||||
.catch(() => { this.status = ''; });
|
||||
}, 400);
|
||||
},
|
||||
}">
|
||||
<label class="block text-xs font-semibold text-slate-600">Custom link <span class="font-normal text-slate-400">(optional)</span></label>
|
||||
<div class="mt-1.5 flex items-stretch rounded-xl border focus-within:border-indigo-400 focus-within:ring-1 focus-within:ring-indigo-400/30"
|
||||
:class="status === 'available' ? 'border-green-400' : status === 'taken' || status === 'invalid' ? 'border-red-400' : 'border-slate-200'">
|
||||
<span class="flex items-center rounded-l-xl border-r border-slate-200 bg-slate-50 px-3 text-xs text-slate-400 whitespace-nowrap select-none"
|
||||
:class="status === 'available' ? 'border-green-400' : status === 'taken' || status === 'invalid' ? 'border-red-400' : 'border-slate-200'">
|
||||
{{ \App\Models\QrCode::publicBaseUrl() }}/q/
|
||||
</span>
|
||||
<input type="text" name="custom_short_code"
|
||||
x-model="slug"
|
||||
@input="check()"
|
||||
maxlength="20"
|
||||
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
|
||||
class="min-w-0 flex-1 rounded-r-xl bg-white px-3 py-2.5 text-sm placeholder:text-slate-400 focus:outline-none"
|
||||
placeholder="my-brand">
|
||||
</div>
|
||||
<div class="mt-1.5 flex items-center gap-1.5 text-[11px]">
|
||||
<template x-if="status === 'checking'"><span class="text-slate-400">Checking…</span></template>
|
||||
<template x-if="status === 'available'">
|
||||
<span class="flex items-center gap-1 text-green-600">
|
||||
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5"/></svg>
|
||||
Available
|
||||
</span>
|
||||
</template>
|
||||
<template x-if="status === 'taken'"><span class="text-red-500">Already taken</span></template>
|
||||
<template x-if="status === 'invalid'"><span class="text-red-500">Invalid format</span></template>
|
||||
<template x-if="status === ''"><span class="text-slate-400">Leave blank to auto-generate</span></template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
@include('qr-codes.partials.type-fields-create')
|
||||
</div>
|
||||
|
||||
@unless($isProgramme)
|
||||
<p class="rounded-xl bg-slate-50 px-3.5 py-2.5 text-xs leading-5 text-slate-400">
|
||||
You will design the QR code in the next step. Details can be updated anytime without reprinting.
|
||||
</p>
|
||||
@else
|
||||
<p class="rounded-xl bg-slate-50 px-3.5 py-2.5 text-xs leading-5 text-slate-400">
|
||||
Attendees scan your Ladill event link — update details anytime without reprinting the QR.
|
||||
</p>
|
||||
@endunless
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Step 2: QR customization (events) or inline (programmes) --}}
|
||||
<div x-show="!wizard || step === 2">
|
||||
@include('qr-codes.partials.customization-fields', [
|
||||
'style' => $defaultStyle,
|
||||
'moduleStyles' => $moduleStyles,
|
||||
'cornerOuterStyles' => $cornerOuterStyles,
|
||||
'cornerInnerStyles' => $cornerInnerStyles,
|
||||
'frameStyles' => $frameStyles,
|
||||
])
|
||||
</div>
|
||||
|
||||
{{-- Step 3: Review & create (events only) --}}
|
||||
<div x-show="wizard && step === 3" x-cloak class="space-y-5">
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200/80 bg-white shadow-sm">
|
||||
<div class="border-b border-slate-100 bg-slate-50/70 px-5 py-4">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Review your event</h2>
|
||||
<p class="mt-0.5 text-xs text-slate-400">Confirm details before publishing. GHS {{ number_format($pricePerQr, 2) }} will be charged from your QR balance.</p>
|
||||
</div>
|
||||
<div class="space-y-3 p-5 text-sm">
|
||||
<div class="flex justify-between gap-4 border-b border-slate-100 pb-3">
|
||||
<span class="text-slate-500">Event</span>
|
||||
<span class="font-semibold text-slate-900 text-right" x-text="reviewEventName()"></span>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4 border-b border-slate-100 pb-3">
|
||||
<span class="text-slate-500">Admin label</span>
|
||||
<span class="font-semibold text-slate-900 text-right" x-text="reviewLabel()"></span>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<span class="text-slate-500">QR balance after</span>
|
||||
<span class="font-semibold text-slate-900">GHS <span x-text="Math.max(0, balance - price).toFixed(2)"></span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lg:hidden">
|
||||
@include('qr-codes.partials.qr-preview-card', ['showDownloads' => false])
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Desktop navigation --}}
|
||||
<div class="hidden gap-3 lg:flex">
|
||||
<button type="button" x-show="wizard && step > 1" x-cloak @click="prevStep()"
|
||||
class="rounded-xl border border-slate-200 px-5 py-3 text-sm font-semibold text-slate-600 hover:bg-slate-50 transition">
|
||||
Back
|
||||
</button>
|
||||
<button type="button" x-show="wizard && step < 3" x-cloak @click="nextStep()"
|
||||
class="btn-primary flex-1">
|
||||
Continue
|
||||
</button>
|
||||
<button type="button" x-show="!wizard || step === 3" @click="wizardSubmit($event)"
|
||||
class="btn-primary flex-1">
|
||||
{{ $isProgramme ? 'Create programme' : 'Create event' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{-- Mobile sticky action bar --}}
|
||||
<div x-show="!showPreviewModal"
|
||||
class="mobile-action-bar lg:hidden">
|
||||
<div class="mobile-action-bar__toolbar">
|
||||
<button type="button" x-show="wizard && step > 1" x-cloak @click="prevStep()"
|
||||
class="rounded-xl border border-slate-200 px-4 py-2.5 text-sm font-semibold text-slate-600">
|
||||
Back
|
||||
</button>
|
||||
<button type="button" x-show="wizard && (step === 2 || step === 3)" @click="showPreviewModal = true"
|
||||
class="flex flex-1 items-center justify-center gap-2 rounded-xl border border-slate-200 bg-slate-50 py-2.5 text-sm font-semibold text-slate-700 transition hover:bg-slate-100">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/>
|
||||
</svg>
|
||||
Preview
|
||||
</button>
|
||||
<button type="button" x-show="wizard && step < 3" x-cloak @click="nextStep()"
|
||||
class="btn-primary flex-1">
|
||||
Continue
|
||||
</button>
|
||||
<button type="button" x-show="!wizard || step === 3" @click="wizardSubmit($event)"
|
||||
class="btn-primary flex-1">
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
<div class="mobile-action-bar__inset-fill" aria-hidden="true"></div>
|
||||
</div>
|
||||
|
||||
{{-- Preview modal --}}
|
||||
<div x-show="showPreviewModal" x-cloak
|
||||
x-transition:enter="transition-opacity duration-200"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition-opacity duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
@keydown.escape.window="showPreviewModal = false"
|
||||
class="fixed inset-0 z-[60] flex flex-col bg-black/60 backdrop-blur-sm lg:hidden"
|
||||
style="padding-bottom: env(safe-area-inset-bottom, 0px)">
|
||||
<div class="flex items-center justify-between px-5 py-4">
|
||||
<span class="text-sm font-semibold text-white">Preview</span>
|
||||
<button type="button" @click="showPreviewModal = false"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-white/20 text-white hover:bg-white/30 transition">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-1 items-center justify-center p-8">
|
||||
<div class="w-full max-w-xs overflow-hidden rounded-2xl bg-white shadow-2xl"
|
||||
:style="frameStyle === 'thin'
|
||||
? 'box-shadow: 0 0 0 2px ' + frameColor + ', 0 25px 50px -12px rgb(0 0 0 / 0.25)'
|
||||
: 'box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25), 0 0 0 1px rgb(0 0 0 / 0.06)'">
|
||||
<div :class="{
|
||||
'p-[8px]': frameStyle === 'thin',
|
||||
'p-[14px]': frameStyle === 'scan_me' || frameStyle === 'tap_to_scan',
|
||||
'p-4': frameStyle === 'none',
|
||||
}">
|
||||
<div x-ref="qrPreviewModal"
|
||||
class="aspect-square w-full [&>svg]:block [&>svg]:h-full [&>svg]:w-full"></div>
|
||||
<div x-show="frameStyle === 'scan_me'" x-cloak
|
||||
:style="'border-top: 1px solid ' + frameColor + '40; color: ' + frameColor"
|
||||
class="pt-2.5 pb-0.5 text-center">
|
||||
<span x-text="(frameText && frameText.trim() ? frameText : 'SCAN ME').toUpperCase()"
|
||||
class="text-[11px] font-bold tracking-[0.18em]"></span>
|
||||
</div>
|
||||
<div x-show="frameStyle === 'tap_to_scan'" x-cloak class="mt-2.5 flex justify-center pb-0.5">
|
||||
<span x-text="(frameText && frameText.trim() ? frameText : 'TAP TO SCAN').toUpperCase()"
|
||||
:style="'background-color: ' + frameColor + '; color: ' + frameTextColor"
|
||||
class="rounded-full px-5 py-1.5 text-[10px] font-bold tracking-widest"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</x-user-layout>
|
||||
@@ -1,86 +0,0 @@
|
||||
<x-user-layout>
|
||||
<x-slot name="title">Events</x-slot>
|
||||
|
||||
<div class="space-y-6"
|
||||
x-data="balanceGate({
|
||||
balance: @js((float) $wallet->spendableBalance()),
|
||||
price: @js((float) $pricePerQr),
|
||||
topupUrl: @js($topupUrl),
|
||||
href: @js(route('events.create')),
|
||||
})">
|
||||
@foreach(['success', 'error'] as $flash)
|
||||
@if(session($flash))
|
||||
<div class="rounded-lg border px-4 py-3 {{ $flash === 'success' ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-red-200 bg-red-50 text-red-700' }}">
|
||||
<p class="text-sm">{{ session($flash) }}</p>
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
<div class="relative overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-violet-50/80 via-white to-indigo-50/60"></div>
|
||||
<div class="relative px-6 py-8 sm:px-10">
|
||||
<div class="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="max-w-xl">
|
||||
<div class="inline-flex items-center gap-1.5 rounded-full bg-violet-100 px-3 py-1 text-xs font-semibold text-violet-700">
|
||||
Tickets · Contributions · Free registration
|
||||
</div>
|
||||
<h1 class="mt-3 text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">Your events</h1>
|
||||
<p class="mt-2 text-sm leading-6 text-slate-600">
|
||||
Create ticketed events, manage attendees, print badges, and attach programme outlines.
|
||||
</p>
|
||||
<div class="mt-6">
|
||||
<button type="button"
|
||||
@click="goOrTopup($event)"
|
||||
class="btn-primary">
|
||||
<svg class="h-4 w-4 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg>
|
||||
Create event
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scrollbar-hide flex snap-x snap-mandatory gap-3 overflow-x-auto pb-2 sm:grid sm:grid-cols-3 sm:overflow-visible sm:pb-0 lg:gap-4" style="-ms-overflow-style:none;scrollbar-width:none;">
|
||||
<div class="mobile-stats-card shrink-0 snap-start rounded-xl border border-slate-200 bg-white/90 px-4 py-3 text-center backdrop-blur-sm">
|
||||
<p class="whitespace-nowrap text-xl font-bold text-slate-900 sm:text-2xl">GHS {{ number_format($wallet->spendableBalance(), 2) }}</p>
|
||||
<p class="mt-0.5 whitespace-nowrap text-[11px] font-medium text-slate-500">Account balance</p>
|
||||
</div>
|
||||
<div class="mobile-stats-card shrink-0 snap-start rounded-xl border border-slate-200 bg-white/90 px-4 py-3 text-center backdrop-blur-sm">
|
||||
<p class="whitespace-nowrap text-xl font-bold text-slate-900 sm:text-2xl">{{ $qrCodes->count() }}</p>
|
||||
<p class="mt-0.5 whitespace-nowrap text-[11px] font-medium text-slate-500">Events</p>
|
||||
</div>
|
||||
<div class="mobile-stats-card shrink-0 snap-start rounded-xl border border-slate-200 bg-white/90 px-4 py-3 text-center backdrop-blur-sm">
|
||||
<p class="whitespace-nowrap text-xl font-bold text-slate-900 sm:text-2xl">{{ number_format($totalRegistrations) }}</p>
|
||||
<p class="mt-0.5 whitespace-nowrap text-[11px] font-medium text-slate-500">Registrations</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white overflow-hidden">
|
||||
<div class="border-b border-slate-100 px-6 py-4">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Your events</h2>
|
||||
</div>
|
||||
@if($qrCodes->isEmpty())
|
||||
<p class="px-6 py-10 text-center text-sm text-slate-500">No events yet. Create your first event to get started.</p>
|
||||
@else
|
||||
<div class="divide-y divide-slate-100">
|
||||
@foreach($qrCodes as $code)
|
||||
<a href="{{ route('events.show', $code) }}" class="flex items-center gap-4 px-6 py-4 transition hover:bg-slate-50">
|
||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg {{ $code->is_active ? 'bg-violet-100' : 'bg-slate-100 opacity-50' }}">
|
||||
<img src="{{ asset('images/ladill-icons/events.svg') }}?v={{ @filemtime(public_path('images/ladill-icons/events.svg')) ?: '1' }}" alt="" class="h-5 w-5 object-contain" width="20" height="20">
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-semibold text-slate-900">{{ $code->label }}</p>
|
||||
<p class="truncate text-xs text-slate-500">{{ $code->typeLabel() }} · {{ $code->publicUrl() }}</p>
|
||||
</div>
|
||||
<div class="text-right text-xs text-slate-500">
|
||||
<p class="font-semibold text-slate-900">{{ number_format($code->registrations_count) }} registered</p>
|
||||
<p>{{ $code->is_active ? 'Active' : 'Paused' }}</p>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</x-user-layout>
|
||||
@@ -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 ──────────────────────────────────────────── --}}
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200/80 bg-white shadow-sm">
|
||||
<div class="border-b border-slate-100 bg-slate-50/70 px-5 py-3.5">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.1em] text-slate-400">Quick styles</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 gap-px bg-slate-100/80">
|
||||
@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
|
||||
<button type="button"
|
||||
@click="setStylePreset('{{ $preset['key'] }}')"
|
||||
class="group flex flex-col items-center gap-2.5 bg-white px-3 py-4 text-center transition hover:bg-slate-50/80 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-400">
|
||||
|
||||
{{-- Mini QR illustration --}}
|
||||
<span class="relative flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-lg"
|
||||
style="background-color: {{ $preset['bg'] }}">
|
||||
|
||||
{{-- Top-left finder --}}
|
||||
<span class="absolute left-[4px] top-[4px] flex h-[14px] w-[14px] items-center justify-center"
|
||||
style="border: 2.5px solid {{ $preset['fg'] }}; border-radius: {{ $outerRadius }}; background: transparent">
|
||||
<span class="block h-[5px] w-[5px]"
|
||||
style="background: {{ $preset['fg'] }}; border-radius: {{ $innerRadius }}"></span>
|
||||
</span>
|
||||
|
||||
{{-- Top-right finder --}}
|
||||
<span class="absolute right-[4px] top-[4px] flex h-[14px] w-[14px] items-center justify-center"
|
||||
style="border: 2.5px solid {{ $preset['fg'] }}; border-radius: {{ $outerRadius }}; background: transparent">
|
||||
<span class="block h-[5px] w-[5px]"
|
||||
style="background: {{ $preset['fg'] }}; border-radius: {{ $innerRadius }}"></span>
|
||||
</span>
|
||||
|
||||
{{-- Bottom-left finder --}}
|
||||
<span class="absolute bottom-[4px] left-[4px] flex h-[14px] w-[14px] items-center justify-center"
|
||||
style="border: 2.5px solid {{ $preset['fg'] }}; border-radius: {{ $outerRadius }}; background: transparent">
|
||||
<span class="block h-[5px] w-[5px]"
|
||||
style="background: {{ $preset['fg'] }}; border-radius: {{ $innerRadius }}"></span>
|
||||
</span>
|
||||
|
||||
{{-- Data dots (5×3 grid, avoiding finder corners) --}}
|
||||
<span class="absolute inset-0 flex items-center justify-center">
|
||||
<span class="grid grid-cols-3 gap-[2px] translate-x-[5px]">
|
||||
@foreach([1,0,1,0,1,0,1,1,0] as $bit)
|
||||
<span class="h-[3px] w-[3px]"
|
||||
style="{{ $bit ? 'background:' . $preset['fg'] . ';border-radius:' . $dotRadius : '' }}"></span>
|
||||
@endforeach
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span class="text-[11px] font-semibold text-slate-600 group-hover:text-slate-900">{{ $preset['label'] }}</span>
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200/80 bg-white shadow-sm">
|
||||
|
||||
{{-- Pill tab nav --}}
|
||||
<div class="border-b border-slate-100 bg-slate-50/70 px-4 py-3">
|
||||
<div class="flex gap-1 rounded-xl bg-slate-200/50 p-1">
|
||||
@foreach([
|
||||
['id' => 'body', 'label' => 'Body'],
|
||||
['id' => 'colors', 'label' => 'Colors'],
|
||||
['id' => 'eyes', 'label' => 'Eyes'],
|
||||
['id' => 'logo', 'label' => 'Logo'],
|
||||
['id' => 'frame', 'label' => 'Frame'],
|
||||
] as $tab)
|
||||
<button type="button"
|
||||
@click="openSection = '{{ $tab['id'] }}'"
|
||||
:class="openSection === '{{ $tab['id'] }}'
|
||||
? 'bg-white shadow-sm text-slate-900'
|
||||
: 'text-slate-500 hover:text-slate-700'"
|
||||
class="flex-1 rounded-lg py-1.5 text-[11px] font-semibold transition-all duration-150">
|
||||
{{ $tab['label'] }}
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-5">
|
||||
|
||||
{{-- ── Body ─────────────────────────────────────────────────────── --}}
|
||||
<div x-show="openSection === 'body'" x-cloak
|
||||
x-transition:enter="transition-opacity duration-150"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100">
|
||||
<p class="mb-3 text-[10px] font-semibold uppercase tracking-[0.1em] text-slate-400">Dot shape</p>
|
||||
<div class="grid grid-cols-2 gap-2.5">
|
||||
@foreach($moduleStyles as $key => $meta)
|
||||
<label class="group cursor-pointer">
|
||||
<input type="radio" name="style[module_style]" value="{{ $key }}" x-model="moduleStyle" class="sr-only">
|
||||
<span class="flex items-center gap-3 rounded-xl border-2 bg-white px-3.5 py-3 transition-all"
|
||||
:class="moduleStyle === '{{ $key }}'
|
||||
? 'border-indigo-500 bg-indigo-50/60'
|
||||
: 'border-slate-200 group-hover:border-slate-300'">
|
||||
<span class="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-slate-50 ring-1 ring-slate-200/60">
|
||||
@if ($key === 'square')
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-slate-800" fill="currentColor">
|
||||
<path d="M0,0V11H11V0H0ZM7,7h-3v-3h3v3ZM13,0V11h11V0H13Zm7,7h-3v-3h3v3ZM0,13v11H11V13H0Zm7,7h-3v-3h3v3Zm10-3h-4v-4h4v4Zm3,3h-3v-3h3v3Zm-3,4h-4v-4h4v4Zm7-7h-4v-4h4v4Z"/>
|
||||
</svg>
|
||||
@elseif ($key === 'dots')
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-slate-800" fill="currentColor">
|
||||
<path d="M7,0h-3C1.794,0,0,1.794,0,4v3c0,2.206,1.794,4,4,4h3c2.206,0,4-1.794,4-4V4C11,1.794,9.206,0,7,0Zm2,7c0,1.103-.897,2-2,2h-3c-1.103,0-2-.897-2-2V4c0-1.103,.897-2,2-2h3c1.103,0,2,.897,2,2v3Zm-2-2v1c0,.552-.448,1-1,1h-1c-.552,0-1-.448-1-1v-1c0-.552,.448-1,1-1h1c.552,0,1,.448,1,1Zm10,6h3c2.206,0,4-1.794,4-4V4C24,1.794,22.206,0,20,0h-3C14.794,0,13,1.794,13,4v3c0,2.206,1.794,4,4,4Zm-2-7c0-1.103,.897-2,2-2h3c1.103,0,2,.897,2,2v3c0,1.103-.897,2-2,2h-3c-1.103,0-2-.897-2-2V4Zm2,2v-1c0-.552,.448-1,1-1h1c.552,0,1,.448,1,1v1c0,.552-.448,1-1,1h-1c-.552,0-1-.448-1-1ZM7,13h-3c-2.206,0-4,1.794-4,4v3c0,2.206,1.794,4,4,4h3c2.206,0,4-1.794,4-4v-3c0-2.206-1.794-4-4-4Zm2,7c0,1.103-.897,2-2,2h-3c-1.103,0-2-.897-2-2v-3c0-1.103,.897-2,2-2h3c1.103,0,2,.897,2,2v3Zm-2-2v1c0,.552-.448,1-1,1h-1c-.552,0-1-.448-1-1v-1c0-.552,.448-1,1-1h1c.552,0,1,.448,1,1Zm10-3.5v1c0,.828-.672,1.5-1.5,1.5h-1c-.828,0-1.5-.672-1.5-1.5v-1c0-.828,.672-1.5,1.5-1.5h1c.828,0,1.5,.672,1.5,1.5Zm3,4h0c0,.828-.672,1.5-1.5,1.5h0c-.828,0-1.5-.672-1.5-1.5h0c0-.828,.672-1.5,1.5-1.5h0c.828,0,1.5,.672,1.5,1.5Zm-3,3v1c0,.828-.672,1.5-1.5,1.5h-1c-.828,0-1.5-.672-1.5-1.5v-1c0-.828,.672-1.5,1.5-1.5h1c.828,0,1.5,.672,1.5,1.5Zm7-7v1c0,.828-.672,1.5-1.5,1.5h-1c-.828,0-1.5-.672-1.5-1.5v-1c0-.828,.672-1.5,1.5-1.5h1c.828,0,1.5,.672,1.5,1.5Z"/>
|
||||
</svg>
|
||||
@else
|
||||
<span class="grid grid-cols-4 gap-[2px] p-1.5">
|
||||
@foreach($qrBits as $bit)
|
||||
<span class="block aspect-square {{ $bit ? ('bg-slate-800 ' . ($meta['circular'] ? 'rounded-full' : '')) : 'bg-transparent' }}"></span>
|
||||
@endforeach
|
||||
</span>
|
||||
@endif
|
||||
</span>
|
||||
<span class="text-xs font-semibold text-slate-700">{{ $meta['label'] }}</span>
|
||||
</span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Colors ───────────────────────────────────────────────────── --}}
|
||||
<div x-show="openSection === 'colors'" x-cloak
|
||||
x-transition:enter="transition-opacity duration-150"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
class="space-y-5">
|
||||
<div>
|
||||
<p class="mb-3 text-[10px] font-semibold uppercase tracking-[0.1em] text-slate-400">Quick presets</p>
|
||||
<div class="flex flex-wrap gap-2.5">
|
||||
@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)
|
||||
<button type="button"
|
||||
@click="fg = '{{ $preset['fg'] }}'; bg = '{{ $preset['bg'] }}'; schedulePreview()"
|
||||
title="{{ $preset['label'] }}"
|
||||
class="h-8 w-8 rounded-full border-2 border-white shadow ring-1 ring-slate-300/60 transition hover:scale-110 hover:ring-indigo-400"
|
||||
style="background-color: {{ $preset['fg'] }}"></button>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-1.5 block text-xs font-semibold text-slate-600">Foreground</label>
|
||||
<div class="flex items-center gap-2 rounded-xl border border-slate-200 bg-slate-50/80 p-2">
|
||||
<input type="color" name="style[foreground]" x-model="fg" @input="schedulePreview()"
|
||||
class="h-9 w-10 shrink-0 cursor-pointer rounded-lg border-0 bg-transparent p-0.5">
|
||||
<input type="text" x-model="fg" @change="schedulePreview()"
|
||||
class="min-w-0 flex-1 rounded-lg border border-slate-200 bg-white px-2.5 py-1.5 font-mono text-sm focus:border-indigo-400 focus:outline-none">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1.5 block text-xs font-semibold text-slate-600">Background</label>
|
||||
<div class="flex items-center gap-2 rounded-xl border border-slate-200 bg-slate-50/80 p-2">
|
||||
<input type="color" name="style[background]" x-model="bg" @input="schedulePreview()"
|
||||
class="h-9 w-10 shrink-0 cursor-pointer rounded-lg border-0 bg-transparent p-0.5">
|
||||
<input type="text" x-model="bg" @change="schedulePreview()"
|
||||
class="min-w-0 flex-1 rounded-lg border border-slate-200 bg-white px-2.5 py-1.5 font-mono text-sm focus:border-indigo-400 focus:outline-none">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="mb-3 text-[10px] font-semibold uppercase tracking-[0.1em] text-slate-400">Gradient</p>
|
||||
<input type="hidden" name="style[gradient_type]" :value="gradientType">
|
||||
<div class="flex gap-1 rounded-xl bg-slate-200/50 p-1">
|
||||
@foreach([['none', 'None'], ['linear', 'Linear'], ['radial', 'Radial']] as [$val, $lbl])
|
||||
<button type="button"
|
||||
@click="gradientType = '{{ $val }}'"
|
||||
:class="gradientType === '{{ $val }}' ? 'bg-white shadow-sm text-slate-900' : 'text-slate-500 hover:text-slate-700'"
|
||||
class="flex-1 rounded-lg py-1.5 text-[11px] font-semibold transition-all duration-150">
|
||||
{{ $lbl }}
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
<div x-show="gradientType !== 'none'" x-cloak class="mt-3 space-y-3">
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-1.5 block text-xs font-semibold text-slate-600">Start color</label>
|
||||
<div class="flex items-center gap-2 rounded-xl border border-slate-200 bg-slate-50/80 p-2">
|
||||
<input type="color" name="style[gradient_color1]" x-model="gradientColor1" @input="schedulePreview()"
|
||||
class="h-9 w-10 shrink-0 cursor-pointer rounded-lg border-0 bg-transparent p-0.5">
|
||||
<input type="text" x-model="gradientColor1" @change="schedulePreview()"
|
||||
class="min-w-0 flex-1 rounded-lg border border-slate-200 bg-white px-2.5 py-1.5 font-mono text-sm focus:border-indigo-400 focus:outline-none">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1.5 block text-xs font-semibold text-slate-600">End color</label>
|
||||
<div class="flex items-center gap-2 rounded-xl border border-slate-200 bg-slate-50/80 p-2">
|
||||
<input type="color" name="style[gradient_color2]" x-model="gradientColor2" @input="schedulePreview()"
|
||||
class="h-9 w-10 shrink-0 cursor-pointer rounded-lg border-0 bg-transparent p-0.5">
|
||||
<input type="text" x-model="gradientColor2" @change="schedulePreview()"
|
||||
class="min-w-0 flex-1 rounded-lg border border-slate-200 bg-white px-2.5 py-1.5 font-mono text-sm focus:border-indigo-400 focus:outline-none">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div x-show="gradientType === 'linear'" class="space-y-1">
|
||||
<label class="flex items-center justify-between text-xs font-semibold text-slate-600">
|
||||
<span>Rotation</span>
|
||||
<span x-text="gradientRotation + '°'" class="font-normal text-slate-400"></span>
|
||||
</label>
|
||||
<input type="range" name="style[gradient_rotation]" min="0" max="360" x-model.number="gradientRotation"
|
||||
class="w-full accent-indigo-600">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Eyes ─────────────────────────────────────────────────────── --}}
|
||||
<div x-show="openSection === 'eyes'" x-cloak
|
||||
x-transition:enter="transition-opacity duration-150"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
class="space-y-5">
|
||||
<div>
|
||||
<p class="mb-3 text-[10px] font-semibold uppercase tracking-[0.1em] text-slate-400">Outer frame</p>
|
||||
<div class="grid grid-cols-3 gap-2.5">
|
||||
@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)
|
||||
<label class="group cursor-pointer">
|
||||
<input type="radio" name="style[finder_outer]" value="{{ $opt['value'] }}" x-model="finderOuter" class="sr-only">
|
||||
<span class="flex flex-col items-center gap-2 rounded-xl border-2 bg-white p-3 transition-all"
|
||||
:class="finderOuter === '{{ $opt['value'] }}'
|
||||
? 'border-indigo-500 bg-indigo-50/60'
|
||||
: 'border-slate-200 group-hover:border-slate-300'">
|
||||
<span class="flex h-10 w-10 items-center justify-center rounded-lg bg-slate-50 ring-1 ring-slate-200/60">
|
||||
<span class="{{ $opt['shape'] }}"></span>
|
||||
</span>
|
||||
<span class="text-[11px] font-semibold text-slate-600">{{ $opt['label'] }}</span>
|
||||
</span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="mb-3 text-[10px] font-semibold uppercase tracking-[0.1em] text-slate-400">Inner dot</p>
|
||||
<div class="grid grid-cols-3 gap-2.5">
|
||||
@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)
|
||||
<label class="group cursor-pointer">
|
||||
<input type="radio" name="style[finder_inner]" value="{{ $opt['value'] }}" x-model="finderInner" class="sr-only">
|
||||
<span class="flex flex-col items-center gap-2 rounded-xl border-2 bg-white p-3 transition-all"
|
||||
:class="finderInner === '{{ $opt['value'] }}'
|
||||
? 'border-indigo-500 bg-indigo-50/60'
|
||||
: 'border-slate-200 group-hover:border-slate-300'">
|
||||
<span class="flex h-9 w-9 items-center justify-center rounded-lg bg-slate-50 ring-1 ring-slate-200/60">
|
||||
<span class="{{ $opt['shape'] }}"></span>
|
||||
</span>
|
||||
<span class="text-[11px] font-semibold text-slate-600">{{ $opt['label'] }}</span>
|
||||
</span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Logo ─────────────────────────────────────────────────────── --}}
|
||||
<div x-show="openSection === 'logo'" x-cloak
|
||||
x-transition:enter="transition-opacity duration-150"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
class="space-y-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-slate-700">Center logo</p>
|
||||
<p class="mt-0.5 text-xs leading-5 text-slate-400">Placed in the center. Error correction is raised automatically when a logo is present.</p>
|
||||
</div>
|
||||
<label class="flex cursor-pointer flex-col items-center gap-3 rounded-xl border-2 border-dashed border-slate-200 bg-slate-50/60 py-8 text-center transition hover:border-indigo-400 hover:bg-indigo-50/20">
|
||||
<span class="flex h-12 w-12 items-center justify-center rounded-full bg-white shadow-sm ring-1 ring-slate-200/60">
|
||||
<svg class="h-6 w-6 text-slate-400" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5"/>
|
||||
</svg>
|
||||
</span>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-slate-700">Click to upload</p>
|
||||
<p class="text-xs text-slate-400">PNG, JPG, GIF, WebP · max 2 MB</p>
|
||||
</div>
|
||||
<input type="file" name="logo" accept="image/png,image/jpeg,image/gif,image/webp" x-ref="logoInput" @change="onLogoChange()" class="sr-only">
|
||||
</label>
|
||||
|
||||
{{-- Logo options: shown when a logo is present (uploaded or existing) --}}
|
||||
<div x-show="hasExistingLogo || _rawLogoFile" x-cloak class="space-y-4 border-t border-slate-100 pt-4">
|
||||
|
||||
{{-- Size --}}
|
||||
<div>
|
||||
<label class="flex items-center justify-between text-xs font-semibold text-slate-600">
|
||||
<span>Logo size</span>
|
||||
<span x-text="Math.round(logoSize * 100) + '%'" class="font-normal text-slate-400"></span>
|
||||
</label>
|
||||
<input type="range" min="10" max="40" step="1"
|
||||
:value="Math.round(logoSize * 100)"
|
||||
@input="logoSize = parseFloat($event.target.value) / 100"
|
||||
class="mt-2 w-full accent-indigo-600">
|
||||
<input type="hidden" name="style[logo_size]" :value="logoSize">
|
||||
</div>
|
||||
|
||||
{{-- Padding --}}
|
||||
<div>
|
||||
<label class="flex items-center justify-between text-xs font-semibold text-slate-600">
|
||||
<span>Padding</span>
|
||||
<span x-text="logoMargin" class="font-normal text-slate-400"></span>
|
||||
</label>
|
||||
<input type="range" name="style[logo_margin]" min="0" max="15" x-model.number="logoMargin"
|
||||
class="mt-2 w-full accent-indigo-600">
|
||||
</div>
|
||||
|
||||
{{-- White background toggle --}}
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs font-semibold text-slate-600">White background</span>
|
||||
<button type="button" role="switch" :aria-checked="logoWhiteBg"
|
||||
@click="logoWhiteBg = !logoWhiteBg"
|
||||
:class="logoWhiteBg ? 'bg-indigo-500' : 'bg-slate-200'"
|
||||
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus:outline-none">
|
||||
<span :class="logoWhiteBg ? 'translate-x-5' : 'translate-x-1'"
|
||||
class="inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform"></span>
|
||||
</button>
|
||||
<input type="hidden" name="style[logo_white_bg]" :value="logoWhiteBg ? '1' : '0'">
|
||||
</div>
|
||||
|
||||
{{-- Shape --}}
|
||||
<div>
|
||||
<p class="mb-2 text-[10px] font-semibold uppercase tracking-[0.1em] text-slate-400">Shape</p>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
{{-- Original: landscape rectangle — logo keeps its natural aspect ratio --}}
|
||||
<button type="button"
|
||||
@click="logoShape = 'none'"
|
||||
:class="logoShape === 'none' ? 'border-indigo-500 bg-indigo-50/60' : 'border-slate-200 hover:border-slate-300'"
|
||||
class="flex flex-col items-center gap-2 rounded-xl border-2 bg-white p-3 transition-all">
|
||||
<span class="flex h-9 w-9 items-center justify-center rounded-lg bg-slate-50 ring-1 ring-slate-200/60">
|
||||
<span class="h-4 w-7 rounded-[2px] bg-slate-800"></span>
|
||||
</span>
|
||||
<span class="text-[11px] font-semibold text-slate-600">Original</span>
|
||||
</button>
|
||||
{{-- Rounded: square crop with rounded corners --}}
|
||||
<button type="button"
|
||||
@click="logoShape = 'rounded'"
|
||||
:class="logoShape === 'rounded' ? 'border-indigo-500 bg-indigo-50/60' : 'border-slate-200 hover:border-slate-300'"
|
||||
class="flex flex-col items-center gap-2 rounded-xl border-2 bg-white p-3 transition-all">
|
||||
<span class="flex h-9 w-9 items-center justify-center rounded-lg bg-slate-50 ring-1 ring-slate-200/60">
|
||||
<span class="h-7 w-7 rounded-xl bg-slate-800"></span>
|
||||
</span>
|
||||
<span class="text-[11px] font-semibold text-slate-600">Rounded</span>
|
||||
</button>
|
||||
{{-- Circle: square crop clipped to circle --}}
|
||||
<button type="button"
|
||||
@click="logoShape = 'circle'"
|
||||
:class="logoShape === 'circle' ? 'border-indigo-500 bg-indigo-50/60' : 'border-slate-200 hover:border-slate-300'"
|
||||
class="flex flex-col items-center gap-2 rounded-xl border-2 bg-white p-3 transition-all">
|
||||
<span class="flex h-9 w-9 items-center justify-center rounded-lg bg-slate-50 ring-1 ring-slate-200/60">
|
||||
<span class="h-7 w-7 rounded-full bg-slate-800"></span>
|
||||
</span>
|
||||
<span class="text-[11px] font-semibold text-slate-600">Circle</span>
|
||||
</button>
|
||||
</div>
|
||||
<input type="hidden" name="style[logo_shape]" :value="logoShape">
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@if(!empty($showRemoveLogo))
|
||||
<label class="flex items-center gap-2.5 text-sm text-slate-600">
|
||||
<input type="checkbox" name="remove_logo" value="1" x-model="removeLogo" @change="schedulePreview()"
|
||||
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
|
||||
Remove current logo
|
||||
</label>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- ── Frame ────────────────────────────────────────────────────── --}}
|
||||
<div x-show="openSection === 'frame'" x-cloak
|
||||
x-transition:enter="transition-opacity duration-150"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
class="space-y-5">
|
||||
<div>
|
||||
<p class="mb-3 text-[10px] font-semibold uppercase tracking-[0.1em] text-slate-400">Frame style</p>
|
||||
<div class="grid grid-cols-2 gap-2.5">
|
||||
@foreach($frameStyles as $key => $meta)
|
||||
<label class="group cursor-pointer">
|
||||
<input type="radio" name="style[frame_style]" value="{{ $key }}" x-model="frameStyle" class="sr-only">
|
||||
<span class="flex items-center gap-3 rounded-xl border-2 bg-white p-3 transition-all"
|
||||
:class="frameStyle === '{{ $key }}'
|
||||
? 'border-indigo-500 bg-indigo-50/60'
|
||||
: 'border-slate-200 group-hover:border-slate-300'">
|
||||
<span class="flex h-10 w-9 shrink-0 items-end justify-center overflow-hidden rounded-lg border border-slate-200 bg-white p-1 shadow-sm">
|
||||
@if($key === 'none')
|
||||
<span class="h-8 w-8 rounded-sm bg-slate-200 opacity-40"></span>
|
||||
@elseif($meta['mode'] === 'border')
|
||||
<span class="h-8 w-8 rounded-sm border-2 border-slate-500 bg-slate-100"></span>
|
||||
@elseif($meta['mode'] === 'label')
|
||||
<span class="flex h-9 w-8 flex-col items-stretch gap-0.5">
|
||||
<span class="flex-1 rounded-sm border border-slate-300 bg-slate-100"></span>
|
||||
<span class="rounded-sm bg-slate-700 py-0.5 text-center text-[5px] font-bold leading-none text-white">SCAN</span>
|
||||
</span>
|
||||
@else
|
||||
<span class="flex h-9 w-8 flex-col items-stretch gap-0.5">
|
||||
<span class="flex-1 rounded-sm border border-slate-300 bg-slate-100"></span>
|
||||
<span class="rounded-full bg-slate-700 py-0.5 text-center text-[5px] font-bold leading-none text-white">TAP</span>
|
||||
</span>
|
||||
@endif
|
||||
</span>
|
||||
<span class="min-w-0">
|
||||
<span class="block text-xs font-semibold text-slate-800">{{ $meta['label'] }}</span>
|
||||
<span class="block text-[10px] leading-4 text-slate-400">{{ $meta['description'] }}</span>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Frame colour --}}
|
||||
<div x-show="frameStyle !== 'none'" x-cloak class="space-y-1">
|
||||
<label class="block text-xs font-semibold text-slate-600">Frame colour</label>
|
||||
<div class="flex gap-2">
|
||||
<input type="color" name="style[frame_color]" x-model="frameColor" @input="schedulePreview()"
|
||||
class="h-9 w-9 shrink-0 cursor-pointer rounded-lg border border-slate-200 bg-white p-0.5">
|
||||
<input type="text" x-model="frameColor" @change="schedulePreview()" maxlength="7"
|
||||
class="flex-1 rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Frame text -- only for frames that show a CTA label --}}
|
||||
<div x-show="frameStyle === 'scan_me' || frameStyle === 'tap_to_scan'" x-cloak class="space-y-1">
|
||||
<label class="flex items-center justify-between text-xs font-semibold text-slate-600">
|
||||
<span>Label text</span>
|
||||
<span class="font-normal text-slate-400" x-text="frameStyle === 'tap_to_scan' ? 'Default: TAP TO SCAN' : 'Default: SCAN ME'"></span>
|
||||
</label>
|
||||
<input type="text" name="style[frame_text]" x-model="frameText"
|
||||
maxlength="100"
|
||||
:placeholder="frameStyle === 'tap_to_scan' ? 'TAP TO SCAN' : 'SCAN ME'"
|
||||
class="w-full rounded-xl border border-slate-200 bg-white px-3.5 py-2.5 text-sm focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 border-t border-slate-100 pt-4">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-600">Error correction</label>
|
||||
<select name="style[error_correction]" x-model="ecc"
|
||||
class="mt-1.5 w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm focus:border-indigo-400 focus:outline-none">
|
||||
<option value="L">Low — 7% recovery</option>
|
||||
<option value="M">Medium — 15% recovery</option>
|
||||
<option value="Q">Quartile — 25% recovery</option>
|
||||
<option value="H">High — 30% recovery</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="flex items-center justify-between text-xs font-semibold text-slate-600">
|
||||
<span>Output scale</span>
|
||||
<span x-text="scale + '×'" class="font-normal text-slate-400"></span>
|
||||
</label>
|
||||
<input type="range" name="style[scale]" min="4" max="16" x-model.number="scale"
|
||||
class="mt-2 w-full accent-indigo-600">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="flex items-center justify-between text-xs font-semibold text-slate-600">
|
||||
<span>Quiet zone</span>
|
||||
<span x-text="margin + ' modules'" class="font-normal text-slate-400"></span>
|
||||
</label>
|
||||
<input type="range" name="style[margin]" min="0" max="10" x-model.number="margin"
|
||||
class="mt-2 w-full accent-indigo-600">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,669 +0,0 @@
|
||||
@once('qr-customizer-alpine')
|
||||
<script>
|
||||
(function () {
|
||||
const registerQrCustomizer = () => {
|
||||
Alpine.data('qrCustomizer', (config) => ({
|
||||
previewUrl: config.previewUrl,
|
||||
shortCode: config.shortCode || 'preview',
|
||||
qrData: config.qrData || 'https://ladill.com',
|
||||
existingLogoPath: config.existingLogoPath || null,
|
||||
hasExistingLogo: Boolean(config.hasExistingLogo),
|
||||
removeLogo: false,
|
||||
previewLoading: false,
|
||||
previewTimer: null,
|
||||
showPreviewModal: false,
|
||||
openSection: config.openSection || 'body',
|
||||
balance: config.balance ?? null,
|
||||
price: config.price ?? null,
|
||||
topupModalId: config.topupModalId ?? null,
|
||||
topupUrl: config.topupUrl ?? null,
|
||||
canonicalSyncUrl: config.canonicalSyncUrl ?? null,
|
||||
csrf: config.csrf ?? null,
|
||||
_canonicalSynced: false,
|
||||
type: config.type ?? 'url',
|
||||
wizard: Boolean(config.wizard),
|
||||
step: config.wizard ? 1 : 0,
|
||||
fg: config.style.foreground,
|
||||
bg: config.style.background,
|
||||
ecc: config.style.error_correction,
|
||||
margin: Number(config.style.margin),
|
||||
moduleStyle: config.style.module_style,
|
||||
finderOuter: config.style.finder_outer,
|
||||
finderInner: config.style.finder_inner,
|
||||
frameStyle: config.style.frame_style,
|
||||
frameText: config.style.frame_text || '',
|
||||
frameColor: config.style.frame_color || '#000000',
|
||||
scale: Number(config.style.scale),
|
||||
gradientType: config.style.gradient_type || 'none',
|
||||
gradientColor1: config.style.gradient_color1 || '#000000',
|
||||
gradientColor2: config.style.gradient_color2 || '#7c3aed',
|
||||
gradientRotation: Number(config.style.gradient_rotation ?? 45),
|
||||
logoSize: Number(config.style.logo_size ?? 0.3),
|
||||
logoMargin: Number(config.style.logo_margin ?? 5),
|
||||
logoWhiteBg: Boolean(config.style.logo_white_bg),
|
||||
logoShape: config.style.logo_shape || 'none',
|
||||
_rawLogoFile: null,
|
||||
_processedLogoUrl: null,
|
||||
init() {
|
||||
[
|
||||
'fg', 'bg', 'ecc', 'margin', 'moduleStyle',
|
||||
'finderOuter', 'finderInner', 'frameStyle', 'frameText', 'frameColor', 'scale',
|
||||
'gradientType', 'gradientColor1', 'gradientColor2', 'gradientRotation',
|
||||
'logoSize', 'logoMargin',
|
||||
].forEach((field) => {
|
||||
this.$watch(field, () => this.schedulePreview());
|
||||
});
|
||||
|
||||
// These require canvas re-processing before preview
|
||||
['logoWhiteBg', 'logoShape'].forEach((field) => {
|
||||
this.$watch(field, () => this._reprocessLogo());
|
||||
});
|
||||
|
||||
this.schedulePreview();
|
||||
|
||||
if (config.existingLogoUrl) {
|
||||
this._loadExistingLogo(config.existingLogoUrl);
|
||||
}
|
||||
|
||||
this.$watch('showPreviewModal', (val) => {
|
||||
if (val) this.schedulePreview();
|
||||
});
|
||||
},
|
||||
toggleSection(id) {
|
||||
this.openSection = this.openSection === id ? null : id;
|
||||
},
|
||||
schedulePreview() {
|
||||
clearTimeout(this.previewTimer);
|
||||
this.previewTimer = setTimeout(() => this.refreshPreview(), 200);
|
||||
},
|
||||
async onLogoChange() {
|
||||
const input = this.$refs.logoInput;
|
||||
this._rawLogoFile = input?.files?.[0] || null;
|
||||
if (this._rawLogoFile) {
|
||||
await this._reprocessLogo();
|
||||
} else {
|
||||
this._processedLogoUrl = null;
|
||||
this.schedulePreview();
|
||||
}
|
||||
},
|
||||
async _reprocessLogo() {
|
||||
if (!this._rawLogoFile) return;
|
||||
const result = await this._processLogoImage(this._rawLogoFile);
|
||||
if (result) {
|
||||
this._processedLogoUrl = result;
|
||||
this.schedulePreview();
|
||||
}
|
||||
},
|
||||
// Load existing logo from data URI (edit view) and apply canvas processing
|
||||
_loadExistingLogo(dataUri) {
|
||||
fetch(dataUri)
|
||||
.then(r => r.blob())
|
||||
.then(blob => {
|
||||
this._rawLogoFile = new File([blob], 'logo', { type: blob.type || 'image/png' });
|
||||
return this._processLogoImage(this._rawLogoFile);
|
||||
})
|
||||
.then(result => {
|
||||
if (result) {
|
||||
this._processedLogoUrl = result;
|
||||
this.schedulePreview();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
this._processedLogoUrl = dataUri;
|
||||
this.schedulePreview();
|
||||
});
|
||||
},
|
||||
// Apply shape clipping and/or white background to logo via canvas.
|
||||
// For circle/rounded shapes a center-square crop is used so the shape looks balanced.
|
||||
// For no-shape logos the natural aspect ratio is preserved.
|
||||
_processLogoImage(file) {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
const raw = URL.createObjectURL(file);
|
||||
img.onload = () => {
|
||||
const needsSquareCrop = this.logoShape !== 'none';
|
||||
const sw = img.naturalWidth;
|
||||
const sh = img.naturalHeight;
|
||||
|
||||
let cw, ch, sx, sy, sSize;
|
||||
if (needsSquareCrop) {
|
||||
// Crop to center square for circle/rounded shapes
|
||||
sSize = Math.min(sw, sh);
|
||||
sx = (sw - sSize) / 2;
|
||||
sy = (sh - sSize) / 2;
|
||||
cw = ch = 300;
|
||||
} else {
|
||||
// Preserve natural aspect ratio
|
||||
const MAX = 600;
|
||||
const scale = Math.min(1, MAX / Math.max(sw, sh));
|
||||
cw = Math.round(sw * scale);
|
||||
ch = Math.round(sh * scale);
|
||||
sx = sy = 0;
|
||||
sSize = null;
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = cw;
|
||||
canvas.height = ch;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (needsSquareCrop) {
|
||||
ctx.beginPath();
|
||||
if (this.logoShape === 'circle') {
|
||||
ctx.arc(cw / 2, ch / 2, cw / 2, 0, Math.PI * 2);
|
||||
} else {
|
||||
// Rounded — 20% corner radius
|
||||
const r = cw * 0.2;
|
||||
ctx.moveTo(r, 0);
|
||||
ctx.lineTo(cw - r, 0);
|
||||
ctx.arcTo(cw, 0, cw, r, r);
|
||||
ctx.lineTo(cw, ch - r);
|
||||
ctx.arcTo(cw, ch, cw - r, ch, r);
|
||||
ctx.lineTo(r, ch);
|
||||
ctx.arcTo(0, ch, 0, ch - r, r);
|
||||
ctx.lineTo(0, r);
|
||||
ctx.arcTo(0, 0, r, 0, r);
|
||||
ctx.closePath();
|
||||
}
|
||||
ctx.clip();
|
||||
}
|
||||
|
||||
if (this.logoWhiteBg) {
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, cw, ch);
|
||||
}
|
||||
|
||||
if (sSize !== null) {
|
||||
ctx.drawImage(img, sx, sy, sSize, sSize, 0, 0, cw, ch);
|
||||
} else {
|
||||
ctx.drawImage(img, 0, 0, cw, ch);
|
||||
}
|
||||
|
||||
URL.revokeObjectURL(raw);
|
||||
resolve(canvas.toDataURL('image/png'));
|
||||
};
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(raw);
|
||||
resolve(null);
|
||||
};
|
||||
img.src = raw;
|
||||
});
|
||||
},
|
||||
setStylePreset(preset) {
|
||||
const presets = {
|
||||
classic: {
|
||||
fg: '#000000', bg: '#ffffff', moduleStyle: 'square',
|
||||
finderOuter: 'square', finderInner: 'square',
|
||||
frameStyle: 'none', ecc: 'M', margin: 4,
|
||||
gradientType: 'none',
|
||||
},
|
||||
rounded: {
|
||||
fg: '#111827', bg: '#ffffff', moduleStyle: 'square',
|
||||
finderOuter: 'rounded', finderInner: 'square',
|
||||
frameStyle: 'scan_me', ecc: 'Q', margin: 4,
|
||||
gradientType: 'none',
|
||||
},
|
||||
branded: {
|
||||
fg: '#1d4ed8', bg: '#ffffff', moduleStyle: 'square',
|
||||
finderOuter: 'rounded', finderInner: 'square',
|
||||
frameStyle: 'tap_to_scan', ecc: 'Q', margin: 4,
|
||||
gradientType: 'none',
|
||||
},
|
||||
dots: {
|
||||
fg: '#0f172a', bg: '#ffffff', moduleStyle: 'dots',
|
||||
finderOuter: 'square', finderInner: 'dot',
|
||||
frameStyle: 'scan_me', ecc: 'H', margin: 5,
|
||||
gradientType: 'none',
|
||||
},
|
||||
};
|
||||
Object.assign(this, presets[preset] || presets.classic);
|
||||
this.schedulePreview();
|
||||
},
|
||||
setEyePreset(preset) {
|
||||
const presets = {
|
||||
square: { finderOuter: 'square', finderInner: 'square' },
|
||||
rounded: { finderOuter: 'rounded', finderInner: 'square' },
|
||||
target: { finderOuter: 'circle', finderInner: 'dot' },
|
||||
};
|
||||
Object.assign(this, presets[preset] || presets.square);
|
||||
this.schedulePreview();
|
||||
},
|
||||
redirectTopup() {
|
||||
if (this.topupUrl) {
|
||||
window.location.href = this.topupUrl;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.topupModalId) {
|
||||
window.dispatchEvent(new CustomEvent('open-modal', {
|
||||
detail: this.topupModalId,
|
||||
bubbles: true,
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
checkBalance(event) {
|
||||
if (this.balance === null || this.price === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.balance <= 0 || this.balance < this.price) {
|
||||
event?.preventDefault();
|
||||
this.redirectTopup();
|
||||
}
|
||||
},
|
||||
nextStep(event) {
|
||||
if (!this.wizard) {
|
||||
return;
|
||||
}
|
||||
if (this.step === 1 && !this.validateWizardStep1()) {
|
||||
event?.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (this.step < 3) {
|
||||
this.step++;
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
},
|
||||
prevStep() {
|
||||
if (this.wizard && this.step > 1) {
|
||||
this.step--;
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
},
|
||||
validateWizardStep1() {
|
||||
const form = this.$refs.createForm;
|
||||
const label = form?.querySelector('[name=label]');
|
||||
if (!label?.value?.trim()) {
|
||||
label?.focus();
|
||||
label?.reportValidity?.();
|
||||
return false;
|
||||
}
|
||||
return label.checkValidity?.() !== false;
|
||||
},
|
||||
wizardSubmit(event) {
|
||||
if (this.wizard && this.step < 3) {
|
||||
event?.preventDefault();
|
||||
this.nextStep(event);
|
||||
return;
|
||||
}
|
||||
this.submitOrTopup(event);
|
||||
},
|
||||
reviewLabel() {
|
||||
return this.$refs.createForm?.querySelector('[name=label]')?.value?.trim() || '';
|
||||
},
|
||||
reviewEventName() {
|
||||
const eventBlock = this.$refs.createForm?.querySelector('[data-event-name]');
|
||||
return eventBlock?.value?.trim() || this.reviewLabel();
|
||||
},
|
||||
submitOrTopup(event) {
|
||||
if (this.balance <= 0 || this.balance < this.price) {
|
||||
event?.preventDefault();
|
||||
this.redirectTopup();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event?.type === 'submit') {
|
||||
return;
|
||||
}
|
||||
|
||||
const form = this.$el.tagName === 'FORM' ? this.$el : this.$refs.createForm;
|
||||
form?.requestSubmit();
|
||||
},
|
||||
_buildGradient() {
|
||||
if (this.gradientType === 'none') return null;
|
||||
return {
|
||||
type: this.gradientType,
|
||||
rotation: this.gradientType === 'linear' ? (Math.PI * this.gradientRotation / 180) : 0,
|
||||
colorStops: [
|
||||
{ offset: 0, color: this.gradientColor1 },
|
||||
{ offset: 1, color: this.gradientColor2 },
|
||||
],
|
||||
};
|
||||
},
|
||||
_buildQrOptions(width, height) {
|
||||
const dotsTypeMap = { square: 'square', dots: 'dots' };
|
||||
const cornersSquareMap = { square: 'square', rounded: 'extra-rounded', circle: 'dot' };
|
||||
// 'rounded' maps to 'square'; SVG post-processing applies the actual corner radius
|
||||
const cornersDotMap = { square: 'square', rounded: 'square', dot: 'dot' };
|
||||
|
||||
const gradient = this._buildGradient();
|
||||
|
||||
const options = {
|
||||
width,
|
||||
height,
|
||||
type: 'svg',
|
||||
data: this.qrData,
|
||||
margin: Math.round(Number(this.margin) * 4),
|
||||
qrOptions: { errorCorrectionLevel: this.ecc || 'M' },
|
||||
dotsOptions: {
|
||||
type: dotsTypeMap[this.moduleStyle] || 'square',
|
||||
...(gradient ? { gradient } : { color: this.fg }),
|
||||
},
|
||||
cornersSquareOptions: {
|
||||
type: cornersSquareMap[this.finderOuter] || 'square',
|
||||
...(gradient ? { gradient } : { color: this.fg }),
|
||||
},
|
||||
cornersDotOptions: {
|
||||
type: cornersDotMap[this.finderInner] || 'square',
|
||||
...(gradient ? { gradient } : { color: this.fg }),
|
||||
},
|
||||
backgroundOptions: { color: this.bg },
|
||||
};
|
||||
|
||||
if (this._processedLogoUrl && !this.removeLogo) {
|
||||
options.image = this._processedLogoUrl;
|
||||
options.imageOptions = {
|
||||
crossOrigin: 'anonymous',
|
||||
margin: this.logoMargin,
|
||||
imageSize: this.logoSize,
|
||||
hideBackgroundDots: true,
|
||||
};
|
||||
}
|
||||
|
||||
return options;
|
||||
},
|
||||
// Post-process SVG to give corner inner dots rounded corners.
|
||||
// qr-code-styling renders square cornersDot as <rect> elements inside <clipPath> in <defs>.
|
||||
// Adding rx/ry directly to those rects rounds the clip mask → the visible dot appears rounded.
|
||||
_applyRoundedInnerDot(container) {
|
||||
const svg = container.querySelector('svg');
|
||||
if (!svg) return;
|
||||
|
||||
const defs = svg.querySelector('defs');
|
||||
if (!defs) { this._applyRoundedInnerDotFallback(container); return; }
|
||||
|
||||
// Collect all square <rect> elements (width === height) inside <clipPath> elements,
|
||||
// grouped by their size value.
|
||||
const groups = new Map();
|
||||
defs.querySelectorAll('clipPath rect').forEach(rect => {
|
||||
const w = rect.getAttribute('width');
|
||||
const h = rect.getAttribute('height');
|
||||
if (w && h && w === h) {
|
||||
if (!groups.has(w)) groups.set(w, []);
|
||||
groups.get(w).push(rect);
|
||||
}
|
||||
});
|
||||
|
||||
// Find the group of exactly 3 with the smallest size — that is the inner finder dot
|
||||
// (one rect per finder eye; outer ring uses <path>, individual modules form much larger groups).
|
||||
let targetRects = null, minSize = Infinity;
|
||||
for (const [sizeStr, rects] of groups) {
|
||||
if (rects.length !== 3) continue;
|
||||
const size = parseFloat(sizeStr);
|
||||
if (size < minSize) { minSize = size; targetRects = rects; }
|
||||
}
|
||||
|
||||
if (!targetRects) { this._applyRoundedInnerDotFallback(container); return; }
|
||||
|
||||
const r = (minSize * 0.28).toFixed(3);
|
||||
for (const rect of targetRects) {
|
||||
rect.setAttribute('rx', r);
|
||||
rect.setAttribute('ry', r);
|
||||
}
|
||||
},
|
||||
// Fallback: use qrcode-generator to compute finder inner dot pixel positions, then
|
||||
// overdraw rounded SVG <rect> elements at those positions.
|
||||
_applyRoundedInnerDotFallback(container) {
|
||||
const svg = container.querySelector('svg');
|
||||
if (!svg || typeof window.qrcode === 'undefined') return;
|
||||
try {
|
||||
const qr = window.qrcode(0, this.ecc || 'M');
|
||||
qr.addData(this.qrData);
|
||||
qr.make();
|
||||
const n = qr.getModuleCount();
|
||||
const svgW = svg.viewBox?.baseVal?.width || parseFloat(svg.getAttribute('width') || '500');
|
||||
const marginPx = Math.round(Number(this.margin) * 4);
|
||||
const modulePx = (svgW - 2 * marginPx) / n;
|
||||
const dotSize = modulePx * 3;
|
||||
const r = dotSize * 0.28;
|
||||
const fill = this.gradientType !== 'none' ? this.gradientColor1 : this.fg;
|
||||
const NS = 'http://www.w3.org/2000/svg';
|
||||
for (const [row, col] of [[2, 2], [2, n - 5], [n - 5, 2]]) {
|
||||
const x = marginPx + col * modulePx;
|
||||
const y = marginPx + row * modulePx;
|
||||
// Cover the square dot with the background color
|
||||
const eraser = document.createElementNS(NS, 'rect');
|
||||
eraser.setAttribute('x', x); eraser.setAttribute('y', y);
|
||||
eraser.setAttribute('width', dotSize); eraser.setAttribute('height', dotSize);
|
||||
eraser.setAttribute('fill', this.bg);
|
||||
svg.appendChild(eraser);
|
||||
// Draw rounded corner dot
|
||||
const dot = document.createElementNS(NS, 'rect');
|
||||
dot.setAttribute('x', x); dot.setAttribute('y', y);
|
||||
dot.setAttribute('width', dotSize); dot.setAttribute('height', dotSize);
|
||||
dot.setAttribute('rx', r); dot.setAttribute('ry', r);
|
||||
dot.setAttribute('fill', fill);
|
||||
svg.appendChild(dot);
|
||||
}
|
||||
} catch (_) { /* silent */ }
|
||||
},
|
||||
refreshPreview() {
|
||||
if (typeof window.QRCodeStyling === 'undefined') return;
|
||||
|
||||
this.previewLoading = true;
|
||||
|
||||
try {
|
||||
const container = this.$refs.qrPreview;
|
||||
if (container) {
|
||||
container.innerHTML = '';
|
||||
new window.QRCodeStyling(this._buildQrOptions(500, 500)).append(container);
|
||||
if (this.finderInner === 'rounded') this._applyRoundedInnerDot(container);
|
||||
}
|
||||
|
||||
const modalContainer = this.$refs.qrPreviewModal;
|
||||
if (modalContainer && this.showPreviewModal) {
|
||||
modalContainer.innerHTML = '';
|
||||
new window.QRCodeStyling(this._buildQrOptions(320, 320)).append(modalContainer);
|
||||
if (this.finderInner === 'rounded') this._applyRoundedInnerDot(modalContainer);
|
||||
}
|
||||
} finally {
|
||||
this.previewLoading = false;
|
||||
this._maybeSyncCanonical();
|
||||
}
|
||||
},
|
||||
|
||||
// Persist the exact rendered QR as the canonical SVG+PNG (single source of
|
||||
// truth) so downloads match the preview. Only on pages with a real /q code
|
||||
// (canonicalSyncUrl set — i.e. the show page), once per load.
|
||||
_maybeSyncCanonical() {
|
||||
if (!this.canonicalSyncUrl || this._canonicalSynced) return;
|
||||
if (typeof window.QRCodeStyling === 'undefined') return;
|
||||
const svgEl = this.$refs.qrPreview?.querySelector('svg');
|
||||
if (!svgEl) return;
|
||||
this._canonicalSynced = true;
|
||||
// Defer so finder-dot post-processing + DOM settle before serializing.
|
||||
setTimeout(() => this.syncCanonical(), 300);
|
||||
},
|
||||
async syncCanonical() {
|
||||
try {
|
||||
const svgEl = this.$refs.qrPreview?.querySelector('svg');
|
||||
if (!svgEl) return;
|
||||
const inner = new XMLSerializer().serializeToString(svgEl);
|
||||
const finalSvg = this._buildFramedSvg(inner);
|
||||
const png = await this._svgToPng(finalSvg);
|
||||
if (!png) return;
|
||||
const fd = new FormData();
|
||||
fd.append('svg', finalSvg);
|
||||
fd.append('png', png);
|
||||
await fetch(this.canonicalSyncUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-TOKEN': this.csrf || '', 'Accept': 'application/json' },
|
||||
body: fd,
|
||||
});
|
||||
} catch (_) { /* non-fatal: server fallback render remains */ }
|
||||
},
|
||||
_xmlEscape(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
},
|
||||
// Composite the CSS preview frame (thin border / SCAN ME label / TAP TO
|
||||
// SCAN pill) into a single vector SVG, with the QR nested as vector — so
|
||||
// downloads include the frame exactly like the preview. No frame => QR as-is.
|
||||
_buildFramedSvg(qrSvg) {
|
||||
const fs = this.frameStyle;
|
||||
if (!fs || fs === 'none') return qrSvg;
|
||||
|
||||
const color = this.frameColor || '#000000';
|
||||
const Q = 1000, pad = 72, radius = 56;
|
||||
const W = Q + pad * 2;
|
||||
|
||||
// Nest the QR vector: ensure a viewBox, drop intrinsic width/height, place it.
|
||||
const wm = qrSvg.match(/<svg\b[^>]*?\swidth\s*=\s*"([\d.]+)/i);
|
||||
const hm = qrSvg.match(/<svg\b[^>]*?\sheight\s*=\s*"([\d.]+)/i);
|
||||
const ow = wm ? wm[1] : '500';
|
||||
const oh = hm ? hm[1] : '500';
|
||||
let qr = qrSvg;
|
||||
if (!/viewBox\s*=/i.test(qr)) {
|
||||
qr = qr.replace(/<svg\b/i, `<svg viewBox="0 0 ${ow} ${oh}"`);
|
||||
}
|
||||
qr = qr
|
||||
.replace(/(<svg\b[^>]*?)\swidth\s*=\s*"[^"]*"/i, '$1')
|
||||
.replace(/(<svg\b[^>]*?)\sheight\s*=\s*"[^"]*"/i, '$1')
|
||||
.replace(/<svg\b/i, `<svg x="${pad}" y="${pad}" width="${Q}" height="${Q}"`);
|
||||
|
||||
let labelH = 0, labelMarkup = '', border = '';
|
||||
if (fs === 'scan_me') {
|
||||
labelH = 124;
|
||||
const txt = ((this.frameText && this.frameText.trim()) ? this.frameText : 'SCAN ME').toUpperCase();
|
||||
labelMarkup =
|
||||
`<line x1="${pad}" y1="${Q + pad + 26}" x2="${W - pad}" y2="${Q + pad + 26}" stroke="${color}" stroke-opacity="0.25" stroke-width="2"/>` +
|
||||
`<text x="${W / 2}" y="${Q + pad + 86}" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="44" font-weight="700" letter-spacing="7" fill="${color}">${this._xmlEscape(txt)}</text>`;
|
||||
} else if (fs === 'tap_to_scan') {
|
||||
labelH = 142;
|
||||
const txt = ((this.frameText && this.frameText.trim()) ? this.frameText : 'TAP TO SCAN').toUpperCase();
|
||||
const pillH = 78;
|
||||
const pillW = Math.min(W - pad * 2, 160 + txt.length * 24);
|
||||
const pillX = (W - pillW) / 2, pillY = Q + pad + 30;
|
||||
labelMarkup =
|
||||
`<rect x="${pillX}" y="${pillY}" width="${pillW}" height="${pillH}" rx="${pillH / 2}" fill="${color}"/>` +
|
||||
`<text x="${W / 2}" y="${pillY + pillH / 2 + 13}" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="36" font-weight="700" letter-spacing="5" fill="${this.frameTextColor}">${this._xmlEscape(txt)}</text>`;
|
||||
}
|
||||
const H = Q + pad * 2 + labelH;
|
||||
if (fs === 'thin') {
|
||||
border = `<rect x="11" y="11" width="${W - 22}" height="${H - 22}" rx="${radius - 6}" fill="none" stroke="${color}" stroke-width="18"/>`;
|
||||
}
|
||||
|
||||
// No XML prolog here — the server's sanitizeSvg prepends it (a literal
|
||||
// XML prolog in a Blade file would be read as a PHP open tag).
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">` +
|
||||
`<rect x="0" y="0" width="${W}" height="${H}" rx="${radius}" fill="#ffffff"/>` +
|
||||
border + qr + labelMarkup +
|
||||
'</svg>';
|
||||
},
|
||||
_svgToPng(svgString) {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
try {
|
||||
const w = img.naturalWidth || 1000;
|
||||
const h = img.naturalHeight || 1000;
|
||||
const f = Math.max(1, 1000 / Math.max(w, h)); // upscale small to ~1000 for crisp print
|
||||
const cw = Math.round(w * f), ch = Math.round(h * f);
|
||||
const c = document.createElement('canvas');
|
||||
c.width = cw; c.height = ch;
|
||||
const ctx = c.getContext('2d');
|
||||
ctx.fillStyle = (this.frameStyle && this.frameStyle !== 'none') ? '#ffffff' : (this.bg || '#ffffff');
|
||||
ctx.fillRect(0, 0, cw, ch);
|
||||
ctx.drawImage(img, 0, 0, cw, ch);
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(c.toDataURL('image/png'));
|
||||
} catch (_) { URL.revokeObjectURL(url); resolve(null); }
|
||||
};
|
||||
img.onerror = () => { URL.revokeObjectURL(url); resolve(null); };
|
||||
img.src = url;
|
||||
} catch (_) { resolve(null); }
|
||||
});
|
||||
},
|
||||
|
||||
// Returns black or white depending on frameColor luminance, for readable CTA text
|
||||
get frameTextColor() {
|
||||
return this._hexToLuminance(this.frameColor) > 0.35 ? '#000000' : '#ffffff';
|
||||
},
|
||||
|
||||
// ── Scanability validation ────────────────────────────────────────
|
||||
get scanability() {
|
||||
let score = 100;
|
||||
const warnings = [];
|
||||
|
||||
// Contrast check — use gradient start colour when gradient is active
|
||||
const effectiveFg = this.gradientType !== 'none' ? this.gradientColor1 : this.fg;
|
||||
const ratio = this._contrastRatio(effectiveFg, this.bg);
|
||||
|
||||
if (ratio < 2.0) {
|
||||
score -= 50;
|
||||
warnings.push('Very low contrast — code will likely fail to scan.');
|
||||
} else if (ratio < 3.0) {
|
||||
score -= 30;
|
||||
warnings.push('Low contrast between foreground and background.');
|
||||
} else if (ratio < 4.5) {
|
||||
score -= 10;
|
||||
warnings.push('Contrast is acceptable but could be improved.');
|
||||
}
|
||||
|
||||
// Logo size checks
|
||||
const hasLogo = Boolean(this._processedLogoUrl) && !this.removeLogo;
|
||||
if (hasLogo) {
|
||||
if (this.logoSize > 0.40) {
|
||||
score -= 25;
|
||||
warnings.push('Logo is too large and may block scanning.');
|
||||
} else if (this.logoSize > 0.30) {
|
||||
score -= 10;
|
||||
warnings.push('Large logo — use High (H) error correction.');
|
||||
}
|
||||
|
||||
if (this.logoSize > 0.25 && this.ecc === 'L') {
|
||||
score -= 15;
|
||||
warnings.push('Logo requires at least Medium (M) error correction.');
|
||||
}
|
||||
}
|
||||
|
||||
// Extreme customisation warning
|
||||
const hasGradient = this.gradientType !== 'none';
|
||||
const complexEyes = this.finderOuter === 'circle' || this.finderInner === 'dot';
|
||||
if (hasLogo && hasGradient && complexEyes && this.ecc === 'L') {
|
||||
score -= 10;
|
||||
warnings.push('Many effects combined — consider higher error correction.');
|
||||
}
|
||||
|
||||
score = Math.max(0, score);
|
||||
|
||||
let label, level;
|
||||
if (score >= 80) { label = 'Excellent'; level = 'excellent'; }
|
||||
else if (score >= 60) { label = 'Good'; level = 'good'; }
|
||||
else { label = 'Warning'; level = 'warning'; }
|
||||
|
||||
return { score, label, level, warnings };
|
||||
},
|
||||
_hexToLuminance(hex) {
|
||||
hex = (hex || '#000000').replace('#', '');
|
||||
if (hex.length === 3) hex = hex.split('').map(c => c + c).join('');
|
||||
if (hex.length !== 6) return 0;
|
||||
const r = parseInt(hex.slice(0, 2), 16) / 255;
|
||||
const g = parseInt(hex.slice(2, 4), 16) / 255;
|
||||
const b = parseInt(hex.slice(4, 6), 16) / 255;
|
||||
const lin = c => c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
||||
},
|
||||
_contrastRatio(hex1, hex2) {
|
||||
const l1 = this._hexToLuminance(hex1);
|
||||
const l2 = this._hexToLuminance(hex2);
|
||||
const light = Math.max(l1, l2);
|
||||
const dark = Math.min(l1, l2);
|
||||
return (light + 0.05) / (dark + 0.05);
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
if (window.Alpine) {
|
||||
registerQrCustomizer();
|
||||
} else {
|
||||
document.addEventListener('alpine:init', registerQrCustomizer);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
@endonce
|
||||
@@ -1,188 +0,0 @@
|
||||
@php
|
||||
$showDownloads = $showDownloads ?? true;
|
||||
@endphp
|
||||
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200/80 bg-white shadow-sm">
|
||||
|
||||
{{-- QR display area with gradient background --}}
|
||||
<div class="relative flex items-center justify-center bg-gradient-to-br from-indigo-50/60 via-white to-violet-50/40 px-8 py-10">
|
||||
|
||||
{{-- Spinner overlay --}}
|
||||
<div x-show="previewLoading" x-cloak
|
||||
class="absolute inset-0 z-10 flex items-center justify-center bg-white/60 backdrop-blur-[2px]">
|
||||
<svg class="h-8 w-8 animate-spin text-indigo-500" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-20" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3"/>
|
||||
<path class="opacity-80" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{{-- Floating QR card --}}
|
||||
<div class="w-full max-w-xs transition-all duration-300"
|
||||
:class="previewLoading ? 'opacity-40 scale-[0.97]' : 'opacity-100 scale-100'">
|
||||
<div class="overflow-hidden rounded-2xl bg-white shadow-2xl"
|
||||
:style="frameStyle === 'thin'
|
||||
? 'box-shadow: 0 0 0 2px ' + frameColor + ', 0 25px 50px -12px rgb(0 0 0 / 0.25)'
|
||||
: 'box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25), 0 0 0 1px rgb(0 0 0 / 0.06)'">
|
||||
|
||||
{{-- Frame wrapper: adds padding + CTA for label/pill frames --}}
|
||||
<div :class="{
|
||||
'p-[8px]': frameStyle === 'thin',
|
||||
'p-[14px]': frameStyle === 'scan_me' || frameStyle === 'tap_to_scan',
|
||||
'p-4': frameStyle === 'none',
|
||||
}">
|
||||
{{-- QR code area — SVG renderer only, no static fallback --}}
|
||||
<div x-ref="qrPreview"
|
||||
class="aspect-square w-full [&>svg]:block [&>svg]:h-full [&>svg]:w-full"></div>
|
||||
|
||||
{{-- Scan me label --}}
|
||||
<div x-show="frameStyle === 'scan_me'" x-cloak
|
||||
:style="'border-top: 1px solid ' + frameColor + '40; color: ' + frameColor"
|
||||
class="pt-2.5 pb-0.5 text-center">
|
||||
<span x-text="(frameText && frameText.trim() ? frameText : 'SCAN ME').toUpperCase()"
|
||||
class="text-[11px] font-bold tracking-[0.18em]"></span>
|
||||
</div>
|
||||
|
||||
{{-- Tap to scan pill --}}
|
||||
<div x-show="frameStyle === 'tap_to_scan'" x-cloak
|
||||
class="mt-2.5 flex justify-center pb-0.5">
|
||||
<span x-text="(frameText && frameText.trim() ? frameText : 'TAP TO SCAN').toUpperCase()"
|
||||
:style="'background-color: ' + frameColor + '; color: ' + frameTextColor"
|
||||
class="rounded-full px-5 py-1.5 text-[10px] font-bold tracking-widest"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Scanability score --}}
|
||||
<div class="border-t border-slate-100 px-5 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-[0.1em] text-slate-400">Scan quality</span>
|
||||
<span class="text-xs font-bold tabular-nums"
|
||||
:class="{
|
||||
'text-emerald-600': scanability.level === 'excellent',
|
||||
'text-amber-500': scanability.level === 'good',
|
||||
'text-red-500': scanability.level === 'warning',
|
||||
}"
|
||||
x-text="scanability.score + '% — ' + scanability.label"></span>
|
||||
</div>
|
||||
<div class="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-slate-100">
|
||||
<div class="h-full rounded-full transition-all duration-300"
|
||||
:style="'width: ' + scanability.score + '%'"
|
||||
:class="{
|
||||
'bg-emerald-500': scanability.level === 'excellent',
|
||||
'bg-amber-400': scanability.level === 'good',
|
||||
'bg-red-500': scanability.level === 'warning',
|
||||
}"></div>
|
||||
</div>
|
||||
<template x-if="scanability.warnings.length > 0">
|
||||
<ul class="mt-2.5 space-y-1.5">
|
||||
<template x-for="w in scanability.warnings" :key="w">
|
||||
<li class="flex items-start gap-1.5">
|
||||
<svg class="mt-px h-3.5 w-3.5 shrink-0 text-amber-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<span class="text-[11px] leading-4 text-amber-700" x-text="w"></span>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
{{-- Download + Share buttons --}}
|
||||
@if($showDownloads && isset($qrCode))
|
||||
@php
|
||||
$shareUrl = $qrCode->publicUrl();
|
||||
$shareEnc = urlencode($shareUrl);
|
||||
$shareText = urlencode($qrCode->label . ' — scan my QR code');
|
||||
@endphp
|
||||
<div class="border-t border-slate-100 bg-slate-50/50 px-6 py-5">
|
||||
<p class="mb-3 text-[10px] font-semibold uppercase tracking-[0.1em] text-slate-400">Download & Share</p>
|
||||
<div class="grid grid-cols-4 gap-2">
|
||||
|
||||
<a href="{{ route('events.download', [$qrCode, 'png']) }}"
|
||||
class="group flex flex-col items-center gap-1.5 rounded-xl border border-slate-200 bg-white py-3.5 text-center transition hover:border-indigo-300 hover:bg-indigo-50">
|
||||
<svg class="h-4 w-4 text-slate-400 transition group-hover:text-indigo-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12M12 16.5V3"/>
|
||||
</svg>
|
||||
<span class="text-xs font-semibold text-slate-600 transition group-hover:text-indigo-700">PNG</span>
|
||||
</a>
|
||||
|
||||
<a href="{{ route('events.download', [$qrCode, 'svg']) }}"
|
||||
class="group flex flex-col items-center gap-1.5 rounded-xl border border-slate-200 bg-white py-3.5 text-center transition hover:border-violet-300 hover:bg-violet-50">
|
||||
<svg class="h-4 w-4 text-slate-400 transition group-hover:text-violet-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5"/>
|
||||
</svg>
|
||||
<span class="text-xs font-semibold text-slate-600 transition group-hover:text-violet-700">SVG</span>
|
||||
</a>
|
||||
|
||||
<a href="{{ route('events.download', [$qrCode, 'pdf']) }}"
|
||||
class="group flex flex-col items-center gap-1.5 rounded-xl border border-slate-200 bg-white py-3.5 text-center transition hover:border-rose-300 hover:bg-rose-50">
|
||||
<svg class="h-4 w-4 text-slate-400 transition group-hover:text-rose-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"/>
|
||||
</svg>
|
||||
<span class="text-xs font-semibold text-slate-600 transition group-hover:text-rose-700">PDF</span>
|
||||
</a>
|
||||
|
||||
{{-- Share button with popover --}}
|
||||
<div class="relative" x-data="{ open: false, copied: false }">
|
||||
<button type="button"
|
||||
@click="open = !open"
|
||||
:class="open ? 'border-sky-300 bg-sky-50' : 'border-slate-200 bg-white hover:border-sky-300 hover:bg-sky-50'"
|
||||
class="group flex w-full flex-col items-center gap-1.5 rounded-xl border py-3.5 text-center transition">
|
||||
<svg class="h-4 w-4 transition" :class="open ? 'text-sky-500' : 'text-slate-400 group-hover:text-sky-500'" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7.217 10.907a2.25 2.25 0 1 0 0 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186 9.566-5.314m-9.566 7.5 9.566 5.314m0 0a2.25 2.25 0 1 0 3.935 2.186 2.25 2.25 0 0 0-3.935-2.186Zm0-12.814a2.25 2.25 0 1 0 3.933-2.185 2.25 2.25 0 0 0-3.933 2.185Z"/>
|
||||
</svg>
|
||||
<span class="text-xs font-semibold transition" :class="open ? 'text-sky-700' : 'text-slate-600 group-hover:text-sky-700'">Share</span>
|
||||
</button>
|
||||
|
||||
<div x-show="open" x-cloak
|
||||
@click.outside="open = false"
|
||||
x-transition:enter="transition ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-75"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
class="absolute bottom-full right-0 z-50 mb-2 w-52 origin-bottom-right rounded-xl border border-slate-200 bg-white p-1.5 shadow-xl">
|
||||
|
||||
<a href="https://wa.me/?text={{ $shareText }}%20{{ $shareEnc }}" target="_blank" rel="noopener"
|
||||
@click="open = false"
|
||||
class="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-700 transition hover:bg-slate-50">
|
||||
<svg class="h-4 w-4 shrink-0 text-green-500" fill="currentColor" viewBox="0 0 24 24"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z"/></svg>
|
||||
WhatsApp
|
||||
</a>
|
||||
|
||||
<a href="https://twitter.com/intent/tweet?url={{ $shareEnc }}&text={{ $shareText }}" target="_blank" rel="noopener"
|
||||
@click="open = false"
|
||||
class="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-700 transition hover:bg-slate-50">
|
||||
<svg class="h-4 w-4 shrink-0 text-slate-800" fill="currentColor" viewBox="0 0 24 24"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.748l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
|
||||
X (Twitter)
|
||||
</a>
|
||||
|
||||
<a href="https://www.facebook.com/sharer/sharer.php?u={{ $shareEnc }}" target="_blank" rel="noopener"
|
||||
@click="open = false"
|
||||
class="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-700 transition hover:bg-slate-50">
|
||||
<svg class="h-4 w-4 shrink-0 text-blue-600" fill="currentColor" viewBox="0 0 24 24"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
|
||||
Facebook
|
||||
</a>
|
||||
|
||||
<div class="my-1 border-t border-slate-100"></div>
|
||||
|
||||
<button type="button"
|
||||
@click="navigator.clipboard.writeText('{{ $shareUrl }}').then(() => { copied = true; setTimeout(() => { copied = false; open = false }, 1500) })"
|
||||
class="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition hover:bg-slate-50"
|
||||
:class="copied ? 'text-emerald-600' : 'text-slate-700'">
|
||||
<svg x-show="!copied" class="h-4 w-4 shrink-0 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244"/></svg>
|
||||
<svg x-show="copied" x-cloak class="h-4 w-4 shrink-0 text-emerald-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/></svg>
|
||||
<span x-text="copied ? 'Copied!' : 'Copy link'">Copy link</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user