Expand Link app with analytics, settings, and custom domains.
Deploy Ladill Link / deploy (push) Successful in 37s
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:
@@ -1,16 +1,17 @@
|
|||||||
name: Deploy Ladill QR Plus
|
name: Deploy Ladill Link
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
- master
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: deploy-qr-plus
|
group: deploy-link
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -19,9 +20,9 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
NODE_ROOT: /tmp/ladill-node-22-r1
|
NODE_ROOT: /tmp/ladill-node-22-r1
|
||||||
NODE_VERSION: "22.14.0"
|
NODE_VERSION: "22.14.0"
|
||||||
RELEASE_ARCHIVE: /tmp/ladill-qr-plus-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz
|
RELEASE_ARCHIVE: /tmp/ladill-link-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz
|
||||||
WORKSPACE: /tmp/${{ gitea.repository_owner }}-qr-plus-${{ gitea.run_id }}-${{ gitea.run_attempt }}
|
WORKSPACE: /tmp/${{ gitea.repository_owner }}-link-${{ gitea.run_id }}-${{ gitea.run_attempt }}
|
||||||
LADILL_APP_ROOT: /var/www/ladill-qr-plus
|
LADILL_APP_ROOT: /var/www/ladill-link
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
shell: bash {0}
|
shell: bash {0}
|
||||||
@@ -78,7 +79,7 @@ jobs:
|
|||||||
- name: Deploy release
|
- name: Deploy release
|
||||||
shell: bash {0}
|
shell: bash {0}
|
||||||
env:
|
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: |
|
run: |
|
||||||
set -Eeuo pipefail
|
set -Eeuo pipefail
|
||||||
: "${LADILL_APP_ROOT:?Set LADILL_APP_ROOT}"
|
: "${LADILL_APP_ROOT:?Set LADILL_APP_ROOT}"
|
||||||
|
|||||||
@@ -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;
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\QrTeamMember;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Http\Client\Response as HttpResponse;
|
use Illuminate\Http\Client\Response as HttpResponse;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
@@ -81,7 +80,7 @@ class SsoLoginController extends Controller
|
|||||||
|
|
||||||
public function callback(Request $request): RedirectResponse|View
|
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');
|
$popup = (bool) $request->session()->get('sso.popup');
|
||||||
|
|
||||||
@@ -118,8 +117,6 @@ class SsoLoginController extends Controller
|
|||||||
return $this->finishCallback($request, $intended, 'userinfo_failed', $popup);
|
return $this->finishCallback($request, $intended, 'userinfo_failed', $popup);
|
||||||
}
|
}
|
||||||
|
|
||||||
QrTeamMember::linkPendingInvitesFor($user);
|
|
||||||
|
|
||||||
Auth::login($user, remember: true);
|
Auth::login($user, remember: true);
|
||||||
$request->session()->regenerate();
|
$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\Http\Controllers\Controller;
|
||||||
use App\Services\Link\LinkManagerService;
|
use App\Services\Link\LinkManagerService;
|
||||||
|
use App\Services\Link\LinkPlatformProxy;
|
||||||
|
use App\Support\LadillLink;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\View\View;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
class LinkRedirectController extends Controller
|
class LinkRedirectController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private LinkManagerService $links,
|
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 ($path === null || $path === '') {
|
||||||
|
$link = $this->links->resolve($request, $slug);
|
||||||
if ($link !== null) {
|
if ($link !== null) {
|
||||||
return redirect()->away($link->destination_url, 302);
|
return redirect()->away($link->destination_url, 302);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy QR / storefront codes still resolve on ladill.com/q/*
|
return $this->platform->forward($request, $slug, $path);
|
||||||
$legacy = rtrim((string) config('app.platform_url', 'https://ladill.com'), '/').'/q/'.$slug;
|
|
||||||
$qs = $request->getQueryString();
|
|
||||||
|
|
||||||
return redirect()->away($legacy.($qs ? '?'.$qs : ''), 302);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,9 +38,12 @@ class ShortLink extends Model
|
|||||||
return $this->hasMany(LinkClick::class);
|
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
|
public static function publicBaseUrl(): string
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
|||||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
use Laravel\Sanctum\HasApiTokens;
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,6 +28,17 @@ class User extends Authenticatable
|
|||||||
return ['email_verified_at' => 'datetime', 'password' => 'hashed'];
|
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
|
public function linkWallet(): HasOne
|
||||||
{
|
{
|
||||||
return $this->hasOne(LinkWallet::class);
|
return $this->hasOne(LinkWallet::class);
|
||||||
@@ -37,6 +49,25 @@ class User extends Authenticatable
|
|||||||
return $this->hasMany(ShortLink::class);
|
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
|
public function getOrCreateLinkWallet(): LinkWallet
|
||||||
{
|
{
|
||||||
return $this->linkWallet()->firstOrCreate(
|
return $this->linkWallet()->firstOrCreate(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, '/');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -2,5 +2,5 @@
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
'api_url' => env('IDENTITY_API_URL', 'https://ladill.com/api'),
|
'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
@@ -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
|
| DO NOT EDIT BY HAND. This file is generated from the app registry in the
|
||||||
| launcher in a new app, copy these three things verbatim into that repo:
|
| monolith (config/ladill_apps.php). To change the launcher, edit that
|
||||||
| - config/ladill_launcher.php (this file)
|
| registry and run: php artisan ladill:launcher:sync --propagate
|
||||||
| - resources/views/partials/launcher.blade.php
|
|
||||||
| - public/images/launcher-icons/* (the icon set)
|
|
||||||
|
|
|
|
||||||
| When a service becomes FULLY extracted: add one row below AND drop its icon in
|
| URLs are absolute (built from the platform root) so this file is
|
||||||
| public/images/launcher-icons/ — in EVERY repo. That's the only edit needed.
|
| byte-identical in every repo; the blade omits whichever row matches the
|
||||||
| Do NOT list a service here until it is fully extracted to its own subdomain.
|
| app's own 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 app; the blade omits whichever row matches the app's APP_URL host.
|
|
||||||
| Icons are served from /images/launcher-icons/<icon>.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
$root = config('app.platform_domain', 'ladill.com');
|
$root = config('app.platform_domain', 'ladill.com');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'apps' => [
|
'apps' => [
|
||||||
['name' => 'Bird', 'url' => 'https://bird.'.$root, 'icon' => 'bird.svg'],
|
['name' => 'Merchant', 'url' => 'https://merchant.'.$root.'/sso/connect?redirect='.urlencode('https://merchant.'.$root.'/dashboard'), 'icon' => 'merchant.svg'],
|
||||||
['name' => 'Email', 'url' => 'https://email.'.$root, 'icon' => 'email.svg'],
|
['name' => 'POS', 'url' => 'https://pos.'.$root.'/sso/connect?redirect='.urlencode('https://pos.'.$root.'/dashboard'), 'icon' => 'pos.svg'],
|
||||||
['name' => 'Mail', 'url' => 'https://mail.'.$root, 'icon' => 'mail.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' => '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' => 'Domains', 'url' => 'https://domains.'.$root, 'icon' => 'domains.svg'],
|
||||||
['name' => 'Servers', 'url' => 'https://servers.'.$root, 'icon' => 'servers.svg'],
|
['name' => 'Servers', 'url' => 'https://servers.'.$root, 'icon' => 'servers.svg'],
|
||||||
['name' => 'Hosting', 'url' => 'https://hosting.'.$root, 'icon' => 'hosting.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
@@ -23,7 +23,7 @@ return [
|
|||||||
'issuer' => 'https://'.config('app.auth_domain'),
|
'issuer' => 'https://'.config('app.auth_domain'),
|
||||||
'client_id' => env('LADILL_SSO_CLIENT_ID'),
|
'client_id' => env('LADILL_SSO_CLIENT_ID'),
|
||||||
'client_secret' => env('LADILL_SSO_CLIENT_SECRET'),
|
'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' => [
|
'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
@@ -2,7 +2,7 @@
|
|||||||
# Fast on-host release deploy (same model as climpme/web/deploy/deploy.sh).
|
# Fast on-host release deploy (same model as climpme/web/deploy/deploy.sh).
|
||||||
set -Eeuo pipefail
|
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"
|
RELEASES_DIR="$APP_ROOT/releases"
|
||||||
SHARED_DIR="$APP_ROOT/shared"
|
SHARED_DIR="$APP_ROOT/shared"
|
||||||
CURRENT_LINK="$APP_ROOT/current"
|
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
|
(cd "$CURRENT_LINK" && php artisan queue:restart) || true
|
||||||
fi
|
fi
|
||||||
if command -v supervisorctl >/dev/null 2>&1; then
|
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
|
fi
|
||||||
|
|
||||||
log "Cleaning old releases"
|
log "Cleaning old releases"
|
||||||
|
|||||||
@@ -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 |
@@ -1,3 +1,4 @@
|
|||||||
|
@props(['title' => 'Dashboard'])
|
||||||
@php
|
@php
|
||||||
$mobileFullScreenPage = false;
|
$mobileFullScreenPage = false;
|
||||||
@endphp
|
@endphp
|
||||||
@@ -8,7 +9,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||||
@include('partials.favicon')
|
@include('partials.favicon')
|
||||||
<title>{{ $title ?? 'Dashboard' }} - Ladill Link</title>
|
<title>{{ $title }} - Ladill Link</title>
|
||||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||||
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
|
<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'])
|
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||||
@@ -239,7 +240,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
@include('partials.afia')
|
|
||||||
@include('partials.sso-keepalive')
|
@include('partials.sso-keepalive')
|
||||||
@include('partials.confirm-prompt')
|
@include('partials.confirm-prompt')
|
||||||
</body>
|
</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>
|
||||||
@@ -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 class="mx-auto max-w-xl space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-semibold text-slate-900">Create short link</h1>
|
<h1 class="text-2xl font-semibold text-slate-900">Create short link</h1>
|
||||||
@@ -50,4 +51,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</x-layouts.user>
|
</x-user-layout>
|
||||||
|
|||||||
@@ -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="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 class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -47,4 +48,4 @@
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</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>
|
||||||
@@ -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="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 class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -52,4 +53,4 @@
|
|||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
@@ -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="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 class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div>
|
<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>
|
<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>
|
</form>
|
||||||
</div>
|
</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" />'],
|
'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' => '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
|
@endphp
|
||||||
<nav class="flex-1 space-y-0.5 overflow-y-auto px-3 py-4">
|
<nav class="flex-1 space-y-0.5 overflow-y-auto px-3 py-4">
|
||||||
@@ -20,4 +24,14 @@
|
|||||||
</a>
|
</a>
|
||||||
@endforeach
|
@endforeach
|
||||||
</nav>
|
</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>
|
</div>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
</button>
|
</button>
|
||||||
@include('partials.mobile-topbar-title')
|
@include('partials.mobile-topbar-title')
|
||||||
<div class="relative hidden min-w-0 flex-1 max-w-md lg:block"
|
<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"
|
@click.outside="open = false"
|
||||||
@keydown.escape.window="open = false">
|
@keydown.escape.window="open = false">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
@keydown.arrow-down.prevent="moveDown()"
|
@keydown.arrow-down.prevent="moveDown()"
|
||||||
@keydown.arrow-up.prevent="moveUp()"
|
@keydown.arrow-up.prevent="moveUp()"
|
||||||
@keydown.enter.prevent="go()"
|
@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">
|
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>
|
<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>
|
</div>
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
@include('partials.search-screen', [
|
@include('partials.search-screen', [
|
||||||
'query' => $query,
|
'query' => $query,
|
||||||
'results' => $results,
|
'results' => $results,
|
||||||
'backUrl' => route('qr.dashboard'),
|
'backUrl' => route('link.dashboard'),
|
||||||
'searchUrl' => route('qr.search'),
|
'searchUrl' => route('user.links.index'),
|
||||||
'heading' => 'Find QR codes',
|
'heading' => 'Find QR codes',
|
||||||
'placeholder' => 'Search QR codes, short codes, URLs…',
|
'placeholder' => 'Search QR codes, short codes, URLs…',
|
||||||
'emptyHint' => 'Use at least 2 characters to search labels, short codes, or destinations.',
|
'emptyHint' => 'Use at least 2 characters to search labels, short codes, or destinations.',
|
||||||
|
|||||||
+2
-1
@@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\SslCallbackController;
|
||||||
use Illuminate\Support\Facades\Route;
|
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
@@ -1,8 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\Auth\SsoLoginController;
|
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\LinkController;
|
||||||
use App\Http\Controllers\Link\OverviewController;
|
use App\Http\Controllers\Link\OverviewController;
|
||||||
|
use App\Http\Controllers\Link\SettingsController;
|
||||||
use App\Http\Controllers\NotificationController;
|
use App\Http\Controllers\NotificationController;
|
||||||
use App\Http\Controllers\Public\LinkRedirectController;
|
use App\Http\Controllers\Public\LinkRedirectController;
|
||||||
use App\Http\Controllers\WalletBalanceController;
|
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::post('/notifications/mark-all-read', [NotificationController::class, 'markAllAsRead'])->name('notifications.mark-all-read');
|
||||||
|
|
||||||
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('/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', [LinkController::class, 'index'])->name('user.links.index');
|
||||||
Route::get('/links/create', [LinkController::class, 'create'])->name('user.links.create');
|
Route::get('/links/create', [LinkController::class, 'create'])->name('user.links.create');
|
||||||
Route::post('/links', [LinkController::class, 'store'])->name('user.links.store');
|
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.
|
// 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('slug', '[a-z0-9][a-z0-9-]{1,30}[a-z0-9]')
|
||||||
|
->where('path', '.*')
|
||||||
->name('link.public.resolve');
|
->name('link.public.resolve');
|
||||||
|
|||||||
Reference in New Issue
Block a user