From 95d73d1367004e4d412cac46637a0e835508c33f Mon Sep 17 00:00:00 2001 From: isaacclad Date: Thu, 16 Jul 2026 20:43:02 +0000 Subject: [PATCH] Add Campaigns module for grouping QR codes. Let accounts organise codes by promo or launch, attach or detach codes, and view combined scan totals from the sidebar. --- .../Controllers/Qr/CampaignController.php | 156 ++++++++++++++++++ app/Models/Campaign.php | 81 +++++++++ app/Models/QrCode.php | 6 + app/Models/User.php | 5 + app/Services/Afia/AfiaService.php | 3 +- ...26_07_16_100000_create_campaigns_table.php | 37 +++++ resources/views/partials/sidebar.blade.php | 2 + resources/views/qr/campaigns/_form.blade.php | 42 +++++ resources/views/qr/campaigns/create.blade.php | 20 +++ resources/views/qr/campaigns/index.blade.php | 100 +++++++++++ resources/views/qr/campaigns/show.blade.php | 115 +++++++++++++ routes/web.php | 10 ++ tests/Feature/CampaignTest.php | 123 ++++++++++++++ 13 files changed, 699 insertions(+), 1 deletion(-) create mode 100644 app/Http/Controllers/Qr/CampaignController.php create mode 100644 app/Models/Campaign.php create mode 100644 database/migrations/2026_07_16_100000_create_campaigns_table.php create mode 100644 resources/views/qr/campaigns/_form.blade.php create mode 100644 resources/views/qr/campaigns/create.blade.php create mode 100644 resources/views/qr/campaigns/index.blade.php create mode 100644 resources/views/qr/campaigns/show.blade.php create mode 100644 tests/Feature/CampaignTest.php diff --git a/app/Http/Controllers/Qr/CampaignController.php b/app/Http/Controllers/Qr/CampaignController.php new file mode 100644 index 0000000..d4b2678 --- /dev/null +++ b/app/Http/Controllers/Qr/CampaignController.php @@ -0,0 +1,156 @@ +where('user_id', $account->id) + ->withCount('qrCodes') + ->withSum('qrCodes', 'scans_total') + ->latest() + ->paginate(20) + ->withQueryString(); + + $base = Campaign::query()->where('user_id', $account->id); + + return view('qr.campaigns.index', [ + 'campaigns' => $campaigns, + 'campaignCount' => (clone $base)->count(), + 'activeCount' => (clone $base)->where('status', Campaign::STATUS_ACTIVE)->count(), + 'codesInCampaigns' => QrCode::query() + ->where('user_id', $account->id) + ->whereNotNull('campaign_id') + ->count(), + ]); + } + + public function create(): View + { + return view('qr.campaigns.create', [ + 'campaign' => new Campaign(['status' => Campaign::STATUS_ACTIVE]), + ]); + } + + public function store(Request $request): RedirectResponse + { + $account = ladill_account(); + $data = $this->validated($request); + + $campaign = Campaign::create([ + ...$data, + 'user_id' => $account->id, + ]); + + return redirect() + ->route('qr.campaigns.show', $campaign) + ->with('success', 'Campaign created.'); + } + + public function show(Campaign $campaign): View + { + $this->authorizeCampaign($campaign); + + $codes = $campaign->qrCodes()->latest()->get(); + $availableCodes = QrCode::query() + ->where('user_id', $campaign->user_id) + ->where(function ($q) use ($campaign) { + $q->whereNull('campaign_id')->orWhere('campaign_id', $campaign->id); + }) + ->orderBy('label') + ->get(); + + return view('qr.campaigns.show', [ + 'campaign' => $campaign, + 'codes' => $codes, + 'availableCodes' => $availableCodes, + 'totalScans' => (int) $codes->sum('scans_total'), + ]); + } + + public function update(Request $request, Campaign $campaign): RedirectResponse + { + $this->authorizeCampaign($campaign); + $campaign->update($this->validated($request)); + + return back()->with('success', 'Campaign updated.'); + } + + public function destroy(Campaign $campaign): RedirectResponse + { + $this->authorizeCampaign($campaign); + + QrCode::query() + ->where('campaign_id', $campaign->id) + ->update(['campaign_id' => null]); + + $campaign->delete(); + + return redirect() + ->route('qr.campaigns.index') + ->with('success', 'Campaign deleted. QR codes were unassigned, not removed.'); + } + + public function attach(Request $request, Campaign $campaign): RedirectResponse + { + $this->authorizeCampaign($campaign); + + $validated = $request->validate([ + 'qr_code_ids' => ['required', 'array', 'min:1'], + 'qr_code_ids.*' => ['integer'], + ]); + + QrCode::query() + ->where('user_id', $campaign->user_id) + ->whereIn('id', $validated['qr_code_ids']) + ->update(['campaign_id' => $campaign->id]); + + return back()->with('success', 'QR codes added to campaign.'); + } + + public function detach(Campaign $campaign, QrCode $qrCode): RedirectResponse + { + $this->authorizeCampaign($campaign); + abort_unless((int) $qrCode->user_id === (int) $campaign->user_id, 403); + abort_unless((int) $qrCode->campaign_id === (int) $campaign->id, 404); + + $qrCode->update(['campaign_id' => null]); + + return back()->with('success', 'QR code removed from campaign.'); + } + + /** @return array */ + private function validated(Request $request): array + { + $data = $request->validate([ + 'name' => ['required', 'string', 'max:120'], + 'description' => ['nullable', 'string', 'max:2000'], + 'status' => ['required', Rule::in(array_keys(Campaign::STATUSES))], + 'starts_at' => ['nullable', 'date'], + 'ends_at' => ['nullable', 'date', 'after_or_equal:starts_at'], + ]); + + $data['description'] = $data['description'] ?? null; + $data['starts_at'] = $data['starts_at'] ?? null; + $data['ends_at'] = $data['ends_at'] ?? null; + + return $data; + } + + private function authorizeCampaign(Campaign $campaign): void + { + abort_unless((int) $campaign->user_id === (int) ladill_account()->id, 403); + } +} diff --git a/app/Models/Campaign.php b/app/Models/Campaign.php new file mode 100644 index 0000000..6e12407 --- /dev/null +++ b/app/Models/Campaign.php @@ -0,0 +1,81 @@ + 'Draft', + self::STATUS_ACTIVE => 'Active', + self::STATUS_ARCHIVED => 'Archived', + ]; + + protected $fillable = [ + 'user_id', + 'name', + 'description', + 'status', + 'starts_at', + 'ends_at', + ]; + + protected function casts(): array + { + return [ + 'starts_at' => 'datetime', + 'ends_at' => 'datetime', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function qrCodes(): HasMany + { + return $this->hasMany(QrCode::class); + } + + public function statusLabel(): string + { + return self::STATUSES[$this->status] ?? ucfirst((string) $this->status); + } + + public function isLive(): bool + { + if ($this->status !== self::STATUS_ACTIVE) { + return false; + } + + if ($this->starts_at && $this->starts_at->isFuture()) { + return false; + } + + if ($this->ends_at && $this->ends_at->isPast()) { + return false; + } + + return true; + } + + public function totalScans(): int + { + return (int) $this->qrCodes()->sum('scans_total'); + } + + public function codesCount(): int + { + return (int) $this->qrCodes()->count(); + } +} diff --git a/app/Models/QrCode.php b/app/Models/QrCode.php index 676ec39..c71ca9d 100644 --- a/app/Models/QrCode.php +++ b/app/Models/QrCode.php @@ -30,6 +30,7 @@ class QrCode extends Model protected $fillable = [ 'user_id', + 'campaign_id', 'short_code', 'type', 'label', @@ -59,6 +60,11 @@ class QrCode extends Model return $this->belongsTo(User::class); } + public function campaign(): BelongsTo + { + return $this->belongsTo(Campaign::class); + } + public function document(): BelongsTo { return $this->belongsTo(QrDocument::class, 'qr_document_id'); diff --git a/app/Models/User.php b/app/Models/User.php index 23dae3b..2a6fdb2 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -58,6 +58,11 @@ class User extends Authenticatable return $this->hasMany(QrCode::class); } + public function campaigns(): HasMany + { + return $this->hasMany(Campaign::class); + } + public function qrSetting(): HasOne { return $this->hasOne(QrSetting::class); diff --git a/app/Services/Afia/AfiaService.php b/app/Services/Afia/AfiaService.php index 377ef7e..e8c9b54 100644 --- a/app/Services/Afia/AfiaService.php +++ b/app/Services/Afia/AfiaService.php @@ -106,6 +106,7 @@ class AfiaService - My Codes: list, create, and edit QR codes. Types: Link (URL), PDF, List of Links, Business profile, WiFi, App download. - Creating a code: pick a type, set content, customize style (colors, logo, frame), then download PNG/SVG/PDF. - Dynamic codes: destination can change after printing — the printed QR always points to ladill.com/q/{code}. + - Campaigns: group QR codes for a promo or launch (sidebar → Campaigns), attach/detach codes, and see combined scan totals. - Business QR: name, tagline, contact, hours, logo, cover, social links — shown as a mobile landing page. - WiFi QR: guests scan to join; network name and password are encoded in the code. - PDF QR: hosts a PDF with optional download button on the viewer. @@ -114,7 +115,7 @@ class AfiaService - Developers: API tokens for programmatic code creation (sidebar → Developers). Rules: - - Only answer questions about Ladill QR Plus — code types, styling, downloads, scans, wallet billing, team, and API. + - Only answer questions about Ladill QR Plus — code types, styling, downloads, campaigns, scans, wallet billing, team, and API. - If asked about domains, hosting, email, or payments/commerce QR (shop, events, donations), briefly say those live in other Ladill apps and suggest the app launcher. - Never invent prices, short codes, or wallet balances — use the user context below or tell them where to check. - For print tips: recommend high contrast, adequate quiet zone, test-scan before mass printing. diff --git a/database/migrations/2026_07_16_100000_create_campaigns_table.php b/database/migrations/2026_07_16_100000_create_campaigns_table.php new file mode 100644 index 0000000..9c88f6e --- /dev/null +++ b/database/migrations/2026_07_16_100000_create_campaigns_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->text('description')->nullable(); + $table->string('status', 20)->default('active')->index(); + $table->timestamp('starts_at')->nullable(); + $table->timestamp('ends_at')->nullable(); + $table->timestamps(); + + $table->index(['user_id', 'status']); + }); + + Schema::table('qr_codes', function (Blueprint $table) { + $table->foreignId('campaign_id')->nullable()->after('user_id')->constrained('campaigns')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('qr_codes', function (Blueprint $table) { + $table->dropConstrainedForeignId('campaign_id'); + }); + + Schema::dropIfExists('campaigns'); + } +}; diff --git a/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php index b1dea73..3e3e45c 100644 --- a/resources/views/partials/sidebar.blade.php +++ b/resources/views/partials/sidebar.blade.php @@ -10,6 +10,8 @@ 'icon' => ''], ['name' => 'My Codes', 'route' => route('user.qr-codes.index'), 'active' => request()->routeIs('user.qr-codes.*'), 'icon' => ''], + ['name' => 'Campaigns', 'route' => route('qr.campaigns.index'), 'active' => request()->routeIs('qr.campaigns.*'), + 'icon' => ''], ['name' => 'Analytics', 'route' => route('qr.analytics.index'), 'active' => request()->routeIs('qr.analytics.*'), 'icon' => ''], ]; diff --git a/resources/views/qr/campaigns/_form.blade.php b/resources/views/qr/campaigns/_form.blade.php new file mode 100644 index 0000000..9f53abc --- /dev/null +++ b/resources/views/qr/campaigns/_form.blade.php @@ -0,0 +1,42 @@ +
+ + + @error('name')

