Expand Link app with analytics, settings, and custom domains.
Deploy Ladill Link / deploy (push) Successful in 37s

Add Bitly-style branded domain support via Ladill Domains SSL API,
account analytics dashboard, settings page with default domain picker,
and fix SSO/dashboard issues from QR Plus template leftovers.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-27 12:16:34 +00:00
co-authored by Cursor
parent d9c91ad7d8
commit 9516fcb9f3
38 changed files with 1263 additions and 70 deletions
+7 -6
View File
@@ -1,16 +1,17 @@
name: Deploy Ladill QR Plus
name: Deploy Ladill Link
on:
push:
branches:
- main
- master
workflow_dispatch:
permissions:
contents: read
concurrency:
group: deploy-qr-plus
group: deploy-link
cancel-in-progress: true
jobs:
@@ -19,9 +20,9 @@ jobs:
env:
NODE_ROOT: /tmp/ladill-node-22-r1
NODE_VERSION: "22.14.0"
RELEASE_ARCHIVE: /tmp/ladill-qr-plus-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz
WORKSPACE: /tmp/${{ gitea.repository_owner }}-qr-plus-${{ gitea.run_id }}-${{ gitea.run_attempt }}
LADILL_APP_ROOT: /var/www/ladill-qr-plus
RELEASE_ARCHIVE: /tmp/ladill-link-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz
WORKSPACE: /tmp/${{ gitea.repository_owner }}-link-${{ gitea.run_id }}-${{ gitea.run_attempt }}
LADILL_APP_ROOT: /var/www/ladill-link
steps:
- name: Checkout
shell: bash {0}
@@ -78,7 +79,7 @@ jobs:
- name: Deploy release
shell: bash {0}
env:
LADILL_RELEASE_ARCHIVE: /tmp/ladill-qr-plus-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz
LADILL_RELEASE_ARCHIVE: /tmp/ladill-link-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz
run: |
set -Eeuo pipefail
: "${LADILL_APP_ROOT:?Set LADILL_APP_ROOT}"
+1 -1
View File
@@ -1 +1 @@
manual-deploy
d9c91ad7d852e003f460e89f50ab710c71976235
@@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\CustomDomain\LinkCustomDomainService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class SslCallbackController extends Controller
{
public function __invoke(Request $request, LinkCustomDomainService $service): JsonResponse
{
$secret = (string) config('customdomain.callback_secret');
$body = $request->getContent();
$signature = (string) $request->header('X-Ladill-Signature', '');
if ($secret === '' || ! hash_equals(hash_hmac('sha256', $body, $secret), $signature)) {
return response()->json(['error' => 'Invalid signature.'], 401);
}
$payload = json_decode($body, true);
$data = (array) ($payload['data'] ?? []);
$host = (string) ($data['host'] ?? '');
if ($host === '') {
return response()->json(['error' => 'Missing host.'], 422);
}
$service->applyCallback(
$host,
(string) ($data['status'] ?? 'failed'),
$data['expires_at'] ?? null,
$data['last_error'] ?? null,
);
return response()->json(['status' => 'ok']);
}
}
@@ -3,7 +3,6 @@
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\QrTeamMember;
use App\Models\User;
use Illuminate\Http\Client\Response as HttpResponse;
use Illuminate\Http\RedirectResponse;
@@ -81,7 +80,7 @@ class SsoLoginController extends Controller
public function callback(Request $request): RedirectResponse|View
{
$intended = (string) $request->session()->get('sso.intended', route('qr.dashboard'));
$intended = (string) $request->session()->get('sso.intended', route('link.dashboard'));
$popup = (bool) $request->session()->get('sso.popup');
@@ -118,8 +117,6 @@ class SsoLoginController extends Controller
return $this->finishCallback($request, $intended, 'userinfo_failed', $popup);
}
QrTeamMember::linkPendingInvitesFor($user);
Auth::login($user, remember: true);
$request->session()->regenerate();
@@ -0,0 +1,26 @@
<?php
namespace App\Http\Controllers\Link;
use App\Http\Controllers\Controller;
use App\Services\Link\LinkAnalyticsService;
use Illuminate\Http\Request;
use Illuminate\View\View;
class AnalyticsController extends Controller
{
public function __construct(private LinkAnalyticsService $analytics) {}
public function index(Request $request): View
{
$account = ladill_account();
return view('links.analytics.index', [
'summary' => $this->analytics->summaryForAccount($account),
'dailyClicks' => $this->analytics->dailyClicksForAccount($account, 30),
'topLinks' => $this->analytics->topLinksForAccount($account),
'recentClicks' => $this->analytics->recentClicksForAccount($account),
'referrers' => $this->analytics->referrersForAccount($account),
]);
}
}
@@ -0,0 +1,94 @@
<?php
namespace App\Http\Controllers\Link;
use App\Http\Controllers\Controller;
use App\Models\LinkCustomDomain;
use App\Services\CustomDomain\DomainsSslClient;
use App\Services\CustomDomain\LinkCustomDomainService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class CustomDomainController extends Controller
{
public function __construct(
private readonly LinkCustomDomainService $service,
private readonly DomainsSslClient $domains,
) {}
public function index(Request $request): View
{
$account = ladill_account();
return view('links.domains.index', [
'domains' => $account->linkCustomDomains()->latest()->get(),
'enabled' => $this->service->enabled(),
'serverIp' => (string) config('customdomain.server_ip'),
'publicDomain' => (string) config('link.public_domain', 'ladl.link'),
]);
}
public function store(Request $request): RedirectResponse
{
abort_unless($this->service->enabled(), 404);
$data = $request->validate([
'host' => ['required', 'string', 'max:255', 'regex:/^(?!-)([a-z0-9-]+\.)+[a-z]{2,}$/i'],
'include_www' => ['nullable', 'boolean'],
'make_default' => ['nullable', 'boolean'],
]);
$host = strtolower(preg_replace('/^www\./', '', trim($data['host'])));
if (LinkCustomDomain::where('host', $host)->exists()) {
return back()->withErrors(['host' => 'That domain is already connected.']);
}
$domain = $this->service->attach(
$request->user(),
$host,
(bool) ($data['include_www'] ?? true),
(bool) ($data['make_default'] ?? false),
);
$this->domains->registerConnected(
$host,
(string) $request->user()->public_id,
'Ladill Link',
route('link.domains.index'),
);
return back()->with('success', "Domain added. Point an A record for {$host} to ".config('customdomain.server_ip').', then click Verify.');
}
public function verify(Request $request, LinkCustomDomain $customDomain): RedirectResponse
{
abort_unless($customDomain->user_id === $request->user()->id, 403);
[$ok, $message] = $this->service->verifyAndProvision($customDomain);
return back()->with($ok ? 'success' : 'error', $message);
}
public function makeDefault(Request $request, LinkCustomDomain $customDomain): RedirectResponse
{
abort_unless($customDomain->user_id === $request->user()->id, 403);
abort_unless($customDomain->isLive(), 422);
$this->service->setDefault($customDomain);
return back()->with('success', $customDomain->host.' is now your default short-link domain.');
}
public function destroy(Request $request, LinkCustomDomain $customDomain): RedirectResponse
{
abort_unless($customDomain->user_id === $request->user()->id, 403);
$host = $customDomain->host;
$customDomain->delete();
$this->domains->removeConnected($host);
return back()->with('success', 'Custom domain removed.');
}
}
@@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers\Link;
use App\Http\Controllers\Controller;
use App\Models\LinkCustomDomain;
use App\Models\LinkWallet;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class SettingsController extends Controller
{
public function index(Request $request): View
{
$account = ladill_account();
return view('links.settings.index', [
'pricePerLink' => LinkWallet::pricePerLink(),
'defaultDomain' => $account->defaultLinkDomain(),
'publicDomain' => (string) config('link.public_domain', 'ladl.link'),
'customDomains' => $account->linkCustomDomains()->latest()->get(),
]);
}
public function updateDefaultDomain(Request $request): RedirectResponse
{
$account = ladill_account();
$validated = $request->validate([
'domain' => ['required', 'string', 'max:255'],
]);
if ($validated['domain'] === 'platform') {
LinkCustomDomain::where('user_id', $account->id)->update(['is_default' => false]);
return back()->with('success', 'Default short-link domain set to ladl.link.');
}
$domain = LinkCustomDomain::query()
->where('user_id', $account->id)
->where('id', $validated['domain'])
->firstOrFail();
abort_unless($domain->isLive(), 422);
LinkCustomDomain::where('user_id', $account->id)->update(['is_default' => false]);
$domain->forceFill(['is_default' => true])->save();
return back()->with('success', 'Default short-link domain updated.');
}
}
@@ -4,28 +4,28 @@ namespace App\Http\Controllers\Public;
use App\Http\Controllers\Controller;
use App\Services\Link\LinkManagerService;
use App\Services\Link\LinkPlatformProxy;
use App\Support\LadillLink;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\Response;
class LinkRedirectController extends Controller
{
public function __construct(
private LinkManagerService $links,
private LinkPlatformProxy $platform,
) {}
public function resolve(Request $request, string $slug): RedirectResponse|View
public function resolve(Request $request, string $slug, ?string $path = null): RedirectResponse|Response
{
$link = $this->links->resolve($request, $slug);
if ($link !== null) {
return redirect()->away($link->destination_url, 302);
if ($path === null || $path === '') {
$link = $this->links->resolve($request, $slug);
if ($link !== null) {
return redirect()->away($link->destination_url, 302);
}
}
// Legacy QR / storefront codes still resolve on ladill.com/q/*
$legacy = rtrim((string) config('app.platform_url', 'https://ladill.com'), '/').'/q/'.$slug;
$qs = $request->getQueryString();
return redirect()->away($legacy.($qs ? '?'.$qs : ''), 302);
return $this->platform->forward($request, $slug, $path);
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Customer-branded short-link domain (e.g. go.brand.com) for Ladill Link.
*/
class LinkCustomDomain extends Model
{
public const STATUS_PENDING = 'pending';
public const STATUS_ACTIVE = 'active';
public const STATUS_FAILED = 'failed';
protected $fillable = [
'user_id', 'host', 'include_www', 'is_default', 'status', 'ssl_status',
'dns_verified_at', 'ssl_issued_at', 'ssl_expires_at', 'last_error',
];
protected function casts(): array
{
return [
'include_www' => 'boolean',
'is_default' => 'boolean',
'dns_verified_at' => 'datetime',
'ssl_issued_at' => 'datetime',
'ssl_expires_at' => 'datetime',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function isLive(): bool
{
return $this->status === self::STATUS_ACTIVE && $this->ssl_status === self::STATUS_ACTIVE;
}
public function baseUrl(): string
{
return 'https://'.$this->host;
}
protected static function booted(): void
{
static::saving(function (LinkCustomDomain $domain) {
$domain->host = strtolower(trim((string) $domain->host, " \t\n\r\0\x0B./"));
$domain->host = preg_replace('/^www\./', '', $domain->host) ?: $domain->host;
});
}
}
+5 -2
View File
@@ -38,9 +38,12 @@ class ShortLink extends Model
return $this->hasMany(LinkClick::class);
}
public function publicUrl(): string
public function publicUrl(?User $owner = null): string
{
return self::publicBaseUrl().'/'.$this->slug;
$owner ??= $this->user;
$base = $owner ? $owner->publicLinkBaseUrl() : self::publicBaseUrl();
return rtrim($base, '/').'/'.$this->slug;
}
public static function publicBaseUrl(): string
+31
View File
@@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Collection;
use Laravel\Sanctum\HasApiTokens;
/**
@@ -27,6 +28,17 @@ class User extends Authenticatable
return ['email_verified_at' => 'datetime', 'password' => 'hashed'];
}
public function canAccessAccount(int $accountId): bool
{
return $accountId === $this->id;
}
/** @return Collection<int, User> */
public function accessibleAccounts(): Collection
{
return collect([$this]);
}
public function linkWallet(): HasOne
{
return $this->hasOne(LinkWallet::class);
@@ -37,6 +49,25 @@ class User extends Authenticatable
return $this->hasMany(ShortLink::class);
}
public function linkCustomDomains(): HasMany
{
return $this->hasMany(LinkCustomDomain::class);
}
public function defaultLinkDomain(): ?LinkCustomDomain
{
return $this->linkCustomDomains()
->where('is_default', true)
->where('status', LinkCustomDomain::STATUS_ACTIVE)
->where('ssl_status', LinkCustomDomain::STATUS_ACTIVE)
->first();
}
public function publicLinkBaseUrl(): string
{
return $this->defaultLinkDomain()?->baseUrl() ?? ShortLink::publicBaseUrl();
}
public function getOrCreateLinkWallet(): LinkWallet
{
return $this->linkWallet()->firstOrCreate(
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\Services\CustomDomain;
class DnsResolver
{
/** @return array<int,string> */
public function aRecords(string $host): array
{
$records = @dns_get_record($host, DNS_A);
if (! is_array($records)) {
return [];
}
return array_values(array_filter(array_map(fn ($r) => $r['ip'] ?? null, $records)));
}
}
@@ -0,0 +1,76 @@
<?php
namespace App\Services\CustomDomain;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class DomainsSslClient
{
public function configured(): bool
{
return (string) config('customdomain.ssl_api_key', '') !== '';
}
public function requestCertificate(string $host, bool $includeWww, string $callbackUrl): bool
{
if (! $this->configured()) {
return false;
}
try {
$res = Http::withToken((string) config('customdomain.ssl_api_key'))
->acceptJson()->timeout(20)
->post(config('customdomain.ssl_api_url').'/ssl/certificates', [
'host' => $host,
'target' => 'app',
'include_www' => $includeWww,
'callback_url' => $callbackUrl,
'metadata' => ['nginx_include' => config('customdomain.nginx_include')],
]);
if ($res->failed()) {
Log::warning('DomainsSslClient: request failed', ['host' => $host, 'status' => $res->status()]);
return false;
}
return true;
} catch (\Throwable $e) {
Log::warning('DomainsSslClient: request error', ['host' => $host, 'error' => $e->getMessage()]);
return false;
}
}
public function registerConnected(string $host, string $ownerPublicId, ?string $label = null, ?string $linkUrl = null): void
{
if (! $this->configured() || $ownerPublicId === '') {
return;
}
try {
Http::withToken((string) config('customdomain.ssl_api_key'))->acceptJson()->timeout(10)
->post(config('customdomain.ssl_api_url').'/connected-domains', array_filter([
'host' => $host,
'owner_public_id' => $ownerPublicId,
'label' => $label,
'link_url' => $linkUrl,
]));
} catch (\Throwable $e) {
Log::info('DomainsSslClient: connected register skipped', ['host' => $host, 'error' => $e->getMessage()]);
}
}
public function removeConnected(string $host): void
{
if (! $this->configured()) {
return;
}
try {
Http::withToken((string) config('customdomain.ssl_api_key'))->acceptJson()->timeout(10)
->delete(config('customdomain.ssl_api_url').'/connected-domains/'.urlencode($host));
} catch (\Throwable $e) {
Log::info('DomainsSslClient: connected remove skipped', ['host' => $host, 'error' => $e->getMessage()]);
}
}
}
@@ -0,0 +1,115 @@
<?php
namespace App\Services\CustomDomain;
use App\Models\LinkCustomDomain;
use App\Models\User;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class LinkCustomDomainService
{
public function __construct(
private readonly DnsResolver $dns,
private readonly DomainsSslClient $ssl,
) {}
public function enabled(): bool
{
return (bool) config('customdomain.enabled', true) && $this->ssl->configured();
}
public function attach(User $user, string $host, bool $includeWww = true, bool $makeDefault = false): LinkCustomDomain
{
return DB::transaction(function () use ($user, $host, $includeWww, $makeDefault) {
if ($makeDefault) {
LinkCustomDomain::where('user_id', $user->id)->update(['is_default' => false]);
}
return LinkCustomDomain::create([
'user_id' => $user->id,
'host' => $host,
'include_www' => $includeWww,
'is_default' => $makeDefault || ! LinkCustomDomain::where('user_id', $user->id)->exists(),
'status' => LinkCustomDomain::STATUS_PENDING,
'ssl_status' => LinkCustomDomain::STATUS_PENDING,
]);
});
}
/** @return array{0:bool,1:string} */
public function verifyAndProvision(LinkCustomDomain $domain): array
{
$serverIp = (string) config('customdomain.server_ip');
$ips = $this->dns->aRecords($domain->host);
if (! in_array($serverIp, $ips, true)) {
$domain->forceFill([
'last_error' => 'DNS not pointing to '.$serverIp.' yet (found: '.(implode(', ', $ips) ?: 'none').').',
])->save();
return [false, 'DNS not verified yet. Add an A record for '.$domain->host.' pointing to '.$serverIp.', then try again.'];
}
$domain->forceFill(['dns_verified_at' => Carbon::now(), 'last_error' => null])->save();
$requested = $this->ssl->requestCertificate(
$domain->host,
$domain->include_www,
route('api.ssl-callback'),
);
if (! $requested) {
$domain->forceFill(['last_error' => 'Could not reach the SSL service. Please try again shortly.'])->save();
return [false, 'Domain verified, but issuing the certificate failed. Please retry in a moment.'];
}
return [true, 'Domain verified. Issuing your SSL certificate — this usually takes under a minute.'];
}
public function applyCallback(string $host, string $status, ?string $expiresAt, ?string $error): void
{
$domain = LinkCustomDomain::where('host', strtolower(trim($host)))->first();
if (! $domain) {
return;
}
if ($status === 'active') {
$domain->forceFill([
'ssl_status' => LinkCustomDomain::STATUS_ACTIVE,
'status' => LinkCustomDomain::STATUS_ACTIVE,
'ssl_issued_at' => Carbon::now(),
'ssl_expires_at' => $expiresAt ? Carbon::parse($expiresAt) : null,
'last_error' => null,
])->save();
} else {
$domain->forceFill([
'ssl_status' => LinkCustomDomain::STATUS_FAILED,
'status' => LinkCustomDomain::STATUS_FAILED,
'last_error' => $error ?: 'Certificate issuance failed.',
])->save();
}
}
public function setDefault(LinkCustomDomain $domain): void
{
DB::transaction(function () use ($domain) {
LinkCustomDomain::where('user_id', $domain->user_id)->update(['is_default' => false]);
$domain->forceFill(['is_default' => true])->save();
});
}
public function isAppHost(string $host): bool
{
$host = preg_replace('/^www\./', '', strtolower(trim($host)));
$known = array_filter([
(string) config('customdomain.app_host'),
(string) config('customdomain.public_host'),
'ladl.link',
'www.ladl.link',
]);
return in_array($host, $known, true);
}
}
+101
View File
@@ -0,0 +1,101 @@
<?php
namespace App\Services\Link;
use App\Models\LinkClick;
use App\Models\ShortLink;
use App\Models\User;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class LinkAnalyticsService
{
/** @return array{total_clicks:int,unique_clicks:int,clicks_7d:int,clicks_30d:int,links_total:int} */
public function summaryForAccount(User $account): array
{
$links = ShortLink::query()->where('user_id', $account->id);
$clicks = LinkClick::query()->whereIn('short_link_id', (clone $links)->select('id'));
return [
'total_clicks' => (int) (clone $links)->sum('clicks_total'),
'unique_clicks' => (int) (clone $links)->sum('unique_clicks_total'),
'clicks_7d' => (int) (clone $clicks)->where('clicked_at', '>=', now()->subDays(7))->count(),
'clicks_30d' => (int) (clone $clicks)->where('clicked_at', '>=', now()->subDays(30))->count(),
'links_total' => (int) (clone $links)->count(),
];
}
/** @return Collection<int, object{date:string,total:int}> */
public function dailyClicksForAccount(User $account, int $days = 30): Collection
{
$rows = LinkClick::query()
->selectRaw('DATE(clicked_at) as date, COUNT(*) as total')
->whereIn('short_link_id', ShortLink::query()->where('user_id', $account->id)->select('id'))
->where('clicked_at', '>=', now()->subDays($days - 1)->startOfDay())
->groupBy('date')
->orderBy('date')
->get()
->keyBy('date');
return collect(range(0, $days - 1))->map(function (int $offset) use ($rows, $days) {
$date = now()->subDays($days - 1 - $offset)->toDateString();
return (object) [
'date' => $date,
'total' => (int) ($rows[$date]->total ?? 0),
];
});
}
/** @return Collection<int, array{label:string,total:int}> */
public function topLinksForAccount(User $account, int $limit = 8): Collection
{
return ShortLink::query()
->where('user_id', $account->id)
->orderByDesc('clicks_total')
->limit($limit)
->get()
->map(fn (ShortLink $link) => [
'label' => $link->label ?: $link->slug,
'slug' => $link->slug,
'total' => $link->clicks_total,
'url' => route('user.links.show', $link),
]);
}
/** @return Collection<int, object> */
public function recentClicksForAccount(User $account, int $limit = 15): Collection
{
return LinkClick::query()
->with('shortLink:id,slug,label')
->whereIn('short_link_id', ShortLink::query()->where('user_id', $account->id)->select('id'))
->latest('clicked_at')
->limit($limit)
->get();
}
/** @return Collection<int, array{label:string,total:int}> */
public function referrersForAccount(User $account, int $limit = 8): Collection
{
return LinkClick::query()
->select('referer', DB::raw('COUNT(*) as total'))
->whereIn('short_link_id', ShortLink::query()->where('user_id', $account->id)->select('id'))
->whereNotNull('referer')
->where('referer', '!=', '')
->groupBy('referer')
->orderByDesc('total')
->limit($limit)
->get()
->map(fn ($row) => [
'label' => $this->refererLabel((string) $row->referer),
'total' => (int) $row->total,
]);
}
private function refererLabel(string $referer): string
{
$host = parse_url($referer, PHP_URL_HOST);
return is_string($host) && $host !== '' ? $host : $referer;
}
}
+96
View File
@@ -0,0 +1,96 @@
<?php
namespace App\Services\Link;
use App\Support\LadillLink;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Symfony\Component\HttpFoundation\Response;
/**
* Proxies ladl.link ecosystem paths to the platform QR resolver (/q/* on ladill.com).
* The browser stays on ladl.link; resolution happens server-side.
*/
class LinkPlatformProxy
{
public function forward(Request $request, string $slug, ?string $path = null): Response
{
$platform = rtrim((string) config('app.platform_url', 'https://ladill.com'), '/');
$target = $platform.'/q/'.$slug;
if ($path !== null && $path !== '') {
$target .= '/'.ltrim($path, '/');
}
if ($qs = $request->getQueryString()) {
$target .= '?'.$qs;
}
$client = Http::withOptions(['allow_redirects' => false])->timeout(15);
$method = strtoupper($request->method());
$pending = match ($method) {
'POST' => $client->withHeaders($this->forwardHeaders($request))->asForm()->post($target, $request->post()),
'PATCH' => $client->withHeaders($this->forwardHeaders($request))->patch($target, $request->all()),
'DELETE' => $client->withHeaders($this->forwardHeaders($request))->delete($target),
default => $client->withHeaders($this->forwardHeaders($request))->get($target),
};
$upstream = $pending->toPsrResponse();
$response = new Response(
(string) $upstream->getBody(),
$upstream->getStatusCode(),
$this->sanitizeHeaders($upstream->getHeaders())
);
if ($response->isRedirection()) {
$location = $response->headers->get('Location');
if (is_string($location)) {
$response->headers->set('Location', $this->rewriteLocation($location, $slug));
}
}
return $response;
}
/** @return array<string, string> */
private function forwardHeaders(Request $request): array
{
$headers = [];
foreach (['Accept', 'Accept-Language', 'User-Agent', 'Referer'] as $name) {
$value = $request->header($name);
if ($value !== null) {
$headers[$name] = $value;
}
}
return $headers;
}
/** @param array<string, array<int, string>> $headers */
private function sanitizeHeaders(array $headers): array
{
$flat = [];
foreach ($headers as $name => $values) {
if (strtolower($name) === 'transfer-encoding') {
continue;
}
$flat[$name] = $values[0] ?? '';
}
return $flat;
}
private function rewriteLocation(string $location, string $slug): string
{
$platform = rtrim((string) config('app.platform_url', 'https://ladill.com'), '/');
$prefix = $platform.'/q/'.$slug;
if (str_starts_with($location, $prefix)) {
$suffix = substr($location, strlen($prefix));
$rewritten = LadillLink::url($slug).$suffix;
return $rewritten;
}
return $location;
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Support;
/**
* Canonical short-link URLs for the Ladill platform (ladl.link).
*/
final class LadillLink
{
public static function baseUrl(): string
{
$domain = (string) config('link.public_domain', 'ladl.link');
return 'https://'.$domain;
}
public static function url(string $slug): string
{
return rtrim(self::baseUrl(), '/').'/'.ltrim($slug, '/');
}
public static function path(string $slug, string $suffix): string
{
return self::url($slug).'/'.ltrim($suffix, '/');
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
$appHost = parse_url((string) env('APP_URL', 'https://link.ladill.com'), PHP_URL_HOST) ?: 'link.ladill.com';
return [
'enabled' => filter_var(env('CUSTOM_DOMAINS_ENABLED', true), FILTER_VALIDATE_BOOLEAN),
'app_host' => $appHost,
'public_host' => env('LINK_PUBLIC_DOMAIN', 'ladl.link'),
'server_ip' => env('LADILL_APP_SERVER_IP', '161.97.138.149'),
'ssl_api_url' => rtrim(env('DOMAINS_SSL_API_URL', 'https://domains.ladill.com/api'), '/'),
'ssl_api_key' => env('DOMAINS_API_KEY_LINK', ''),
'nginx_include' => env('CUSTOM_DOMAINS_NGINX_INCLUDE', '/etc/nginx/snippets/ladill-link-app.conf'),
'callback_secret' => env('SSL_CALLBACK_SECRET', ''),
];
+1 -1
View File
@@ -2,5 +2,5 @@
return [
'api_url' => env('IDENTITY_API_URL', 'https://ladill.com/api'),
'api_key' => env('IDENTITY_API_KEY_HOSTING'),
'api_key' => env('IDENTITY_API_KEY_LINK'),
];
+21 -26
View File
@@ -2,44 +2,39 @@
/*
|--------------------------------------------------------------------------
| Ladill App Launcher SHARED, IDENTICAL across every app/service repo
| Ladill App Launcher GENERATED, IDENTICAL across every app/service repo
|--------------------------------------------------------------------------
|
| Lists ONLY fully-extracted apps (each on its own subdomain). To replicate the
| launcher in a new app, copy these three things verbatim into that repo:
| - config/ladill_launcher.php (this file)
| - resources/views/partials/launcher.blade.php
| - public/images/launcher-icons/* (the icon set)
| DO NOT EDIT BY HAND. This file is generated from the app registry in the
| monolith (config/ladill_apps.php). To change the launcher, edit that
| registry and run: php artisan ladill:launcher:sync --propagate
|
| When a service becomes FULLY extracted: add one row below AND drop its icon in
| public/images/launcher-icons/ in EVERY repo. That's the only edit needed.
| Do NOT list a service here until it is fully extracted to its own subdomain.
|
| URLs are absolute (built from the platform root) so this file is byte-identical
| in every app; the blade omits whichever row matches the app's APP_URL host.
| Icons are served from /images/launcher-icons/<icon>.
| URLs are absolute (built from the platform root) so this file is
| byte-identical in every repo; the blade omits whichever row matches the
| app's own host. Icons are served from /images/launcher-icons/<icon>.
*/
$root = config('app.platform_domain', 'ladill.com');
return [
'apps' => [
['name' => 'Bird', 'url' => 'https://bird.'.$root, 'icon' => 'bird.svg'],
['name' => 'Email', 'url' => 'https://email.'.$root, 'icon' => 'email.svg'],
['name' => 'Mail', 'url' => 'https://mail.'.$root, 'icon' => 'mail.svg'],
['name' => 'Merchant', 'url' => 'https://merchant.'.$root.'/sso/connect?redirect='.urlencode('https://merchant.'.$root.'/dashboard'), 'icon' => 'merchant.svg'],
['name' => 'POS', 'url' => 'https://pos.'.$root.'/sso/connect?redirect='.urlencode('https://pos.'.$root.'/dashboard'), 'icon' => 'pos.svg'],
['name' => 'Mini', 'url' => 'https://mini.'.$root.'/sso/connect?redirect='.urlencode('https://mini.'.$root.'/dashboard'), 'icon' => 'mini.svg'],
['name' => 'Give', 'url' => 'https://give.'.$root.'/sso/connect?redirect='.urlencode('https://give.'.$root.'/dashboard'), 'icon' => 'give.svg'],
['name' => 'Events', 'url' => 'https://events.'.$root.'/sso/connect?redirect='.urlencode('https://events.'.$root.'/dashboard'), 'icon' => 'events.svg'],
['name' => 'Invoice', 'url' => 'https://invoice.'.$root.'/sso/connect?redirect='.urlencode('https://invoice.'.$root.'/dashboard'), 'icon' => 'invoice.svg'],
['name' => 'Transfer', 'url' => 'https://transfer.'.$root.'/sso/connect?redirect='.urlencode('https://transfer.'.$root.'/dashboard'), 'icon' => 'transfer.svg'],
['name' => 'Accounting', 'url' => 'https://accounting.'.$root.'/sso/connect?redirect='.urlencode('https://accounting.'.$root.'/dashboard'), 'icon' => 'accounting.svg'],
['name' => 'CRM', 'url' => 'https://crm.'.$root.'/sso/connect?redirect='.urlencode('https://crm.'.$root.'/dashboard'), 'icon' => 'crm.svg'],
['name' => 'QR Plus', 'url' => 'https://qrplus.'.$root.'/sso/connect?redirect='.urlencode('https://qrplus.'.$root.'/dashboard'), 'icon' => 'qrplus.svg'],
['name' => 'Link', 'url' => 'https://link.'.$root.'/sso/connect?redirect='.urlencode('https://link.'.$root.'/dashboard'), 'icon' => 'link.svg'],
['name' => 'SMS', 'url' => 'https://sms.'.$root, 'icon' => 'sms.svg'],
['name' => 'Bird', 'url' => 'https://bird.'.$root, 'icon' => 'bird.svg'],
['name' => 'Mail', 'url' => 'https://mail.'.$root, 'icon' => 'mail.svg'],
['name' => 'Email', 'url' => 'https://email.'.$root, 'icon' => 'email.svg'],
['name' => 'Domains', 'url' => 'https://domains.'.$root, 'icon' => 'domains.svg'],
['name' => 'Servers', 'url' => 'https://servers.'.$root, 'icon' => 'servers.svg'],
['name' => 'Hosting', 'url' => 'https://hosting.'.$root, 'icon' => 'hosting.svg'],
['name' => 'QR Plus', 'url' => 'https://qrplus.'.$root.'/sso/connect?redirect='.urlencode('https://qrplus.'.$root.'/dashboard'), 'icon' => 'qrplus.svg'],
['name' => 'Events', 'url' => 'https://events.'.$root.'/sso/connect?redirect='.urlencode('https://events.'.$root.'/dashboard'), 'icon' => 'events.svg'],
['name' => 'Mini', 'url' => 'https://mini.'.$root.'/sso/connect?redirect='.urlencode('https://mini.'.$root.'/dashboard'), 'icon' => 'mini.svg'],
['name' => 'Invoice', 'url' => 'https://invoice.'.$root.'/sso/connect?redirect='.urlencode('https://invoice.'.$root.'/dashboard'), 'icon' => 'invoice.svg'],
['name' => 'Give', 'url' => 'https://give.'.$root.'/sso/connect?redirect='.urlencode('https://give.'.$root.'/dashboard'), 'icon' => 'give.svg'],
['name' => 'Merchant', 'url' => 'https://merchant.'.$root.'/sso/connect?redirect='.urlencode('https://merchant.'.$root.'/dashboard'), 'icon' => 'merchant.svg'],
['name' => 'POS', 'url' => 'https://pos.'.$root.'/sso/connect?redirect='.urlencode('https://pos.'.$root.'/dashboard'), 'icon' => 'pos.svg'],
['name' => 'Transfer', 'url' => 'https://transfer.'.$root.'/sso/connect?redirect='.urlencode('https://transfer.'.$root.'/dashboard'), 'icon' => 'transfer.svg'],
['name' => 'CRM', 'url' => 'https://crm.'.$root.'/sso/connect?redirect='.urlencode('https://crm.'.$root.'/dashboard'), 'icon' => 'crm.svg'],
['name' => 'Accounting', 'url' => 'https://accounting.'.$root.'/sso/connect?redirect='.urlencode('https://accounting.'.$root.'/dashboard'), 'icon' => 'accounting.svg'],
],
];
+1 -1
View File
@@ -23,7 +23,7 @@ return [
'issuer' => 'https://'.config('app.auth_domain'),
'client_id' => env('LADILL_SSO_CLIENT_ID'),
'client_secret' => env('LADILL_SSO_CLIENT_SECRET'),
'redirect' => rtrim((string) env('APP_URL', 'https://qrplus.ladill.com'), '/').'/sso/callback',
'redirect' => rtrim((string) env('APP_URL', 'https://link.ladill.com'), '/').'/sso/callback',
],
'ladill_webmail' => [
@@ -0,0 +1,33 @@
<?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('link_custom_domains', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('host')->unique();
$table->boolean('include_www')->default(true);
$table->boolean('is_default')->default(false);
$table->string('status', 16)->default('pending');
$table->string('ssl_status', 16)->default('pending');
$table->timestamp('dns_verified_at')->nullable();
$table->timestamp('ssl_issued_at')->nullable();
$table->timestamp('ssl_expires_at')->nullable();
$table->text('last_error')->nullable();
$table->timestamps();
$table->index(['user_id', 'is_default']);
});
}
public function down(): void
{
Schema::dropIfExists('link_custom_domains');
}
};
+2 -2
View File
@@ -2,7 +2,7 @@
# Fast on-host release deploy (same model as climpme/web/deploy/deploy.sh).
set -Eeuo pipefail
APP_ROOT="${LADILL_APP_ROOT:-/var/www/ladill-qr-plus}"
APP_ROOT="${LADILL_APP_ROOT:-/var/www/ladill-link}"
RELEASES_DIR="$APP_ROOT/releases"
SHARED_DIR="$APP_ROOT/shared"
CURRENT_LINK="$APP_ROOT/current"
@@ -236,7 +236,7 @@ if [ -L "$CURRENT_LINK" ] && [ -f "$CURRENT_LINK/artisan" ]; then
(cd "$CURRENT_LINK" && php artisan queue:restart) || true
fi
if command -v supervisorctl >/dev/null 2>&1; then
supervisorctl restart ladill-qr-plus-worker:* 2>/dev/null || true
supervisorctl restart ladill-link-worker:* 2>/dev/null || true
fi
log "Cleaning old releases"
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env bash
# One-time server bootstrap for Ladill Link on 161.97.138.149
set -Eeuo pipefail
APP_ROOT="/var/www/ladill-link"
MONOLITH="/var/www/ladill.com/current"
QR_PLUS_ENV="/var/www/ladill-qr-plus/shared/.env"
NGINX_SETUP="/var/www/ladill.com/current/deployment/setup-service-subdomain-nginx.sh"
RELEASE_ARCHIVE="${1:-/tmp/ladill-link-release.tgz}"
log() { printf '[%s] %s\n' "$(date '+%F %T')" "$*"; }
[ -f "$RELEASE_ARCHIVE" ] || { echo "Missing release archive: $RELEASE_ARCHIVE" >&2; exit 1; }
[ -f "$QR_PLUS_ENV" ] || { echo "Missing qr-plus env template" >&2; exit 1; }
log "Creating app directories"
mkdir -p "$APP_ROOT"/{releases,shared}
chown -R deploy:www-data "$APP_ROOT" 2>/dev/null || true
if [ ! -f "$APP_ROOT/shared/.env" ]; then
log "Bootstrapping .env from qr-plus template"
cp "$QR_PLUS_ENV" "$APP_ROOT/shared/.env"
sed -i \
-e 's/^APP_NAME=.*/APP_NAME="Ladill Link"/' \
-e 's#^APP_URL=.*#APP_URL=https://link.ladill.com#' \
-e 's/^DB_DATABASE=.*/DB_DATABASE=ladill_link/' \
-e 's/^DB_USERNAME=.*/DB_USERNAME=ladill_link/' \
-e 's/^BILLING_API_KEY_QR=.*/BILLING_API_KEY_LINK=/' \
"$APP_ROOT/shared/.env"
# Generate DB password + billing key if missing
DB_PASS="$(openssl rand -hex 16)"
BILL_KEY="$(openssl rand -hex 24)"
sed -i "s/^DB_PASSWORD=.*/DB_PASSWORD=${DB_PASS}/" "$APP_ROOT/shared/.env"
if ! grep -q '^BILLING_API_KEY_LINK=' "$APP_ROOT/shared/.env"; then
echo "BILLING_API_KEY_LINK=${BILL_KEY}" >> "$APP_ROOT/shared/.env"
else
sed -i "s/^BILLING_API_KEY_LINK=.*/BILLING_API_KEY_LINK=${BILL_KEY}/" "$APP_ROOT/shared/.env"
fi
grep -q '^LINK_PUBLIC_DOMAIN=' "$APP_ROOT/shared/.env" || echo 'LINK_PUBLIC_DOMAIN=ladl.link' >> "$APP_ROOT/shared/.env"
grep -q '^LINK_PRICE_PER_LINK_GHS=' "$APP_ROOT/shared/.env" || echo 'LINK_PRICE_PER_LINK_GHS=0.05' >> "$APP_ROOT/shared/.env"
grep -q '^IDENTITY_API_KEY_LINK=' "$APP_ROOT/shared/.env" || echo "IDENTITY_API_KEY_LINK=$(openssl rand -hex 24)" >> "$APP_ROOT/shared/.env"
chmod 640 "$APP_ROOT/shared/.env"
chown deploy:www-data "$APP_ROOT/shared/.env" 2>/dev/null || true
log "Creating MySQL database ladill_link"
mysql -e "CREATE DATABASE IF NOT EXISTS ladill_link CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
mysql -e "CREATE USER IF NOT EXISTS 'ladill_link'@'127.0.0.1' IDENTIFIED BY '${DB_PASS}';"
mysql -e "GRANT ALL PRIVILEGES ON ladill_link.* TO 'ladill_link'@'127.0.0.1';"
mysql -e "FLUSH PRIVILEGES;"
# Save keys for monolith wiring (picked up below)
echo "$BILL_KEY" > /root/.ladill-link-bootstrap-billing-key
echo "$DB_PASS" > /root/.ladill-link-bootstrap-db-pass
fi
log "Deploying release archive"
export LADILL_APP_ROOT="$APP_ROOT"
export LADILL_RELEASE_ARCHIVE="$RELEASE_ARCHIVE"
bash "$APP_ROOT/releases/bootstrap-deploy.sh" 2>/dev/null || true
# deploy.sh lives inside the archive — extract deploy helper first if needed
TMP_EXTRACT="/tmp/ladill-link-deploy-$$"
mkdir -p "$TMP_EXTRACT"
tar -xzf "$RELEASE_ARCHIVE" -C "$TMP_EXTRACT" deploy/deploy.sh
cp "$TMP_EXTRACT/deploy/deploy.sh" /tmp/ladill-link-deploy.sh
LADILL_APP_ROOT="$APP_ROOT" LADILL_RELEASE_ARCHIVE="$RELEASE_ARCHIVE" bash /tmp/ladill-link-deploy.sh
log "Configuring nginx for link.ladill.com"
if [ -x "$NGINX_SETUP" ]; then
bash "$NGINX_SETUP" link --app "$APP_ROOT/current"
else
bash /var/www/ladill.com/current/deployment/setup-service-subdomain-nginx.sh link --app "$APP_ROOT/current" 2>/dev/null || \
echo "WARN: run setup-service-subdomain-nginx.sh link manually"
fi
log "Configuring nginx for ladl.link"
LADL_CONF="/etc/nginx/sites-available/ladl.link.conf"
if [ ! -f "$LADL_CONF" ]; then
cat > "$LADL_CONF" <<'NGINX'
server {
listen 80;
listen [::]:80;
server_name ladl.link www.ladl.link;
root /var/www/ladill-link/current/public;
location ^~ /.well-known/acme-challenge/ { allow all; }
location / { try_files $uri $uri/ /index.php?$query_string; }
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.4-fpm-ladill.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
}
}
NGINX
ln -sf "$LADL_CONF" /etc/nginx/sites-enabled/ladl.link.conf
nginx -t && systemctl reload nginx
certbot certonly --webroot -w /var/www/ladill-link/current/public -d ladl.link -d www.ladl.link --non-interactive --agree-tos -m admin@ladill.com 2>/dev/null || \
certbot certonly --nginx -d ladl.link -d www.ladl.link --non-interactive --agree-tos -m admin@ladill.com 2>/dev/null || \
log "WARN: certbot for ladl.link failed — DNS may not point here yet"
if [ -d /etc/letsencrypt/live/ladl.link ]; then
cat > "$LADL_CONF" <<'NGINX'
server {
listen 80;
listen [::]:80;
server_name ladl.link www.ladl.link;
location ^~ /.well-known/acme-challenge/ { root /var/www/ladill-link/current/public; allow all; }
location / { return 301 https://$host$request_uri; }
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name ladl.link www.ladl.link;
client_max_body_size 64M;
ssl_certificate /etc/letsencrypt/live/ladl.link/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/ladl.link/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
root /var/www/ladill-link/current/public;
index index.php;
location / { try_files $uri $uri/ /index.php?$query_string; }
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.4-fpm-ladill.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
}
location ~ /\.(?!well-known).* { deny all; }
}
NGINX
nginx -t && systemctl reload nginx
fi
fi
log "Wiring monolith billing + link DB (if monolith has link registry)"
if [ -f "$MONOLITH/artisan" ]; then
BILL_KEY="$(cat /root/.ladill-link-bootstrap-billing-key 2>/dev/null || grep '^BILLING_API_KEY_LINK=' "$APP_ROOT/shared/.env" | cut -d= -f2-)"
MONO_ENV="/var/www/ladill.com/shared/.env"
if [ -n "$BILL_KEY" ] && [ -f "$MONO_ENV" ]; then
grep -q '^BILLING_API_KEY_LINK=' "$MONO_ENV" || echo "BILLING_API_KEY_LINK=${BILL_KEY}" >> "$MONO_ENV"
grep -q '^LINK_DB_DATABASE=' "$MONO_ENV" || cat >> "$MONO_ENV" <<EOF
LINK_DB_HOST=127.0.0.1
LINK_DB_DATABASE=ladill_link
LINK_DB_USERNAME=ladill_link
LINK_DB_PASSWORD=$(grep '^DB_PASSWORD=' "$APP_ROOT/shared/.env" | cut -d= -f2-)
LINK_PUBLIC_DOMAIN=ladl.link
EOF
(cd "$MONOLITH" && php artisan config:clear) || true
fi
if grep -q "'link'" "$MONOLITH/config/ladill_apps.php" 2>/dev/null; then
(cd "$MONOLITH" && php artisan ladill:onboard-app link --run-dns) || true
(cd "$MONOLITH" && php artisan dns:sync-subdomains) || true
else
log "Monolith missing link registry — deploy monolith code then re-run onboard"
fi
fi
log "Bootstrap complete"
curl -sI "https://link.ladill.com/up" 2>/dev/null | head -3 || curl -sI "http://link.ladill.com/up" 2>/dev/null | head -3 || true
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 264 KiB

+3 -2
View File
@@ -1,3 +1,4 @@
@props(['title' => 'Dashboard'])
@php
$mobileFullScreenPage = false;
@endphp
@@ -8,7 +9,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="csrf-token" content="{{ csrf_token() }}">
@include('partials.favicon')
<title>{{ $title ?? 'Dashboard' }} - Ladill Link</title>
<title>{{ $title }} - Ladill Link</title>
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
@vite(['resources/css/app.css', 'resources/js/app.js'])
@@ -239,7 +240,7 @@ document.addEventListener('alpine:init', () => {
});
</script>
@include('partials.afia')
@include('partials.sso-keepalive')
@include('partials.confirm-prompt')
</body>
@@ -0,0 +1,93 @@
<x-user-layout>
<x-slot name="title">Analytics</x-slot>
<div class="mx-auto max-w-6xl space-y-6">
<div>
<h1 class="text-2xl font-semibold text-slate-900">Analytics</h1>
<p class="mt-1 text-sm text-slate-500">Click activity across all your short links.</p>
</div>
<div class="grid grid-cols-2 gap-4 sm:grid-cols-5">
<div class="rounded-xl border border-slate-200 bg-white p-5">
<p class="text-2xl font-bold text-slate-900">{{ number_format($summary['total_clicks']) }}</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-5">
<p class="text-2xl font-bold text-slate-900">{{ number_format($summary['unique_clicks']) }}</p>
<p class="mt-0.5 text-xs text-slate-500">Unique clicks</p>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-5">
<p class="text-2xl font-bold text-slate-900">{{ number_format($summary['clicks_7d']) }}</p>
<p class="mt-0.5 text-xs text-slate-500">Last 7 days</p>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-5">
<p class="text-2xl font-bold text-slate-900">{{ number_format($summary['clicks_30d']) }}</p>
<p class="mt-0.5 text-xs text-slate-500">Last 30 days</p>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-5">
<p class="text-2xl font-bold text-emerald-700">{{ number_format($summary['links_total']) }}</p>
<p class="mt-0.5 text-xs text-slate-500">Links created</p>
</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-6">
<h2 class="text-sm font-semibold text-slate-900">Clicks last 30 days</h2>
@php $maxDaily = max(1, $dailyClicks->max('total')); @endphp
<div class="mt-5 flex h-28 items-end gap-px">
@foreach($dailyClicks as $day)
<div class="group relative flex flex-1 flex-col items-center justify-end" title="{{ $day->date }}: {{ $day->total }}">
<div class="w-full rounded-t-sm bg-emerald-400/70 transition-colors group-hover:bg-emerald-600"
style="height: {{ max(2, ($day->total / $maxDaily) * 100) }}%"></div>
</div>
@endforeach
</div>
</div>
<div class="grid gap-4 lg:grid-cols-2">
<div class="rounded-xl border border-slate-200 bg-white p-5">
<h2 class="text-sm font-semibold text-slate-900">Top links</h2>
<ul class="mt-4 space-y-2.5">
@forelse($topLinks as $row)
<li class="flex items-center justify-between gap-3 text-sm">
<a href="{{ $row['url'] }}" class="truncate text-slate-600 hover:text-emerald-700">{{ $row['label'] }}</a>
<span class="shrink-0 font-semibold text-slate-900">{{ number_format($row['total']) }}</span>
</li>
@empty
<li class="text-sm text-slate-400">No clicks yet</li>
@endforelse
</ul>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-5">
<h2 class="text-sm font-semibold text-slate-900">Top referrers</h2>
<ul class="mt-4 space-y-2.5">
@forelse($referrers as $row)
<li class="flex items-center justify-between gap-3 text-sm">
<span class="truncate text-slate-600">{{ $row['label'] }}</span>
<span class="shrink-0 font-semibold text-slate-900">{{ number_format($row['total']) }}</span>
</li>
@empty
<li class="text-sm text-slate-400">No referrer data yet</li>
@endforelse
</ul>
</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white">
<div class="border-b border-slate-100 px-5 py-4">
<h2 class="font-semibold text-slate-900">Recent clicks</h2>
</div>
<ul class="divide-y divide-slate-100">
@forelse($recentClicks as $click)
<li class="flex items-center justify-between gap-4 px-5 py-3 text-sm">
<div class="min-w-0">
<p class="font-medium text-slate-900">{{ $click->shortLink?->label ?: $click->shortLink?->slug }}</p>
<p class="truncate text-xs text-slate-500">{{ $click->referer ?: 'Direct / unknown' }}</p>
</div>
<time class="shrink-0 text-xs text-slate-400">{{ $click->clicked_at->diffForHumans() }}</time>
</li>
@empty
<li class="px-5 py-8 text-center text-sm text-slate-400">No clicks recorded yet.</li>
@endforelse
</ul>
</div>
</div>
</x-user-layout>
+3 -2
View File
@@ -1,4 +1,5 @@
<x-layouts.user :title="'Create Link'">
<x-user-layout>
<x-slot name="title">Create Link</x-slot>
<div class="mx-auto max-w-xl space-y-6">
<div>
<h1 class="text-2xl font-semibold text-slate-900">Create short link</h1>
@@ -50,4 +51,4 @@
</div>
</form>
</div>
</x-layouts.user>
</x-user-layout>
+3 -2
View File
@@ -1,4 +1,5 @@
<x-layouts.user :title="'Dashboard'">
<x-user-layout>
<x-slot name="title">Dashboard</x-slot>
<div class="mx-auto max-w-5xl space-y-6">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
@@ -47,4 +48,4 @@
</div>
@endif
</div>
</x-layouts.user>
</x-user-layout>
@@ -0,0 +1,92 @@
<x-user-layout>
<x-slot name="title">Custom Domains</x-slot>
<div class="mx-auto max-w-4xl space-y-6">
<div>
<h1 class="text-2xl font-semibold text-slate-900">Custom Domains</h1>
<p class="mt-1 text-sm text-slate-500">Use your own domain for short links like Bitly branded domains. Point DNS to Ladill, we handle SSL.</p>
</div>
@unless($enabled)
<div class="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
Custom domains are not configured on this environment yet. Contact support to enable the Domains API key.
</div>
@endunless
@if($enabled)
<div class="rounded-xl border border-slate-200 bg-white p-6">
<h2 class="font-semibold text-slate-900">Connect a domain</h2>
<p class="mt-1 text-sm text-slate-500">Example: <span class="font-medium">go.yourbrand.com</span> short links like <span class="font-medium">go.yourbrand.com/summer-sale</span></p>
<form method="POST" action="{{ route('link.domains.store') }}" class="mt-5 space-y-4">
@csrf
<div>
<label class="block text-sm font-medium text-slate-700">Domain</label>
<input type="text" name="host" value="{{ old('host') }}" placeholder="go.yourbrand.com"
class="mt-1 w-full rounded-lg border-slate-200 text-sm" required>
@error('host')<p class="mt-1 text-xs text-red-600">{{ $message }}</p>@enderror
</div>
<label class="flex items-center gap-2 text-sm text-slate-600">
<input type="checkbox" name="include_www" value="1" checked class="rounded border-slate-300 text-emerald-600">
Include www subdomain in certificate
</label>
<label class="flex items-center gap-2 text-sm text-slate-600">
<input type="checkbox" name="make_default" value="1" class="rounded border-slate-300 text-emerald-600">
Make default domain for new links
</label>
<p class="text-xs text-slate-500">Add an <strong>A record</strong> for your domain pointing to <code class="rounded bg-slate-100 px-1">{{ $serverIp }}</code>, then click Verify.</p>
<button type="submit" class="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700">Add domain</button>
</form>
</div>
@endif
<div class="rounded-xl border border-slate-200 bg-white">
<div class="border-b border-slate-100 px-5 py-4">
<h2 class="font-semibold text-slate-900">Your domains</h2>
</div>
@if($domains->isEmpty())
<p class="px-5 py-8 text-center text-sm text-slate-400">No custom domains yet. Your links use <span class="font-medium text-emerald-700">{{ $publicDomain }}</span> by default.</p>
@else
<ul class="divide-y divide-slate-100">
@foreach($domains as $domain)
<li class="px-5 py-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<p class="font-medium text-slate-900">{{ $domain->host }}</p>
<p class="mt-1 text-xs text-slate-500">
DNS: {{ $domain->dns_verified_at ? 'verified' : 'pending' }} ·
SSL: {{ $domain->ssl_status }} ·
@if($domain->is_default)<span class="font-medium text-emerald-700">Default</span>@endif
</p>
@if($domain->last_error)
<p class="mt-1 text-xs text-red-600">{{ $domain->last_error }}</p>
@endif
</div>
<div class="flex flex-wrap gap-2">
@if($enabled && $domain->ssl_status !== 'active')
<form method="POST" action="{{ route('link.domains.verify', $domain) }}">
@csrf
<button class="rounded-lg border border-slate-200 px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-50">Verify DNS</button>
</form>
@endif
@if($domain->isLive() && ! $domain->is_default)
<form method="POST" action="{{ route('link.domains.default', $domain) }}">
@csrf
<button class="rounded-lg border border-emerald-200 px-3 py-1.5 text-xs font-medium text-emerald-700 hover:bg-emerald-50">Make default</button>
</form>
@endif
<form method="POST" action="{{ route('link.domains.destroy', $domain) }}" onsubmit="return confirm('Remove this domain?')">
@csrf @method('DELETE')
<button class="rounded-lg border border-red-200 px-3 py-1.5 text-xs font-medium text-red-700 hover:bg-red-50">Remove</button>
</form>
</div>
</div>
</li>
@endforeach
</ul>
@endif
</div>
<p class="text-sm text-slate-500">
Domains also appear in <a href="{{ ladill_domains_url() }}" class="font-medium text-emerald-700 hover:underline">Ladill Domains</a> once connected.
</p>
</div>
</x-user-layout>
+3 -2
View File
@@ -1,4 +1,5 @@
<x-layouts.user :title="'My Links'">
<x-user-layout>
<x-slot name="title">My Links</x-slot>
<div class="mx-auto max-w-5xl space-y-6">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
@@ -52,4 +53,4 @@
@endif
</div>
</div>
</x-layouts.user>
</x-user-layout>
@@ -0,0 +1,48 @@
<x-user-layout>
<x-slot name="title">Settings</x-slot>
<div class="mx-auto max-w-3xl space-y-6">
<div>
<h1 class="text-2xl font-semibold text-slate-900">Settings</h1>
<p class="mt-1 text-sm text-slate-500">Manage your Ladill Link preferences.</p>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-6">
<h2 class="font-semibold text-slate-900">Default short-link domain</h2>
<p class="mt-1 text-sm text-slate-500">Choose which domain new links are shown on. Custom domains must be verified first.</p>
<form method="POST" action="{{ route('link.settings.default-domain') }}" class="mt-4 space-y-3">
@csrf @method('PUT')
<label class="flex items-center gap-3 rounded-lg border border-slate-200 px-4 py-3">
<input type="radio" name="domain" value="platform" @checked(! $defaultDomain) class="text-emerald-600">
<span class="text-sm text-slate-700"><span class="font-medium">{{ $publicDomain }}</span> Ladill default</span>
</label>
@foreach($customDomains->where('status', 'active')->where('ssl_status', 'active') as $domain)
<label class="flex items-center gap-3 rounded-lg border border-slate-200 px-4 py-3">
<input type="radio" name="domain" value="{{ $domain->id }}" @checked($defaultDomain?->id === $domain->id) class="text-emerald-600">
<span class="text-sm text-slate-700"><span class="font-medium">{{ $domain->host }}</span> custom domain</span>
</label>
@endforeach
<button type="submit" class="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700">Save</button>
</form>
<p class="mt-3 text-xs text-slate-500">
<a href="{{ route('link.domains.index') }}" class="font-medium text-emerald-700 hover:underline">Manage custom domains </a>
</p>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-6">
<h2 class="font-semibold text-slate-900">Billing</h2>
<p class="mt-1 text-sm text-slate-500">GHS {{ number_format($pricePerLink, 2) }} per link created. Charged to your Ladill wallet.</p>
<div class="mt-4 flex flex-wrap gap-3">
<a href="{{ route('account.wallet') }}" class="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Wallet</a>
<a href="{{ route('account.billing') }}" class="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Billing history</a>
</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-6">
<h2 class="font-semibold text-slate-900">Ladill Account</h2>
<p class="mt-1 text-sm text-slate-500">Profile, security, and platform settings live on your Ladill account.</p>
<a href="{{ ladill_account_url('account-settings') }}" class="mt-4 inline-flex rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
Open account settings
</a>
</div>
</div>
</x-user-layout>
+3 -2
View File
@@ -1,4 +1,5 @@
<x-layouts.user :title="$link->label ?: $link->slug">
<x-user-layout>
<x-slot name="title">{{ $link->label ?: $link->slug }}</x-slot>
<div class="mx-auto max-w-3xl space-y-6">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
@@ -55,4 +56,4 @@
<button type="submit" class="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700">Save changes</button>
</form>
</div>
</x-layouts.user>
</x-user-layout>
@@ -10,6 +10,10 @@
'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.*'),
'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' => '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" />'],
['name' => 'Custom Domains', 'route' => route('link.domains.index'), 'active' => request()->routeIs('link.domains.*'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5a17.92 17.92 0 0 1-8.716-2.247m0 0A8.966 8.966 0 0 1 3 12c0-1.264.26-2.467.732-3.553" />'],
];
@endphp
<nav class="flex-1 space-y-0.5 overflow-y-auto px-3 py-4">
@@ -20,4 +24,14 @@
</a>
@endforeach
</nav>
<div class="shrink-0 border-t border-slate-100 px-3 py-3">
<a href="{{ route('link.settings') }}"
class="group flex items-center gap-3 rounded-lg px-3 py-2 text-[13px] transition {{ request()->routeIs('link.settings*') ? 'bg-emerald-50 text-emerald-700 font-semibold' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900' }}">
<svg class="h-[18px] w-[18px] shrink-0 {{ request()->routeIs('link.settings*') ? 'text-emerald-600' : 'text-slate-400' }}" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.241.437-.613.43-.992a6.932 6.932 0 0 1 0-.255c.007-.378-.138-.75-.43-.991l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.281Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
<span class="flex-1 truncate">Settings</span>
</a>
</div>
</div>
+2 -2
View File
@@ -14,7 +14,7 @@
</button>
@include('partials.mobile-topbar-title')
<div class="relative hidden min-w-0 flex-1 max-w-md lg:block"
x-data="topbarSearch({ searchUrl: @js(route('qr.search')) })"
x-data="topbarSearch({ searchUrl: @js(route('user.links.index')) })"
@click.outside="open = false"
@keydown.escape.window="open = false">
<div class="relative">
@@ -26,7 +26,7 @@
@keydown.arrow-down.prevent="moveDown()"
@keydown.arrow-up.prevent="moveUp()"
@keydown.enter.prevent="go()"
placeholder="Search QR codes…"
placeholder="Search links…"
class="w-full rounded-xl border border-slate-200 bg-slate-50 py-2 pl-9 pr-10 text-sm text-slate-700 placeholder-slate-400 transition focus:border-indigo-300 focus:bg-white focus:ring-2 focus:ring-indigo-100 focus:outline-none">
<kbd class="pointer-events-none absolute right-3 top-1/2 hidden -translate-y-1/2 rounded-md border border-slate-200 bg-white px-1.5 py-0.5 text-[10px] font-medium text-slate-400 sm:inline-block">/</kbd>
</div>
+2 -2
View File
@@ -4,8 +4,8 @@
@include('partials.search-screen', [
'query' => $query,
'results' => $results,
'backUrl' => route('qr.dashboard'),
'searchUrl' => route('qr.search'),
'backUrl' => route('link.dashboard'),
'searchUrl' => route('user.links.index'),
'heading' => 'Find QR codes',
'placeholder' => 'Search QR codes, short codes, URLs…',
'emptyHint' => 'Use at least 2 characters to search labels, short codes, or destinations.',
+2 -1
View File
@@ -1,5 +1,6 @@
<?php
use App\Http\Controllers\Api\SslCallbackController;
use Illuminate\Support\Facades\Route;
// Ladill Link has no public API yet — management is web-only.
Route::post('/ssl-callback', SslCallbackController::class)->name('api.ssl-callback');
+13 -1
View File
@@ -1,8 +1,11 @@
<?php
use App\Http\Controllers\Auth\SsoLoginController;
use App\Http\Controllers\Link\AnalyticsController;
use App\Http\Controllers\Link\CustomDomainController;
use App\Http\Controllers\Link\LinkController;
use App\Http\Controllers\Link\OverviewController;
use App\Http\Controllers\Link\SettingsController;
use App\Http\Controllers\NotificationController;
use App\Http\Controllers\Public\LinkRedirectController;
use App\Http\Controllers\WalletBalanceController;
@@ -31,6 +34,14 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
Route::post('/notifications/mark-all-read', [NotificationController::class, 'markAllAsRead'])->name('notifications.mark-all-read');
Route::get('/dashboard', [OverviewController::class, 'index'])->name('link.dashboard');
Route::get('/analytics', [AnalyticsController::class, 'index'])->name('link.analytics.index');
Route::get('/domains', [CustomDomainController::class, 'index'])->name('link.domains.index');
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}/default', [CustomDomainController::class, 'makeDefault'])->name('link.domains.default');
Route::delete('/domains/{customDomain}', [CustomDomainController::class, 'destroy'])->name('link.domains.destroy');
Route::get('/settings', [SettingsController::class, 'index'])->name('link.settings');
Route::put('/settings/default-domain', [SettingsController::class, 'updateDefaultDomain'])->name('link.settings.default-domain');
Route::get('/links', [LinkController::class, 'index'])->name('user.links.index');
Route::get('/links/create', [LinkController::class, 'create'])->name('user.links.create');
Route::post('/links', [LinkController::class, 'store'])->name('user.links.store');
@@ -44,6 +55,7 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
});
// Public short-link resolution — must be last so app routes are not shadowed.
Route::get('/{slug}', [LinkRedirectController::class, 'resolve'])
Route::match(['GET', 'POST', 'PATCH', 'DELETE'], '/{slug}/{path?}', [LinkRedirectController::class, 'resolve'])
->where('slug', '[a-z0-9][a-z0-9-]{1,30}[a-z0-9]')
->where('path', '.*')
->name('link.public.resolve');