Add Ladill Link URL shortener on ladl.link.

Management UI at link.ladill.com with GHS 0.05 per link wallet billing,
click analytics, and legacy fallback to ladill.com/q for QR ecosystem codes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-27 10:58:41 +00:00
co-authored by Cursor
parent 04e4f6ab51
commit d9c91ad7d8
30 changed files with 1002 additions and 247 deletions
@@ -0,0 +1,118 @@
<?php
namespace App\Http\Controllers\Link;
use App\Http\Controllers\Controller;
use App\Models\ShortLink;
use App\Services\Link\LinkBillingService;
use App\Services\Link\LinkManagerService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use RuntimeException;
class LinkController extends Controller
{
public function __construct(
private LinkManagerService $links,
private LinkBillingService $billing,
) {}
public function index(Request $request): View
{
$account = ladill_account();
$links = ShortLink::query()
->where('user_id', $account->id)
->latest()
->paginate(20);
return view('links.index', [
'links' => $links,
'canCreate' => $this->billing->canCreate($account),
'pricePerLink' => \App\Models\LinkWallet::pricePerLink(),
]);
}
public function create(): View
{
$account = ladill_account();
return view('links.create', [
'canCreate' => $this->billing->canCreate($account),
'pricePerLink' => \App\Models\LinkWallet::pricePerLink(),
]);
}
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'destination_url' => ['required', 'string', 'max:2048'],
'label' => ['nullable', 'string', 'max:120'],
'custom_slug' => ['nullable', 'string', 'max:20', 'regex:/^[a-z0-9][a-z0-9-]{1,18}[a-z0-9]$/'],
'expires_at' => ['nullable', 'date', 'after:now'],
]);
try {
$link = $this->links->create(ladill_account(), $validated);
} catch (RuntimeException $e) {
return back()->withInput()->withErrors(['balance' => $e->getMessage()]);
}
return redirect()
->route('user.links.show', $link)
->with('success', 'Short link created.');
}
public function show(ShortLink $link): View
{
$this->authorizeLink($link);
return view('links.show', [
'link' => $link,
'recentClicks' => $link->clicks()->latest('clicked_at')->limit(20)->get(),
]);
}
public function update(Request $request, ShortLink $link): RedirectResponse
{
$this->authorizeLink($link);
$validated = $request->validate([
'destination_url' => ['sometimes', 'required', 'string', 'max:2048'],
'label' => ['nullable', 'string', 'max:120'],
'is_active' => ['sometimes', 'boolean'],
'expires_at' => ['nullable', 'date'],
]);
try {
$this->links->update($link, $validated);
} catch (RuntimeException $e) {
return back()->withInput()->withErrors(['destination_url' => $e->getMessage()]);
}
return back()->with('success', 'Link updated.');
}
public function destroy(ShortLink $link): RedirectResponse
{
$this->authorizeLink($link);
$link->delete();
return redirect()->route('user.links.index')->with('success', 'Link deleted.');
}
public function checkSlug(Request $request): \Illuminate\Http\JsonResponse
{
$slug = strtolower((string) $request->query('slug', ''));
$taken = $slug !== '' && ShortLink::where('slug', $slug)->exists();
return response()->json(['available' => ! $taken]);
}
private function authorizeLink(ShortLink $link): void
{
if ($link->user_id !== ladill_account()->id) {
abort(403);
}
}
}