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 = [
'user_id',
'campaign_id',
'source_app',
'source_kind',
'source_ref',
@@ -40,6 +41,11 @@ class ShortLink extends Model
return $this->belongsTo(User::class);
}
public function campaign(): BelongsTo
{
return $this->belongsTo(Campaign::class);
}
public function clicks(): HasMany
{
return $this->hasMany(LinkClick::class);
+5
View File
@@ -49,6 +49,11 @@ class User extends Authenticatable
return $this->hasMany(ShortLink::class);
}
public function campaigns(): HasMany
{
return $this->hasMany(Campaign::class);
}
public function linkCustomDomains(): HasMany
{
return $this->hasMany(LinkCustomDomain::class);
+3 -2
View File
@@ -97,11 +97,12 @@ class AfiaService
{
return <<<PROMPT
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:
- 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).
- 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.
- Analytics: click totals and trends across 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.
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.
- 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.