Add Campaigns module for grouping short links.
Deploy Ladill Link / deploy (push) Successful in 1m26s

Let accounts organise links by promo or launch, attach or detach links, and view combined click totals from the sidebar.
This commit is contained in:
isaacclad
2026-07-16 20:43:11 +00:00
parent 32a174c826
commit f45fdcd159
13 changed files with 702 additions and 2 deletions
@@ -0,0 +1,156 @@
<?php
namespace App\Http\Controllers\Link;
use App\Http\Controllers\Controller;
use App\Models\Campaign;
use App\Models\ShortLink;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
class CampaignController extends Controller
{
public function index(Request $request): View
{
$account = ladill_account();
$campaigns = Campaign::query()
->where('user_id', $account->id)
->withCount('shortLinks')
->withSum('shortLinks', 'clicks_total')
->latest()
->paginate(20)
->withQueryString();
$base = Campaign::query()->where('user_id', $account->id);
return view('links.campaigns.index', [
'campaigns' => $campaigns,
'campaignCount' => (clone $base)->count(),
'activeCount' => (clone $base)->where('status', Campaign::STATUS_ACTIVE)->count(),
'linksInCampaigns' => ShortLink::query()
->where('user_id', $account->id)
->whereNotNull('campaign_id')
->count(),
]);
}
public function create(): View
{
return view('links.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('link.campaigns.show', $campaign)
->with('success', 'Campaign created.');
}
public function show(Campaign $campaign): View
{
$this->authorizeCampaign($campaign);
$links = $campaign->shortLinks()->latest()->get();
$availableLinks = ShortLink::query()
->where('user_id', $campaign->user_id)
->where(function ($q) use ($campaign) {
$q->whereNull('campaign_id')->orWhere('campaign_id', $campaign->id);
})
->orderBy('slug')
->get();
return view('links.campaigns.show', [
'campaign' => $campaign,
'links' => $links,
'availableLinks' => $availableLinks,
'totalClicks' => (int) $links->sum('clicks_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);
ShortLink::query()
->where('campaign_id', $campaign->id)
->update(['campaign_id' => null]);
$campaign->delete();
return redirect()
->route('link.campaigns.index')
->with('success', 'Campaign deleted. Links were unassigned, not removed.');
}
public function attach(Request $request, Campaign $campaign): RedirectResponse
{
$this->authorizeCampaign($campaign);
$validated = $request->validate([
'short_link_ids' => ['required', 'array', 'min:1'],
'short_link_ids.*' => ['integer'],
]);
ShortLink::query()
->where('user_id', $campaign->user_id)
->whereIn('id', $validated['short_link_ids'])
->update(['campaign_id' => $campaign->id]);
return back()->with('success', 'Links added to campaign.');
}
public function detach(Campaign $campaign, ShortLink $link): RedirectResponse
{
$this->authorizeCampaign($campaign);
abort_unless((int) $link->user_id === (int) $campaign->user_id, 403);
abort_unless((int) $link->campaign_id === (int) $campaign->id, 404);
$link->update(['campaign_id' => null]);
return back()->with('success', 'Link removed from campaign.');
}
/** @return array<string, mixed> */
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);
}
}
+81
View File
@@ -0,0 +1,81 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Campaign extends Model
{
public const STATUS_DRAFT = 'draft';
public const STATUS_ACTIVE = 'active';
public const STATUS_ARCHIVED = 'archived';
public const STATUSES = [
self::STATUS_DRAFT => '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 shortLinks(): HasMany
{
return $this->hasMany(ShortLink::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 totalClicks(): int
{
return (int) $this->shortLinks()->sum('clicks_total');
}
public function linksCount(): int
{
return (int) $this->shortLinks()->count();
}
}
+6
View File
@@ -11,6 +11,7 @@ class ShortLink extends Model
{ {
protected $fillable = [ protected $fillable = [
'user_id', 'user_id',
'campaign_id',
'source_app', 'source_app',
'source_kind', 'source_kind',
'source_ref', 'source_ref',
@@ -40,6 +41,11 @@ class ShortLink extends Model
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
} }
public function campaign(): BelongsTo
{
return $this->belongsTo(Campaign::class);
}
public function clicks(): HasMany public function clicks(): HasMany
{ {
return $this->hasMany(LinkClick::class); return $this->hasMany(LinkClick::class);
+5
View File
@@ -49,6 +49,11 @@ class User extends Authenticatable
return $this->hasMany(ShortLink::class); return $this->hasMany(ShortLink::class);
} }
public function campaigns(): HasMany
{
return $this->hasMany(Campaign::class);
}
public function linkCustomDomains(): HasMany public function linkCustomDomains(): HasMany
{ {
return $this->hasMany(LinkCustomDomain::class); return $this->hasMany(LinkCustomDomain::class);
+3 -2
View File
@@ -97,11 +97,12 @@ class AfiaService
{ {
return <<<PROMPT return <<<PROMPT
You are Afia, the assistant inside Ladill Link (link.ladill.com). You are Afia, the assistant inside Ladill Link (link.ladill.com).
Help users create and manage short links, track clicks, use custom domains, and understand wallet billing. Be concise, friendly, and actionable: short numbered steps and name the screen or section to use. Help users create and manage short links, track clicks, use custom domains, campaigns, and understand wallet billing. Be concise, friendly, and actionable: short numbered steps and name the screen or section to use.
What Ladill Link does and where things live: What Ladill Link does and where things live:
- Overview (Dashboard): link count, recent clicks, and wallet balance. - Overview (Dashboard): link count, recent clicks, and wallet balance.
- My Links: create, edit, enable/disable, and delete short links. Each new link debits a small fee from the Ladill wallet (see Settings or the create screen for the current price). - My Links: create, edit, enable/disable, and delete short links. Each new link debits a small fee from the Ladill wallet (see Settings or the create screen for the current price).
- Campaigns: group short links for a promo or launch, attach/detach links, and see combined click totals.
- Public URLs: default domain is configured (usually ladl.link/{slug}) and always resolved live custom domains from Settings replace it when set as default. - Public URLs: default domain is configured (usually ladl.link/{slug}) and always resolved live custom domains from Settings replace it when set as default.
- Analytics: click totals and trends across links. - Analytics: click totals and trends across links.
- Custom Domains: connect a domain you own, verify DNS, and set a default domain for new links. - Custom Domains: connect a domain you own, verify DNS, and set a default domain for new links.
@@ -110,7 +111,7 @@ class AfiaService
- Wallet: link creation is paid from the Ladill wallet; top up at account.ladill.com/wallet. - Wallet: link creation is paid from the Ladill wallet; top up at account.ladill.com/wallet.
Rules: Rules:
- Only answer questions about Ladill Link short links, slugs, destinations, clicks, custom domains, billing, and the unified link list. - Only answer questions about Ladill Link short links, slugs, destinations, campaigns, clicks, custom domains, billing, and the unified link list.
- If asked about QR code design, donations, events, or storefronts, briefly say those live in other Ladill apps and suggest the app launcher. - If asked about QR code design, donations, events, or storefronts, briefly say those live in other Ladill apps and suggest the app launcher.
- Never invent slugs, click counts, or wallet balances use the user context below or tell them where to check. - Never invent slugs, click counts, or wallet balances use the user context below or tell them where to check.
- For DNS/custom domain help: mention verifying the TXT/CNAME record in Custom Domains before the domain goes live. - For DNS/custom domain help: mention verifying the TXT/CNAME record in Custom Domains before the domain goes live.
@@ -0,0 +1,37 @@
<?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('campaigns', function (Blueprint $table) {
$table->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('short_links', function (Blueprint $table) {
$table->foreignId('campaign_id')->nullable()->after('user_id')->constrained('campaigns')->nullOnDelete();
});
}
public function down(): void
{
Schema::table('short_links', function (Blueprint $table) {
$table->dropConstrainedForeignId('campaign_id');
});
Schema::dropIfExists('campaigns');
}
};
@@ -0,0 +1,42 @@
<div>
<label for="name" class="block text-sm font-medium text-slate-700">Name</label>
<input type="text" name="name" id="name" required value="{{ old('name', $campaign->name) }}"
class="mt-1 block w-full rounded-lg border-slate-300 shadow-sm focus:border-emerald-500 focus:ring-emerald-500"
placeholder="e.g. Product launch · April">
@error('name')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
<div>
<label for="description" class="block text-sm font-medium text-slate-700">Description <span class="font-normal text-slate-400">(optional)</span></label>
<textarea name="description" id="description" rows="3"
class="mt-1 block w-full rounded-lg border-slate-300 shadow-sm focus:border-emerald-500 focus:ring-emerald-500"
placeholder="What this campaign is for">{{ old('description', $campaign->description) }}</textarea>
@error('description')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
<div>
<label for="status" class="block text-sm font-medium text-slate-700">Status</label>
<select name="status" id="status" class="mt-1 block w-full rounded-lg border-slate-300 shadow-sm focus:border-emerald-500 focus:ring-emerald-500">
@foreach (\App\Models\Campaign::STATUSES as $value => $label)
<option value="{{ $value }}" @selected(old('status', $campaign->status ?? 'active') === $value)>{{ $label }}</option>
@endforeach
</select>
@error('status')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label for="starts_at" class="block text-sm font-medium text-slate-700">Starts <span class="font-normal text-slate-400">(optional)</span></label>
<input type="date" name="starts_at" id="starts_at"
value="{{ old('starts_at', optional($campaign->starts_at)->format('Y-m-d')) }}"
class="mt-1 block w-full rounded-lg border-slate-300 shadow-sm focus:border-emerald-500 focus:ring-emerald-500">
@error('starts_at')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
<div>
<label for="ends_at" class="block text-sm font-medium text-slate-700">Ends <span class="font-normal text-slate-400">(optional)</span></label>
<input type="date" name="ends_at" id="ends_at"
value="{{ old('ends_at', optional($campaign->ends_at)->format('Y-m-d')) }}"
class="mt-1 block w-full rounded-lg border-slate-300 shadow-sm focus:border-emerald-500 focus:ring-emerald-500">
@error('ends_at')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
</div>
@@ -0,0 +1,20 @@
<x-user-layout>
<x-slot name="title">New campaign</x-slot>
<div class="mx-auto max-w-xl space-y-6">
<div>
<a href="{{ route('link.campaigns.index') }}" class="text-sm text-slate-500 hover:text-slate-700">&larr; Campaigns</a>
<h1 class="mt-2 text-2xl font-semibold text-slate-900">New campaign</h1>
<p class="mt-1 text-sm text-slate-500">Name a launch or promo, then attach short links on the next screen.</p>
</div>
<form method="POST" action="{{ route('link.campaigns.store') }}" class="space-y-5 rounded-xl border border-slate-200 bg-white p-6">
@csrf
@include('links.campaigns._form', ['campaign' => $campaign])
<div class="flex justify-end gap-3">
<a href="{{ route('link.campaigns.index') }}" class="rounded-xl px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-50">Cancel</a>
<button type="submit" class="btn-primary">Create campaign</button>
</div>
</form>
</div>
</x-user-layout>
@@ -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
<x-user-layout>
<x-slot name="title">Campaigns</x-slot>
<div class="space-y-6">
@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-emerald-50/80 via-white to-teal-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-emerald-100 px-3 py-1 text-xs font-semibold text-emerald-700">
Group links · Track performance
</div>
<h1 class="mt-3 text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">Campaigns</h1>
<p class="mt-2 text-sm leading-6 text-slate-600">
Organise short links by launch, promo, or channel and see combined click totals in one place.
</p>
<div class="mt-6">
<a href="{{ route('link.campaigns.create') }}" class="btn-primary">
<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="M12 4.5v15m7.5-7.5h-15"/></svg>
New campaign
</a>
</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">{{ number_format($campaignCount) }}</p>
<p class="mt-0.5 whitespace-nowrap text-[11px] font-medium text-slate-500">Campaigns</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($activeCount) }}</p>
<p class="mt-0.5 whitespace-nowrap text-[11px] font-medium text-slate-500">Active</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($linksInCampaigns) }}</p>
<p class="mt-0.5 whitespace-nowrap text-[11px] font-medium text-slate-500">Links assigned</p>
</div>
</div>
</div>
</div>
</div>
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white">
<div class="border-b border-slate-100 px-6 py-4">
<h2 class="text-sm font-semibold text-slate-900">All campaigns</h2>
</div>
@if ($campaigns->isEmpty())
<div class="px-6 py-12 text-center">
<p class="text-sm text-slate-500">No campaigns yet. Create one to group short links for a promo or launch.</p>
</div>
@else
<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-5 py-3">Campaign</th>
<th class="hidden px-5 py-3 sm:table-cell">Links</th>
<th class="hidden px-5 py-3 md:table-cell">Clicks</th>
<th class="px-5 py-3">Status</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50">
@foreach ($campaigns as $campaign)
<tr class="cursor-pointer hover:bg-slate-50" onclick="window.location='{{ route('link.campaigns.show', $campaign) }}'">
<td class="px-5 py-3">
<p class="font-medium text-slate-900">{{ $campaign->name }}</p>
@if ($campaign->description)
<p class="mt-0.5 line-clamp-1 text-xs text-slate-500">{{ $campaign->description }}</p>
@endif
</td>
<td class="hidden px-5 py-3 text-slate-600 sm:table-cell">{{ number_format($campaign->short_links_count) }}</td>
<td class="hidden px-5 py-3 text-slate-600 md:table-cell">{{ number_format((int) $campaign->short_links_sum_clicks_total) }}</td>
<td class="px-5 py-3">
<span class="rounded-full px-2 py-0.5 text-xs font-medium {{ $statusColor[$campaign->status] ?? 'bg-slate-100 text-slate-700' }}">
{{ $campaign->statusLabel() }}
</span>
</td>
</tr>
@endforeach
</tbody>
</table>
<div class="border-t border-slate-100 px-5 py-3">{{ $campaigns->links() }}</div>
@endif
</div>
</div>
</x-user-layout>
@@ -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
<x-user-layout>
<x-slot name="title">{{ $campaign->name }}</x-slot>
<div class="mx-auto max-w-5xl space-y-6">
@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="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<a href="{{ route('link.campaigns.index') }}" class="text-sm text-slate-500 hover:text-slate-700">&larr; Campaigns</a>
<div class="mt-2 flex flex-wrap items-center gap-2">
<h1 class="text-2xl font-semibold text-slate-900">{{ $campaign->name }}</h1>
<span class="rounded-full px-2 py-0.5 text-xs font-medium {{ $statusColor[$campaign->status] ?? 'bg-slate-100 text-slate-700' }}">
{{ $campaign->statusLabel() }}
</span>
</div>
@if ($campaign->description)
<p class="mt-1 text-sm text-slate-600">{{ $campaign->description }}</p>
@endif
</div>
<form method="POST" action="{{ route('link.campaigns.destroy', $campaign) }}" onsubmit="return confirm('Delete this campaign? Links stay; they are only unassigned.')">
@csrf @method('DELETE')
<button type="submit" class="text-sm text-red-600 hover:text-red-700">Delete campaign</button>
</form>
</div>
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3">
<div class="rounded-xl border border-slate-200 bg-white p-4">
<p class="text-2xl font-bold text-slate-900">{{ number_format($links->count()) }}</p>
<p class="mt-0.5 text-xs text-slate-500">Short links</p>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-4">
<p class="text-2xl font-bold text-slate-900">{{ number_format($totalClicks) }}</p>
<p class="mt-0.5 text-xs text-slate-500">Total clicks</p>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-4 col-span-2 sm:col-span-1">
<p class="text-sm font-semibold text-slate-900">
@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
</p>
<p class="mt-0.5 text-xs text-slate-500">Schedule</p>
</div>
</div>
<form method="POST" action="{{ route('link.campaigns.update', $campaign) }}" class="space-y-5 rounded-xl border border-slate-200 bg-white p-6">
@csrf @method('PATCH')
<h2 class="text-base font-semibold text-slate-900">Campaign details</h2>
@include('links.campaigns._form', ['campaign' => $campaign])
<button type="submit" class="btn-primary">Save changes</button>
</form>
<div class="rounded-xl border border-slate-200 bg-white">
<div class="border-b border-slate-100 px-6 py-4">
<h2 class="text-base font-semibold text-slate-900">Links in this campaign</h2>
</div>
@if ($links->isEmpty())
<p class="px-6 py-8 text-center text-sm text-slate-500">No links assigned yet.</p>
@else
<ul class="divide-y divide-slate-100">
@foreach ($links as $link)
<li class="flex items-center justify-between gap-4 px-6 py-3">
<a href="{{ route('user.links.show', $link) }}" class="min-w-0 flex-1 hover:text-emerald-700">
<p class="truncate text-sm font-medium text-slate-900">{{ $link->label ?: $link->slug }}</p>
<p class="truncate text-xs text-slate-500">{{ $link->publicUrl() }} · {{ number_format($link->clicks_total) }} clicks</p>
</a>
<form method="POST" action="{{ route('link.campaigns.detach', [$campaign, $link]) }}">
@csrf @method('DELETE')
<button type="submit" class="text-xs font-medium text-red-600 hover:text-red-800">Remove</button>
</form>
</li>
@endforeach
</ul>
@endif
</div>
@php
$unassigned = $availableLinks->whereNull('campaign_id');
@endphp
@if ($unassigned->isNotEmpty())
<form method="POST" action="{{ route('link.campaigns.attach', $campaign) }}" class="space-y-4 rounded-xl border border-slate-200 bg-white p-6">
@csrf
<h2 class="text-base font-semibold text-slate-900">Add short links</h2>
<div class="max-h-56 space-y-2 overflow-y-auto rounded-lg border border-slate-100 p-3">
@foreach ($unassigned as $link)
<label class="flex items-center gap-3 rounded-lg px-2 py-1.5 text-sm hover:bg-slate-50">
<input type="checkbox" name="short_link_ids[]" value="{{ $link->id }}" class="rounded border-slate-300 text-emerald-600 focus:ring-emerald-500">
<span class="min-w-0 flex-1 truncate">
<span class="font-medium text-slate-900">{{ $link->label ?: $link->slug }}</span>
<span class="text-slate-400"> · {{ $link->slug }}</span>
</span>
</label>
@endforeach
</div>
<button type="submit" class="btn-primary">Add selected</button>
</form>
@endif
</div>
</x-user-layout>
@@ -10,6 +10,8 @@
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12 11.2 3.05c.44-.44 1.15-.44 1.59 0L21.75 12M4.5 9.75v10.5a.75.75 0 0 0 .75.75H9.75v-6a.75.75 0 0 1 .75-.75h3a.75.75 0 0 1 .75.75v6h4.5a.75.75 0 0 0 .75-.75V9.75" />'], 'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12 11.2 3.05c.44-.44 1.15-.44 1.59 0L21.75 12M4.5 9.75v10.5a.75.75 0 0 0 .75.75H9.75v-6a.75.75 0 0 1 .75-.75h3a.75.75 0 0 1 .75.75v6h4.5a.75.75 0 0 0 .75-.75V9.75" />'],
['name' => 'My Links', 'route' => route('user.links.index'), 'active' => request()->routeIs('user.links.*'), ['name' => 'My Links', 'route' => route('user.links.index'), 'active' => request()->routeIs('user.links.*'),
'icon' => '<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" />'], 'icon' => '<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" />'],
['name' => 'Campaigns', 'route' => route('link.campaigns.index'), 'active' => request()->routeIs('link.campaigns.*'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 1 1 0-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 0 1-1.44-4.282m3.102.069a18.03 18.03 0 0 1-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 0 1 8.789 2.553c.56.246 1.198-.004 1.434-.56a11.958 11.958 0 0 0 1.15-4.506c.02-.292.02-.585 0-.877a11.958 11.958 0 0 0-1.15-4.506c-.236-.556-.874-.806-1.434-.56a23.848 23.848 0 0 1-8.789 2.553m0 0a23.948 23.948 0 0 0 0 5.634" />'],
['name' => 'Analytics', 'route' => route('link.analytics.index'), 'active' => request()->routeIs('link.analytics.*'), ['name' => 'Analytics', 'route' => route('link.analytics.index'), 'active' => request()->routeIs('link.analytics.*'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" />'], 'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" />'],
['name' => 'Custom Domains', 'route' => route('link.domains.index'), 'active' => request()->routeIs('link.domains.*'), ['name' => 'Custom Domains', 'route' => route('link.domains.index'), 'active' => request()->routeIs('link.domains.*'),
+11
View File
@@ -3,6 +3,7 @@
use App\Http\Controllers\Auth\SsoLoginController; use App\Http\Controllers\Auth\SsoLoginController;
use App\Http\Controllers\Link\AfiaController; use App\Http\Controllers\Link\AfiaController;
use App\Http\Controllers\Link\AnalyticsController; use App\Http\Controllers\Link\AnalyticsController;
use App\Http\Controllers\Link\CampaignController;
use App\Http\Controllers\Link\CustomDomainController; use App\Http\Controllers\Link\CustomDomainController;
use App\Http\Controllers\Link\LinkController; use App\Http\Controllers\Link\LinkController;
use App\Http\Controllers\Link\OverviewController; use App\Http\Controllers\Link\OverviewController;
@@ -36,6 +37,16 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
Route::get('/dashboard', [OverviewController::class, 'index'])->name('link.dashboard'); Route::get('/dashboard', [OverviewController::class, 'index'])->name('link.dashboard');
Route::get('/analytics', [AnalyticsController::class, 'index'])->name('link.analytics.index'); Route::get('/analytics', [AnalyticsController::class, 'index'])->name('link.analytics.index');
Route::get('/campaigns', [CampaignController::class, 'index'])->name('link.campaigns.index');
Route::get('/campaigns/create', [CampaignController::class, 'create'])->name('link.campaigns.create');
Route::post('/campaigns', [CampaignController::class, 'store'])->name('link.campaigns.store');
Route::get('/campaigns/{campaign}', [CampaignController::class, 'show'])->name('link.campaigns.show');
Route::patch('/campaigns/{campaign}', [CampaignController::class, 'update'])->name('link.campaigns.update');
Route::delete('/campaigns/{campaign}', [CampaignController::class, 'destroy'])->name('link.campaigns.destroy');
Route::post('/campaigns/{campaign}/attach', [CampaignController::class, 'attach'])->name('link.campaigns.attach');
Route::delete('/campaigns/{campaign}/links/{link}', [CampaignController::class, 'detach'])->name('link.campaigns.detach');
Route::get('/domains', [CustomDomainController::class, 'index'])->name('link.domains.index'); Route::get('/domains', [CustomDomainController::class, 'index'])->name('link.domains.index');
Route::post('/domains', [CustomDomainController::class, 'store'])->name('link.domains.store'); Route::post('/domains', [CustomDomainController::class, 'store'])->name('link.domains.store');
Route::post('/domains/{customDomain}/verify', [CustomDomainController::class, 'verify'])->name('link.domains.verify'); Route::post('/domains/{customDomain}/verify', [CustomDomainController::class, 'verify'])->name('link.domains.verify');
+124
View File
@@ -0,0 +1,124 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\Campaign;
use App\Models\ShortLink;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Tests\TestCase;
class CampaignTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->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 link(User $user, array $attrs = []): ShortLink
{
return ShortLink::create(array_merge([
'user_id' => $user->id,
'slug' => strtolower(Str::random(8)),
'source_app' => 'link',
'source_kind' => 'redirect',
'is_managed_here' => true,
'destination_url' => 'https://example.com',
'is_active' => true,
'clicks_total' => 12,
'unique_clicks_total' => 7,
], $attrs));
}
public function test_owner_can_create_campaign_and_attach_links(): void
{
$user = $this->user();
$link = $this->link($user);
$this->actingAs($user)
->post(route('link.campaigns.store'), [
'name' => 'Launch week',
'description' => 'Homepage CTAs',
'status' => Campaign::STATUS_ACTIVE,
])
->assertRedirect();
$campaign = Campaign::first();
$this->assertNotNull($campaign);
$this->assertSame('Launch week', $campaign->name);
$this->actingAs($user)
->post(route('link.campaigns.attach', $campaign), [
'short_link_ids' => [$link->id],
])
->assertRedirect()
->assertSessionHas('success');
$this->assertSame($campaign->id, $link->fresh()->campaign_id);
$this->actingAs($user)
->get(route('link.campaigns.show', $campaign))
->assertOk()
->assertSee('Launch week')
->assertSee($link->slug);
}
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,
]);
$link = $this->link($user, ['campaign_id' => $campaign->id]);
$this->actingAs($user)
->delete(route('link.campaigns.detach', [$campaign, $link]))
->assertRedirect();
$this->assertNull($link->fresh()->campaign_id);
$link->update(['campaign_id' => $campaign->id]);
$this->actingAs($user)
->delete(route('link.campaigns.destroy', $campaign))
->assertRedirect(route('link.campaigns.index'));
$this->assertDatabaseMissing('campaigns', ['id' => $campaign->id]);
$this->assertNull($link->fresh()->campaign_id);
}
public function test_index_lists_campaigns(): void
{
$user = $this->user();
Campaign::create([
'user_id' => $user->id,
'name' => 'Visible campaign',
'status' => Campaign::STATUS_ACTIVE,
]);
$this->actingAs($user)
->get(route('link.campaigns.index'))
->assertOk()
->assertSee('Visible campaign')
->assertSee('New campaign');
}
}