Add QR-specific settings for notifications and new-code defaults.
Deploy Ladill QR Plus / deploy (push) Successful in 1m15s

Store per-account preferences in qr_settings and pre-fill the create flow with saved type and brand styling.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-07 09:14:16 +00:00
co-authored by Cursor
parent 9ddda21ea3
commit a7090e9c30
11 changed files with 435 additions and 7 deletions
+61 -1
View File
@@ -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.');
}
}
@@ -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(),
]);
}
+85
View File
@@ -0,0 +1,85 @@
<?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 QR Plus preferences (notifications and new-code defaults). */
class QrSetting extends Model
{
protected $fillable = [
'user_id',
'notify_email',
'product_updates',
'low_balance_alerts',
'default_type',
'default_style',
];
protected $casts = [
'product_updates' => 'boolean',
'low_balance_alerts' => 'boolean',
'default_style' => 'array',
];
/** @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_URL);
return in_array($type, QrTypeCatalog::plusTypes(), true) ? $type : QrCode::TYPE_URL;
}
/** @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();
}
}
+10
View File
@@ -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(
@@ -0,0 +1,27 @@
<?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');
}
};
+2 -1
View File
@@ -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')],
],
+2 -1
View File
@@ -124,7 +124,8 @@
<div x-show="profileOpen" x-cloak x-transition @click.outside="profileOpen = false"
class="absolute right-0 z-20 mt-2 w-48 rounded-xl border border-slate-200 bg-white p-1 shadow-lg">
<a href="{{ route('account.settings') }}" class="block rounded-lg px-3 py-2 text-sm text-slate-700 hover:bg-slate-100">Account Settings</a>
<a href="{{ route('account.settings') }}" class="block rounded-lg px-3 py-2 text-sm text-slate-700 hover:bg-slate-100">QR Settings</a>
<a href="{{ ladill_account_url('account-settings') }}" class="block rounded-lg px-3 py-2 text-sm text-slate-700 hover:bg-slate-100">Account Settings</a>
<a href="{{ route('qr.dashboard') }}" class="block rounded-lg px-3 py-2 text-sm text-slate-700 hover:bg-slate-100">Dashboard</a>
<form method="POST" action="{{ route('logout') }}">
@csrf
+2 -2
View File
@@ -2,7 +2,7 @@
<x-slot name="title">Create QR Code</x-slot>
@php
$defaultStyle = \App\Support\Qr\QrStyleDefaults::merge(old('style'));
$defaultStyle = \App\Support\Qr\QrStyleDefaults::merge(old('style') ?: ($accountDefaultStyle ?? null));
@endphp
<div class="mx-auto max-w-6xl space-y-6"
@@ -14,7 +14,7 @@
balance: @js((float) $wallet->spendableBalance()),
price: @js((float) $pricePerQr),
topupUrl: @js($topupUrl),
type: @js(old('type', 'url')),
type: @js(old('type', $defaultType ?? 'url')),
})">
@if(session('error'))
+155 -2
View File
@@ -1,7 +1,160 @@
<x-user-layout>
<x-slot name="title">Settings</x-slot>
<div class="mx-auto max-w-2xl">
<h1 class="text-xl font-semibold text-slate-900">Settings</h1>
<p class="mt-2 text-sm text-slate-500">Profile and account settings are managed on <a href="https://{{ config('app.account_domain') }}/account-settings" class="font-medium text-indigo-600 hover:text-indigo-800">account.ladill.com</a>.</p>
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Settings</h1>
<p class="mt-0.5 text-sm text-slate-500">QR Plus notifications and defaults for new codes.</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">Where we send QR Plus billing reminders and product updates.</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">Low balance alerts</span>
<span class="block text-xs text-slate-400">Email when your wallet is too low to create a new code.</span>
</span>
<input type="checkbox" name="low_balance_alerts" value="1"
@checked(old('low_balance_alerts', $settings->low_balance_alerts ?? 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">Product updates</span>
<span class="block text-xs text-slate-400">Occasional emails about new QR Plus features.</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>
<div class="rounded-2xl border border-slate-200 bg-white p-5">
<h2 class="text-sm font-semibold text-slate-900">New code defaults</h2>
<p class="mt-1 text-xs text-slate-500">Pre-fill the create screen so every new QR starts with your brand look.</p>
<div class="mt-4">
<label for="default_type" class="block text-[11px] font-medium text-slate-500">Default type</label>
<select id="default_type" name="default_type"
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">
@foreach ($types as $key => $meta)
<option value="{{ $key }}" @selected(old('default_type', $settings->default_type ?? 'url') === $key)>
{{ $meta['label'] }}
</option>
@endforeach
</select>
@error('default_type') <p class="mt-1 text-xs text-rose-600">{{ $message }}</p> @enderror
</div>
<div class="mt-4 grid gap-4 sm:grid-cols-2">
<div>
<label for="foreground" class="block text-[11px] font-medium text-slate-500">Foreground</label>
<input type="color" id="foreground" name="default_style[foreground]"
value="{{ old('default_style.foreground', $style['foreground']) }}"
class="mt-1 h-10 w-full cursor-pointer rounded-lg border border-slate-200 bg-white p-1">
</div>
<div>
<label for="background" class="block text-[11px] font-medium text-slate-500">Background</label>
<input type="color" id="background" name="default_style[background]"
value="{{ old('default_style.background', $style['background']) }}"
class="mt-1 h-10 w-full cursor-pointer rounded-lg border border-slate-200 bg-white p-1">
</div>
</div>
<div class="mt-4 grid gap-4 sm:grid-cols-2">
<div>
<label for="module_style" class="block text-[11px] font-medium text-slate-500">Module style</label>
<select id="module_style" name="default_style[module_style]"
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">
@foreach ($moduleStyles as $key => $meta)
<option value="{{ $key }}" @selected(old('default_style.module_style', $style['module_style']) === $key)>{{ $meta['label'] }}</option>
@endforeach
</select>
</div>
<div>
<label for="error_correction" class="block text-[11px] font-medium text-slate-500">Error correction</label>
<select id="error_correction" name="default_style[error_correction]"
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">
@foreach (['L' => 'Low', 'M' => 'Medium', 'Q' => 'Quartile', 'H' => 'High'] as $key => $label)
<option value="{{ $key }}" @selected(old('default_style.error_correction', $style['error_correction']) === $key)>{{ $label }} ({{ $key }})</option>
@endforeach
</select>
</div>
</div>
<div class="mt-4 grid gap-4 sm:grid-cols-2">
<div>
<label for="finder_outer" class="block text-[11px] font-medium text-slate-500">Corner outer</label>
<select id="finder_outer" name="default_style[finder_outer]"
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">
@foreach ($cornerOuterStyles as $key => $meta)
<option value="{{ $key }}" @selected(old('default_style.finder_outer', $style['finder_outer']) === $key)>{{ $meta['label'] }}</option>
@endforeach
</select>
</div>
<div>
<label for="finder_inner" class="block text-[11px] font-medium text-slate-500">Corner inner</label>
<select id="finder_inner" name="default_style[finder_inner]"
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">
@foreach ($cornerInnerStyles as $key => $meta)
<option value="{{ $key }}" @selected(old('default_style.finder_inner', $style['finder_inner']) === $key)>{{ $meta['label'] }}</option>
@endforeach
</select>
</div>
</div>
<div class="mt-4 grid gap-4 sm:grid-cols-2">
<div>
<label for="frame_style" class="block text-[11px] font-medium text-slate-500">Frame</label>
<select id="frame_style" name="default_style[frame_style]"
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">
@foreach ($frameStyles as $key => $meta)
<option value="{{ $key }}" @selected(old('default_style.frame_style', $style['frame_style']) === $key)>{{ $meta['label'] }}</option>
@endforeach
</select>
</div>
<div>
<label for="frame_color" class="block text-[11px] font-medium text-slate-500">Frame color</label>
<input type="color" id="frame_color" name="default_style[frame_color]"
value="{{ old('default_style.frame_color', $style['frame_color']) }}"
class="mt-1 h-10 w-full cursor-pointer rounded-lg border border-slate-200 bg-white p-1">
</div>
</div>
<div class="mt-4">
<label for="frame_text" class="block text-[11px] font-medium text-slate-500">Frame label</label>
<input type="text" id="frame_text" name="default_style[frame_text]" maxlength="100"
value="{{ old('default_style.frame_text', $style['frame_text']) }}"
placeholder="SCAN ME"
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">
</div>
</div>
<button type="submit" class="rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-indigo-700">
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
View File
@@ -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');
+87
View File
@@ -0,0 +1,87 @@
<?php
namespace Tests\Feature;
use App\Models\QrSetting;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Tests\TestCase;
class QrSettingsTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Http::preventStrayRequests();
}
private function user(): User
{
return User::create(['public_id' => (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);
}
}