Add Campaigns module for grouping short links.
Deploy Ladill Link / deploy (push) Successful in 1m26s
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user