{{ $message }}

@enderror +
+ +
+ + + @error('description')

{{ $message }}

@enderror +
+ +
+ + + @error('status')

{{ $message }}

@enderror +
+ +
+
+ + + @error('starts_at')

{{ $message }}

@enderror +
+
+ + + @error('ends_at')

{{ $message }}

@enderror +
+
diff --git a/resources/views/qr/campaigns/create.blade.php b/resources/views/qr/campaigns/create.blade.php new file mode 100644 index 0000000..76d6267 --- /dev/null +++ b/resources/views/qr/campaigns/create.blade.php @@ -0,0 +1,20 @@ + + New campaign + +
+
+ ← Campaigns +

New campaign

+

Name a launch or promo, then attach QR codes on the next screen.

+
+ +
+ @csrf + @include('qr.campaigns._form', ['campaign' => $campaign]) +
+ Cancel + +
+
+
+
diff --git a/resources/views/qr/campaigns/index.blade.php b/resources/views/qr/campaigns/index.blade.php new file mode 100644 index 0000000..513cac6 --- /dev/null +++ b/resources/views/qr/campaigns/index.blade.php @@ -0,0 +1,100 @@ +@php + $statusColor = [ + 'draft' => 'bg-slate-100 text-slate-700', + 'active' => 'bg-emerald-50 text-emerald-700', + 'archived' => 'bg-amber-50 text-amber-800', + ]; +@endphp + + Campaigns + +
+ @foreach (['success', 'error'] as $flash) + @if (session($flash)) +
+

