From a7090e9c30e7704becea21c6c948a5e18a416cb5 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Sun, 7 Jun 2026 09:14:16 +0000 Subject: [PATCH] Add QR-specific settings for notifications and new-code defaults. Store per-account preferences in qr_settings and pre-fill the create flow with saved type and brand styling. Co-authored-by: Cursor --- app/Http/Controllers/Qr/AccountController.php | 62 ++++++- app/Http/Controllers/Qr/QrCodeController.php | 3 + app/Models/QrSetting.php | 85 ++++++++++ app/Models/User.php | 10 ++ ..._06_06_220000_create_qr_settings_table.php | 27 +++ resources/views/layouts/user.blade.php | 3 +- resources/views/partials/topbar-qr.blade.php | 3 +- resources/views/qr-codes/create.blade.php | 4 +- resources/views/qr/account/settings.blade.php | 157 +++++++++++++++++- routes/web.php | 1 + tests/Feature/QrSettingsTest.php | 87 ++++++++++ 11 files changed, 435 insertions(+), 7 deletions(-) create mode 100644 app/Models/QrSetting.php create mode 100644 database/migrations/2026_06_06_220000_create_qr_settings_table.php create mode 100644 tests/Feature/QrSettingsTest.php diff --git a/app/Http/Controllers/Qr/AccountController.php b/app/Http/Controllers/Qr/AccountController.php index cdb9044..27e8c20 100644 --- a/app/Http/Controllers/Qr/AccountController.php +++ b/app/Http/Controllers/Qr/AccountController.php @@ -3,7 +3,14 @@ namespace App\Http\Controllers\Qr; use App\Http\Controllers\Controller; +use App\Models\QrSetting; use App\Services\Billing\BillingClient; +use App\Support\Qr\QrCornerStyleCatalog; +use App\Support\Qr\QrFrameStyleCatalog; +use App\Support\Qr\QrModuleStyleCatalog; +use App\Support\Qr\QrTypeCatalog; +use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; use Illuminate\View\View; class AccountController extends Controller @@ -56,6 +63,59 @@ class AccountController extends Controller public function settings(): View { - return view('qr.account.settings', ['account' => ladill_account()]); + $account = ladill_account(); + $settings = $account->getOrCreateQrSetting(); + $style = $settings->resolvedDefaultStyle(); + + return view('qr.account.settings', [ + 'account' => $account, + 'settings' => $settings, + 'style' => $style, + 'types' => QrTypeCatalog::all(), + 'moduleStyles' => QrModuleStyleCatalog::visible(), + 'cornerOuterStyles' => QrCornerStyleCatalog::outerStyles(), + 'cornerInnerStyles' => QrCornerStyleCatalog::innerStyles(), + 'frameStyles' => QrFrameStyleCatalog::visible(), + ]); + } + + public function updateSettings(Request $request): RedirectResponse + { + $account = ladill_account(); + + $data = $request->validate([ + 'notify_email' => ['nullable', 'email', 'max:255'], + 'product_updates' => ['nullable', 'boolean'], + 'low_balance_alerts' => ['nullable', 'boolean'], + 'default_type' => ['nullable', 'in:'.implode(',', QrTypeCatalog::plusTypes())], + 'default_style' => ['nullable', 'array'], + 'default_style.foreground' => ['nullable', 'regex:/^#[0-9a-fA-F]{6}$/'], + 'default_style.background' => ['nullable', 'regex:/^#[0-9a-fA-F]{6}$/'], + 'default_style.error_correction' => ['nullable', 'in:L,M,Q,H'], + 'default_style.margin' => ['nullable', 'integer', 'min:0', 'max:10'], + 'default_style.module_style' => ['nullable', 'in:'.implode(',', QrModuleStyleCatalog::keys())], + 'default_style.finder_outer' => ['nullable', 'string', 'max:32'], + 'default_style.finder_inner' => ['nullable', 'string', 'max:32'], + 'default_style.frame_style' => ['nullable', 'string', 'max:32'], + 'default_style.frame_text' => ['nullable', 'string', 'max:100'], + 'default_style.frame_color' => ['nullable', 'regex:/^#[0-9a-fA-F]{6}$/'], + 'default_style.gradient_type' => ['nullable', 'in:none,linear,radial'], + 'default_style.gradient_color1' => ['nullable', 'regex:/^#[0-9a-fA-F]{6}$/'], + 'default_style.gradient_color2' => ['nullable', 'regex:/^#[0-9a-fA-F]{6}$/'], + 'default_style.gradient_rotation' => ['nullable', 'integer', 'min:0', 'max:360'], + ]); + + QrSetting::updateOrCreate( + ['user_id' => $account->id], + [ + 'notify_email' => $data['notify_email'] ?? null, + 'product_updates' => $request->boolean('product_updates'), + 'low_balance_alerts' => $request->boolean('low_balance_alerts'), + 'default_type' => $data['default_type'] ?? null, + 'default_style' => QrSetting::sanitizeDefaultStyle($data['default_style'] ?? null), + ], + ); + + return redirect()->route('account.settings')->with('success', 'Settings saved.'); } } diff --git a/app/Http/Controllers/Qr/QrCodeController.php b/app/Http/Controllers/Qr/QrCodeController.php index 1633926..706a3f3 100644 --- a/app/Http/Controllers/Qr/QrCodeController.php +++ b/app/Http/Controllers/Qr/QrCodeController.php @@ -71,6 +71,7 @@ class QrCodeController extends Controller { $account = ladill_account(); $wallet = $this->manager->walletFor($account); + $qrSettings = $account->getOrCreateQrSetting(); return view('qr-codes.create', [ 'wallet' => $wallet, @@ -83,6 +84,8 @@ class QrCodeController extends Controller 'minTopup' => QrWallet::minTopupGhs(), 'ladillWalletBalance' => $this->platformBilling->balanceMinor($account->public_id) / 100, 'topupUrl' => 'https://'.config('app.account_domain').'/wallet', + 'defaultType' => $qrSettings->resolvedDefaultType(), + 'accountDefaultStyle' => $qrSettings->resolvedDefaultStyle(), ]); } diff --git a/app/Models/QrSetting.php b/app/Models/QrSetting.php new file mode 100644 index 0000000..afd8b60 --- /dev/null +++ b/app/Models/QrSetting.php @@ -0,0 +1,85 @@ + 'boolean', + 'low_balance_alerts' => 'boolean', + 'default_style' => 'array', + ]; + + /** @return list */ + public static function storableStyleKeys(): array + { + return [ + 'foreground', + 'background', + 'error_correction', + 'margin', + 'module_style', + 'finder_outer', + 'finder_inner', + 'frame_style', + 'frame_text', + 'frame_color', + 'gradient_type', + 'gradient_color1', + 'gradient_color2', + 'gradient_rotation', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function resolvedDefaultType(): string + { + $type = (string) ($this->default_type ?? QrCode::TYPE_URL); + + return in_array($type, QrTypeCatalog::plusTypes(), true) ? $type : QrCode::TYPE_URL; + } + + /** @return array */ + public function resolvedDefaultStyle(): array + { + $stored = collect($this->default_style ?? []) + ->only(self::storableStyleKeys()) + ->filter(fn ($value) => $value !== null && $value !== '') + ->all(); + + return QrStyleDefaults::merge($stored !== [] ? $stored : null); + } + + /** @param array|null $input */ + public static function sanitizeDefaultStyle(?array $input): ?array + { + if ($input === null) { + return null; + } + + $merged = QrStyleDefaults::merge( + collect($input)->only(self::storableStyleKeys())->all(), + ); + + return collect($merged)->only(self::storableStyleKeys())->all(); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 0d81bdc..23dae3b 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -58,6 +58,16 @@ class User extends Authenticatable return $this->hasMany(QrCode::class); } + public function qrSetting(): HasOne + { + return $this->hasOne(QrSetting::class); + } + + public function getOrCreateQrSetting(): QrSetting + { + return $this->qrSetting()->firstOrCreate([]); + } + public function getOrCreateQrWallet(): QrWallet { return $this->qrWallet()->firstOrCreate( diff --git a/database/migrations/2026_06_06_220000_create_qr_settings_table.php b/database/migrations/2026_06_06_220000_create_qr_settings_table.php new file mode 100644 index 0000000..f8a8f2c --- /dev/null +++ b/database/migrations/2026_06_06_220000_create_qr_settings_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('user_id')->unique()->constrained()->cascadeOnDelete(); + $table->string('notify_email')->nullable(); + $table->boolean('product_updates')->default(true); + $table->boolean('low_balance_alerts')->default(true); + $table->string('default_type', 32)->nullable(); + $table->json('default_style')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('qr_settings'); + } +}; diff --git a/resources/views/layouts/user.blade.php b/resources/views/layouts/user.blade.php index a9c5ad1..00601f0 100644 --- a/resources/views/layouts/user.blade.php +++ b/resources/views/layouts/user.blade.php @@ -74,7 +74,8 @@ 'profileName' => $navUser?->name ?? '', 'profileSubtitle' => $navUser?->email ?? '', 'profileMenuItems' => [ - ['type' => 'link', 'label' => 'Account Settings', 'href' => route('account.settings')], + ['type' => 'link', 'label' => 'QR Settings', 'href' => route('account.settings')], + ['type' => 'link', 'label' => 'Account Settings', 'href' => ladill_account_url('account-settings')], ['type' => 'link', 'label' => 'Overview', 'href' => route('qr.dashboard')], ['type' => 'logout', 'label' => 'Logout', 'action' => route('logout')], ], diff --git a/resources/views/partials/topbar-qr.blade.php b/resources/views/partials/topbar-qr.blade.php index 9801c27..54a3f0e 100644 --- a/resources/views/partials/topbar-qr.blade.php +++ b/resources/views/partials/topbar-qr.blade.php @@ -124,7 +124,8 @@
- Account Settings + QR Settings + Account Settings Dashboard
@csrf diff --git a/resources/views/qr-codes/create.blade.php b/resources/views/qr-codes/create.blade.php index 4d0c044..d12832d 100644 --- a/resources/views/qr-codes/create.blade.php +++ b/resources/views/qr-codes/create.blade.php @@ -2,7 +2,7 @@ Create QR Code @php - $defaultStyle = \App\Support\Qr\QrStyleDefaults::merge(old('style')); + $defaultStyle = \App\Support\Qr\QrStyleDefaults::merge(old('style') ?: ($accountDefaultStyle ?? null)); @endphp
spendableBalance()), price: @js((float) $pricePerQr), topupUrl: @js($topupUrl), - type: @js(old('type', 'url')), + type: @js(old('type', $defaultType ?? 'url')), })"> @if(session('error')) diff --git a/resources/views/qr/account/settings.blade.php b/resources/views/qr/account/settings.blade.php index 99c546d..9019875 100644 --- a/resources/views/qr/account/settings.blade.php +++ b/resources/views/qr/account/settings.blade.php @@ -1,7 +1,160 @@ Settings +
-

Settings

-

Profile and account settings are managed on account.ladill.com.

+

Settings

+

QR Plus notifications and defaults for new codes.

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

Notifications

+

Where we send QR Plus billing reminders and product updates.

+ +
+ + + @error('notify_email')

{{ $message }}

@enderror +
+ + + + +
+ +
+

New code defaults

+

Pre-fill the create screen so every new QR starts with your brand look.

+ +
+ + + @error('default_type')

{{ $message }}

@enderror +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ + + + +

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

diff --git a/routes/web.php b/routes/web.php index 75250c3..f1a3e50 100644 --- a/routes/web.php +++ b/routes/web.php @@ -52,6 +52,7 @@ Route::middleware(['auth'])->group(function () { Route::get('/wallet', [AccountController::class, 'wallet'])->name('account.wallet'); Route::get('/billing', [AccountController::class, 'billing'])->name('account.billing'); Route::get('/settings', [AccountController::class, 'settings'])->name('account.settings'); + Route::put('/settings', [AccountController::class, 'updateSettings'])->name('account.settings.update'); Route::get('/team', [TeamController::class, 'index'])->name('account.team'); Route::post('/team', [TeamController::class, 'store'])->name('account.team.store'); diff --git a/tests/Feature/QrSettingsTest.php b/tests/Feature/QrSettingsTest.php new file mode 100644 index 0000000..82c3a2a --- /dev/null +++ b/tests/Feature/QrSettingsTest.php @@ -0,0 +1,87 @@ + (string) Str::uuid(), 'name' => 'A', 'email' => 'a+'.uniqid().'@x.com']); + } + + public function test_settings_page_renders_for_authenticated_user(): void + { + $this->actingAs($this->user()) + ->get(route('account.settings')) + ->assertOk() + ->assertSee('New code defaults') + ->assertSee('Notifications'); + } + + public function test_can_save_qr_settings(): void + { + $user = $this->user(); + + $this->actingAs($user) + ->put(route('account.settings.update'), [ + 'notify_email' => 'alerts@example.com', + 'product_updates' => '1', + 'low_balance_alerts' => '0', + 'default_type' => 'wifi', + 'default_style' => [ + 'foreground' => '#112233', + 'background' => '#ffffff', + 'module_style' => 'dots', + 'frame_style' => 'scan_me', + 'frame_color' => '#445566', + 'frame_text' => 'SCAN ME', + ], + ]) + ->assertRedirect(route('account.settings')); + + $settings = QrSetting::where('user_id', $user->id)->first(); + + $this->assertNotNull($settings); + $this->assertSame('alerts@example.com', $settings->notify_email); + $this->assertTrue($settings->product_updates); + $this->assertFalse($settings->low_balance_alerts); + $this->assertSame('wifi', $settings->default_type); + $this->assertSame('#112233', $settings->resolvedDefaultStyle()['foreground']); + $this->assertSame('scan_me', $settings->resolvedDefaultStyle()['frame_style']); + } + + public function test_create_page_uses_saved_defaults(): void + { + $user = $this->user(); + QrSetting::create([ + 'user_id' => $user->id, + 'default_type' => 'business', + 'default_style' => ['foreground' => '#aabbcc', 'module_style' => 'dots'], + ]); + + Http::fake([ + config('billing.api_url').'/balance*' => Http::response(['balance_minor' => 50000]), + ]); + + $this->actingAs($user) + ->get(route('user.qr-codes.create')) + ->assertOk() + ->assertSee('#aabbcc', false) + ->assertSee('"business"', false); + } +}