{{ session($flash) }}

+
+ @endif + @endforeach + +
+
+
+
+
+
+ Group codes · Track performance +
+

Campaigns

+

+ Organise QR codes by launch, promo, or channel and see combined scan totals in one place. +

+ +
+ +
+
+

{{ number_format($campaignCount) }}

+

Campaigns

+
+
+

{{ number_format($activeCount) }}

+

Active

+
+
+

{{ number_format($codesInCampaigns) }}

+

Codes assigned

+
+
+
+
+
+ +
+
+

All campaigns

+
+ @if ($campaigns->isEmpty()) +
+

No campaigns yet. Create one to group QR codes for a promo or launch.

+
+ @else + + + + + + + + + + + @foreach ($campaigns as $campaign) + + + + + + + @endforeach + +
CampaignStatus
+

{{ $campaign->name }}

+ @if ($campaign->description) +

{{ $campaign->description }}

+ @endif +
+ + {{ $campaign->statusLabel() }} + +
+
{{ $campaigns->links() }}
+ @endif +
+
+
diff --git a/resources/views/qr/campaigns/show.blade.php b/resources/views/qr/campaigns/show.blade.php new file mode 100644 index 0000000..51b136c --- /dev/null +++ b/resources/views/qr/campaigns/show.blade.php @@ -0,0 +1,115 @@ +@php + $statusColor = [ + 'draft' => 'bg-slate-100 text-slate-700', + 'active' => 'bg-emerald-50 text-emerald-700', + 'archived' => 'bg-amber-50 text-amber-800', + ]; +@endphp + + {{ $campaign->name }} + +
+ @foreach (['success', 'error'] as $flash) + @if (session($flash)) +
+

{{ session($flash) }}

+
+ @endif + @endforeach + +
+
+ ← Campaigns +
+

{{ $campaign->name }}

+ + {{ $campaign->statusLabel() }} + +
+ @if ($campaign->description) +

{{ $campaign->description }}

+ @endif +
+
+ @csrf @method('DELETE') + +
+
+ +
+
+

{{ number_format($codes->count()) }}

+

QR codes

+
+
+

{{ number_format($totalScans) }}

+

Total scans

+
+
+

+ @if ($campaign->starts_at || $campaign->ends_at) + {{ optional($campaign->starts_at)->format('d M Y') ?? '…' }} + → + {{ optional($campaign->ends_at)->format('d M Y') ?? '…' }} + @else + No date range + @endif +

+

Schedule

+
+
+ +
+ @csrf @method('PATCH') +

Campaign details

+ @include('qr.campaigns._form', ['campaign' => $campaign]) + +
+ +
+
+

QR codes in this campaign

+
+ @if ($codes->isEmpty()) +

No codes assigned yet.

+ @else + + @endif +
+ + @php + $unassigned = $availableCodes->whereNull('campaign_id'); + @endphp + @if ($unassigned->isNotEmpty()) +
+ @csrf +

Add QR codes

+
+ @foreach ($unassigned as $code) + + @endforeach +
+ +
+ @endif +
+
diff --git a/routes/web.php b/routes/web.php index 52ce4db..7e9ca63 100644 --- a/routes/web.php +++ b/routes/web.php @@ -9,6 +9,7 @@ use App\Http\Controllers\Qr\AccountController; use App\Http\Controllers\Qr\AfiaController; use App\Http\Controllers\Qr\AnalyticsController; use App\Http\Controllers\Qr\DeveloperController; +use App\Http\Controllers\Qr\CampaignController; use App\Http\Controllers\Qr\OverviewController; use App\Http\Controllers\Qr\TeamController; use App\Http\Controllers\Qr\QrCodeController; @@ -50,6 +51,15 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::post('/afia/chat', [AfiaController::class, 'chat'])->name('qr.afia.chat'); Route::get('/search', SearchController::class)->name('qr.search'); + Route::get('/campaigns', [CampaignController::class, 'index'])->name('qr.campaigns.index'); + Route::get('/campaigns/create', [CampaignController::class, 'create'])->name('qr.campaigns.create'); + Route::post('/campaigns', [CampaignController::class, 'store'])->name('qr.campaigns.store'); + Route::get('/campaigns/{campaign}', [CampaignController::class, 'show'])->name('qr.campaigns.show'); + Route::patch('/campaigns/{campaign}', [CampaignController::class, 'update'])->name('qr.campaigns.update'); + Route::delete('/campaigns/{campaign}', [CampaignController::class, 'destroy'])->name('qr.campaigns.destroy'); + Route::post('/campaigns/{campaign}/attach', [CampaignController::class, 'attach'])->name('qr.campaigns.attach'); + Route::delete('/campaigns/{campaign}/codes/{qrCode}', [CampaignController::class, 'detach'])->name('qr.campaigns.detach'); + Route::get('/qr-codes', [QrCodeController::class, 'index'])->name('user.qr-codes.index'); Route::get('/qr-codes/create', [QrCodeController::class, 'create'])->name('user.qr-codes.create'); Route::post('/qr-codes', [QrCodeController::class, 'store'])->name('user.qr-codes.store'); diff --git a/tests/Feature/CampaignTest.php b/tests/Feature/CampaignTest.php new file mode 100644 index 0000000..12b6a47 --- /dev/null +++ b/tests/Feature/CampaignTest.php @@ -0,0 +1,123 @@ +withoutMiddleware(EnsurePlatformSession::class); + if (method_exists($this, 'withoutVite')) { + $this->withoutVite(); + } + } + + private function user(): User + { + return User::create([ + 'public_id' => (string) Str::uuid(), + 'name' => 'Owner', + 'email' => 'owner+'.uniqid().'@example.com', + ]); + } + + private function code(User $user, array $attrs = []): QrCode + { + return QrCode::create(array_merge([ + 'user_id' => $user->id, + 'short_code' => strtolower(Str::random(8)), + 'type' => QrCode::TYPE_URL, + 'label' => 'Test code', + 'destination_url' => 'https://example.com', + 'is_active' => true, + 'scans_total' => 5, + 'unique_scans_total' => 3, + ], $attrs)); + } + + public function test_owner_can_create_campaign_and_attach_codes(): void + { + $user = $this->user(); + $code = $this->code($user); + + $this->actingAs($user) + ->post(route('qr.campaigns.store'), [ + 'name' => 'Spring promo', + 'description' => 'April flyers', + 'status' => Campaign::STATUS_ACTIVE, + ]) + ->assertRedirect(); + + $campaign = Campaign::first(); + $this->assertNotNull($campaign); + $this->assertSame('Spring promo', $campaign->name); + $this->assertSame($user->id, $campaign->user_id); + + $this->actingAs($user) + ->post(route('qr.campaigns.attach', $campaign), [ + 'qr_code_ids' => [$code->id], + ]) + ->assertRedirect() + ->assertSessionHas('success'); + + $this->assertSame($campaign->id, $code->fresh()->campaign_id); + + $this->actingAs($user) + ->get(route('qr.campaigns.show', $campaign)) + ->assertOk() + ->assertSee('Spring promo') + ->assertSee($code->short_code); + } + + public function test_owner_can_detach_and_delete_campaign(): void + { + $user = $this->user(); + $campaign = Campaign::create([ + 'user_id' => $user->id, + 'name' => 'Temp', + 'status' => Campaign::STATUS_ACTIVE, + ]); + $code = $this->code($user, ['campaign_id' => $campaign->id]); + + $this->actingAs($user) + ->delete(route('qr.campaigns.detach', [$campaign, $code])) + ->assertRedirect(); + + $this->assertNull($code->fresh()->campaign_id); + + $code->update(['campaign_id' => $campaign->id]); + + $this->actingAs($user) + ->delete(route('qr.campaigns.destroy', $campaign)) + ->assertRedirect(route('qr.campaigns.index')); + + $this->assertDatabaseMissing('campaigns', ['id' => $campaign->id]); + $this->assertNull($code->fresh()->campaign_id); + } + + public function test_other_user_cannot_view_campaign(): void + { + $owner = $this->user(); + $other = $this->user(); + $campaign = Campaign::create([ + 'user_id' => $owner->id, + 'name' => 'Private', + 'status' => Campaign::STATUS_ACTIVE, + ]); + + $this->actingAs($other) + ->get(route('qr.campaigns.show', $campaign)) + ->assertForbidden(); + } +}