Add Ladill Link URL shortener on ladl.link.

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-27 10:58:41 +00:00
co-authored by Cursor
parent 04e4f6ab51
commit d9c91ad7d8
30 changed files with 1002 additions and 247 deletions
+8 -12
View File
@@ -1,20 +1,20 @@
APP_NAME="Ladill QR Plus"
APP_NAME="Ladill Link"
APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=https://qrplus.ladill.com
APP_URL=https://link.ladill.com
PLATFORM_URL=https://ladill.com
PLATFORM_DOMAIN=ladill.com
AUTH_DOMAIN=auth.ladill.com
ACCOUNT_DOMAIN=account.ladill.com
QR_DOMAIN=qrplus.ladill.com
LINK_PUBLIC_DOMAIN=ladl.link
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=ladill_qr_plus
DB_USERNAME=ladill_qr_plus
DB_DATABASE=ladill_link
DB_USERNAME=ladill_link
DB_PASSWORD=
SESSION_DRIVER=database
@@ -25,15 +25,11 @@ LADILL_SSO_CLIENT_ID=
LADILL_SSO_CLIENT_SECRET=
BILLING_API_URL=https://ladill.com/api/billing
BILLING_API_KEY_QR=
BILLING_API_KEY_LINK=
IDENTITY_API_URL=https://ladill.com/api
IDENTITY_API_KEY_QR=
IDENTITY_API_KEY_LINK=
AFIA_ENABLED=true
AFIA_PRODUCT=qr
AFIA_PROVIDER=openai
AFIA_MODEL=gpt-4o-mini
AFIA_API_KEY=
LINK_PRICE_PER_LINK_GHS=0.05
VITE_APP_NAME="${APP_NAME}"
+23 -63
View File
@@ -1,87 +1,47 @@
# Ladill QR Plus — deploy & cutover runbook
# Ladill Link — deployment
Standalone app for **utility QR codes** at `qrplus.ladill.com` (URL, WiFi, link
list, business, app download). **vCard** is a separate future product
(`vcard.ladill.com`). Commerce types (shop, menu, event, give, …) stay on the
platform until Merchant/Events/Give extract.
Ladill Link is the platform URL shortener. Public short URLs live on **ladl.link**; the management app runs at **link.ladill.com**.
Trusts `auth.ladill.com` for SSO and bills the one platform UserWallet via the
Billing API. Public scans stay at `ladill.com/q/<code>` (nginx proxies to this
app for Plus codes after cutover).
## Domains
---
| Host | Purpose |
|------|---------|
| `ladl.link` | Public short-link resolution (`/{slug}`) |
| `link.ladill.com` | SSO management UI (create/edit links) |
## 0. Prerequisites
Both vhosts point at the same Laravel app. Set `LINK_PUBLIC_DOMAIN=ladl.link` in `.env`.
| Secret | Where |
|---|---|
| `LADILL_SSO_CLIENT_ID` / `LADILL_SSO_CLIENT_SECRET` | `passport:client` on platform |
| `BILLING_API_KEY_QR` | platform `.env` + this app |
| `IDENTITY_API_KEY_QR` | platform `.env` + this app |
## Pricing
## 1. Gitea repo + CI
GHS **0.05** per link created (`LINK_PRICE_PER_LINK_GHS`). Debited from the central UserWallet via the Billing API.
1. Repo: **http://161.97.138.149:3000/isaacclad/ladill-qr-plus**
2. Push to `main` triggers `.gitea/workflows/deploy.yml`.
3. App root: `/var/www/ladill-qr-plus`
## Platform onboarding
## 2. Server app-slot + database
From the monolith:
```bash
sudo install -d -o deploy -g www-data /var/www/ladill-qr-plus
sudo mysql -e "CREATE DATABASE ladill_qr_plus CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
sudo mysql -e "CREATE USER 'ladill_qr_plus'@'127.0.0.1' IDENTIFIED BY '<pw>';"
sudo mysql -e "GRANT ALL ON ladill_qr_plus.* TO 'ladill_qr_plus'@'127.0.0.1'; FLUSH PRIVILEGES;"
php artisan ladill:onboard-app link --run-dns
php artisan ladill:launcher:sync --propagate --push
```
## 3. Register OIDC client (platform)
Register the Passport OIDC client:
```bash
php artisan passport:client \
--name="Ladill QR Plus" \
--redirect_uri="https://qrplus.ladill.com/sso/callback"
--name="Ladill Link" \
--redirect_uri="https://link.ladill.com/sso/callback"
```
## 4. Platform integration
Set `BILLING_API_KEY_LINK` and `IDENTITY_API_KEY_LINK` on both monolith and this app.
```env
BILLING_API_KEY_QR=<same>
IDENTITY_API_KEY_QR=<same>
RP_QR_FRONTCHANNEL_LOGOUT=https://qrplus.ladill.com/sso/logout-frontchannel
LADILL_QR_APP_URL=https://qrplus.ladill.com
```
## Legacy URLs
## 5. nginx + TLS
`https://ladill.com/q/{code}` continues to work for existing QR and storefront codes. New links use `https://ladl.link/{code}`. Unknown slugs on ladl.link fall back to the legacy resolver.
## Database
```bash
sudo deployment/setup-service-subdomain-nginx.sh qrplus --app /var/www/ladill-qr-plus/current
```
Proxy `ladill.com/q/*` to this app after import (see platform nginx docs).
## 6. First deploy
```bash
cd /var/www/ladill-qr-plus/current
php artisan migrate --force
php artisan config:cache route:cache view:cache
```
## 7. Data migration
```bash
# platform
php artisan qr-plus:export --out=/tmp/qr-plus-export.json
# qr app (include --storage-source so PDF files copy across)
php artisan qr-plus:import /tmp/qr-plus-export.json \
--storage-source=/var/www/ladill/current/storage/app/private/qr \
--commit
```
## 8. Smoke test
- `https://qrplus.ladill.com/up` → 200
- SSO login → create URL QR → wallet debited
- `https://ladill.com/q/<code>` resolves (after nginx proxy)
- `account.ladill.com/qr-codes` redirects here
Database name: `ladill_link`.
@@ -23,14 +23,14 @@ class SsoLoginController extends Controller
{
public function connect(Request $request): RedirectResponse|View
{
$intended = (string) $request->query('redirect', route('qr.dashboard'));
$intended = (string) $request->query('redirect', route('link.dashboard'));
if (Auth::check()) {
return $this->safeRedirect($intended, route('qr.dashboard'));
return $this->safeRedirect($intended, route('link.dashboard'));
}
if ($this->attemptSilentRefresh($request, $intended)) {
return $this->safeRedirect($intended, route('qr.dashboard'));
return $this->safeRedirect($intended, route('link.dashboard'));
}
$verifier = Str::random(64);
@@ -282,7 +282,7 @@ class SsoLoginController extends Controller
]);
}
return $this->safeRedirect($intended, route('qr.dashboard'));
return $this->safeRedirect($intended, route('link.dashboard'));
}
private function defaultSignedOutUrl(): string
@@ -0,0 +1,118 @@
<?php
namespace App\Http\Controllers\Link;
use App\Http\Controllers\Controller;
use App\Models\ShortLink;
use App\Services\Link\LinkBillingService;
use App\Services\Link\LinkManagerService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use RuntimeException;
class LinkController extends Controller
{
public function __construct(
private LinkManagerService $links,
private LinkBillingService $billing,
) {}
public function index(Request $request): View
{
$account = ladill_account();
$links = ShortLink::query()
->where('user_id', $account->id)
->latest()
->paginate(20);
return view('links.index', [
'links' => $links,
'canCreate' => $this->billing->canCreate($account),
'pricePerLink' => \App\Models\LinkWallet::pricePerLink(),
]);
}
public function create(): View
{
$account = ladill_account();
return view('links.create', [
'canCreate' => $this->billing->canCreate($account),
'pricePerLink' => \App\Models\LinkWallet::pricePerLink(),
]);
}
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'destination_url' => ['required', 'string', 'max:2048'],
'label' => ['nullable', 'string', 'max:120'],
'custom_slug' => ['nullable', 'string', 'max:20', 'regex:/^[a-z0-9][a-z0-9-]{1,18}[a-z0-9]$/'],
'expires_at' => ['nullable', 'date', 'after:now'],
]);
try {
$link = $this->links->create(ladill_account(), $validated);
} catch (RuntimeException $e) {
return back()->withInput()->withErrors(['balance' => $e->getMessage()]);
}
return redirect()
->route('user.links.show', $link)
->with('success', 'Short link created.');
}
public function show(ShortLink $link): View
{
$this->authorizeLink($link);
return view('links.show', [
'link' => $link,
'recentClicks' => $link->clicks()->latest('clicked_at')->limit(20)->get(),
]);
}
public function update(Request $request, ShortLink $link): RedirectResponse
{
$this->authorizeLink($link);
$validated = $request->validate([
'destination_url' => ['sometimes', 'required', 'string', 'max:2048'],
'label' => ['nullable', 'string', 'max:120'],
'is_active' => ['sometimes', 'boolean'],
'expires_at' => ['nullable', 'date'],
]);
try {
$this->links->update($link, $validated);
} catch (RuntimeException $e) {
return back()->withInput()->withErrors(['destination_url' => $e->getMessage()]);
}
return back()->with('success', 'Link updated.');
}
public function destroy(ShortLink $link): RedirectResponse
{
$this->authorizeLink($link);
$link->delete();
return redirect()->route('user.links.index')->with('success', 'Link deleted.');
}
public function checkSlug(Request $request): \Illuminate\Http\JsonResponse
{
$slug = strtolower((string) $request->query('slug', ''));
$taken = $slug !== '' && ShortLink::where('slug', $slug)->exists();
return response()->json(['available' => ! $taken]);
}
private function authorizeLink(ShortLink $link): void
{
if ($link->user_id !== ladill_account()->id) {
abort(403);
}
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Http\Controllers\Link;
use App\Http\Controllers\Controller;
use App\Models\ShortLink;
use Illuminate\View\View;
class OverviewController extends Controller
{
public function index(): View
{
$account = ladill_account();
$wallet = $account->getOrCreateLinkWallet();
$recentLinks = ShortLink::query()
->where('user_id', $account->id)
->latest()
->limit(5)
->get();
return view('links.dashboard', [
'wallet' => $wallet,
'recentLinks' => $recentLinks,
'pricePerLink' => \App\Models\LinkWallet::pricePerLink(),
]);
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Controllers\Public;
use App\Http\Controllers\Controller;
use App\Services\Link\LinkManagerService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class LinkRedirectController extends Controller
{
public function __construct(
private LinkManagerService $links,
) {}
public function resolve(Request $request, string $slug): RedirectResponse|View
{
$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);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class LinkClick extends Model
{
protected $fillable = [
'short_link_id',
'ip_hash',
'user_agent',
'referer',
'country',
'is_unique',
'clicked_at',
];
protected $casts = [
'is_unique' => 'boolean',
'clicked_at' => 'datetime',
];
public function shortLink(): BelongsTo
{
return $this->belongsTo(ShortLink::class);
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class LinkTransaction extends Model
{
public const TYPE_DEBIT = 'debit';
protected $fillable = [
'user_id',
'link_wallet_id',
'short_link_id',
'type',
'amount_ghs',
'balance_after_ghs',
'reference',
'status',
'description',
'metadata',
];
protected $casts = [
'amount_ghs' => 'decimal:4',
'balance_after_ghs' => 'decimal:4',
'metadata' => 'array',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function wallet(): BelongsTo
{
return $this->belongsTo(LinkWallet::class, 'link_wallet_id');
}
public function shortLink(): BelongsTo
{
return $this->belongsTo(ShortLink::class);
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class LinkWallet extends Model
{
public const STATUS_ACTIVE = 'active';
protected $fillable = [
'user_id',
'links_total',
'clicks_total',
'status',
];
protected $casts = [
'links_total' => 'integer',
'clicks_total' => 'integer',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function transactions(): HasMany
{
return $this->hasMany(LinkTransaction::class);
}
public static function pricePerLink(): float
{
return (float) config('link.price_per_link_ghs', 0.05);
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class ShortLink extends Model
{
protected $fillable = [
'user_id',
'slug',
'label',
'destination_url',
'is_active',
'clicks_total',
'unique_clicks_total',
'last_clicked_at',
'expires_at',
];
protected $casts = [
'is_active' => 'boolean',
'clicks_total' => 'integer',
'unique_clicks_total' => 'integer',
'last_clicked_at' => 'datetime',
'expires_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function clicks(): HasMany
{
return $this->hasMany(LinkClick::class);
}
public function publicUrl(): string
{
return self::publicBaseUrl().'/'.$this->slug;
}
public static function publicBaseUrl(): string
{
$domain = (string) config('link.public_domain', 'ladl.link');
return 'https://'.$domain;
}
public function isExpired(): bool
{
return $this->expires_at !== null && $this->expires_at->isPast();
}
}
+7 -38
View File
@@ -8,7 +8,6 @@ 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;
/**
@@ -28,51 +27,21 @@ class User extends Authenticatable
return ['email_verified_at' => 'datetime', 'password' => 'hashed'];
}
public function memberships(): HasMany
public function linkWallet(): HasOne
{
return $this->hasMany(QrTeamMember::class, 'user_id')
->where('status', QrTeamMember::STATUS_ACTIVE);
return $this->hasOne(LinkWallet::class);
}
public function canAccessAccount(int $accountId): bool
public function shortLinks(): HasMany
{
return $accountId === $this->id
|| $this->memberships()->where('account_id', $accountId)->exists();
return $this->hasMany(ShortLink::class);
}
/** @return Collection<int, User> */
public function accessibleAccounts(): Collection
public function getOrCreateLinkWallet(): LinkWallet
{
$ids = $this->memberships()->pluck('account_id')->all();
return collect([$this])->merge(self::whereIn('id', $ids)->get())->unique('id')->values();
}
public function qrWallet(): HasOne
{
return $this->hasOne(QrWallet::class);
}
public function qrCodes(): HasMany
{
return $this->hasMany(QrCode::class);
}
public function qrSetting(): HasOne
{
return $this->hasOne(QrSetting::class);
}
public function getOrCreateQrSetting(): QrSetting
{
return $this->qrSetting()->firstOrCreate([]);
}
public function getOrCreateQrWallet(): QrWallet
{
return $this->qrWallet()->firstOrCreate(
return $this->linkWallet()->firstOrCreate(
[],
['credit_balance' => 0, 'qr_codes_total' => 0, 'scans_total' => 0, 'status' => QrWallet::STATUS_ACTIVE],
['links_total' => 0, 'clicks_total' => 0, 'status' => LinkWallet::STATUS_ACTIVE],
);
}
+1 -6
View File
@@ -2,12 +2,9 @@
namespace App\Providers;
use App\Models\QrCode;
use App\Policies\QrCodePolicy;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;
use App\Support\MobileTopbar;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
@@ -18,10 +15,8 @@ class AppServiceProvider extends ServiceProvider
public function boot(): void
{
Gate::policy(QrCode::class, QrCodePolicy::class);
View::composer(['partials.topbar', 'partials.topbar-qr'], function ($view) {
$view->with(MobileTopbar::resolve());
});
}
}
+71
View File
@@ -0,0 +1,71 @@
<?php
namespace App\Services\Link;
use App\Models\LinkTransaction;
use App\Models\LinkWallet;
use App\Models\ShortLink;
use App\Models\User;
use App\Services\Billing\BillingClient;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use RuntimeException;
class LinkBillingService
{
public function __construct(
private BillingClient $billing,
) {}
public function balanceCedis(User $user): float
{
return $this->billing->balanceMinor($user->public_id) / 100;
}
public function canCreate(User $user): bool
{
$priceMinor = (int) round(LinkWallet::pricePerLink() * 100);
return $this->billing->canAfford($user->public_id, $priceMinor);
}
public function debitForLinkCreation(LinkWallet $wallet, ShortLink $link): LinkTransaction
{
$price = LinkWallet::pricePerLink();
$priceMinor = (int) round($price * 100);
$user = $wallet->user;
$reference = 'LINK-DEBIT-'.strtoupper(Str::random(12));
return DB::transaction(function () use ($wallet, $user, $link, $price, $priceMinor, $reference) {
$ok = $this->billing->debit(
$user->public_id,
$priceMinor,
'link',
'link_create',
$reference,
$link->id,
sprintf('Created short link: %s', $link->label ?: $link->slug),
);
if (! $ok) {
throw new RuntimeException('Insufficient wallet balance.');
}
$wallet->increment('links_total');
$balanceAfter = $this->billing->balanceMinor($user->public_id) / 100;
return LinkTransaction::create([
'user_id' => $wallet->user_id,
'link_wallet_id' => $wallet->id,
'short_link_id' => $link->id,
'type' => LinkTransaction::TYPE_DEBIT,
'amount_ghs' => round($price, 4),
'balance_after_ghs' => $balanceAfter,
'reference' => $reference,
'status' => 'completed',
'description' => sprintf('Created short link: %s', $link->label ?: $link->slug),
'metadata' => ['short_link_id' => $link->id],
]);
});
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace App\Services\Link;
use App\Models\LinkClick;
use App\Models\LinkWallet;
use App\Models\ShortLink;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class LinkClickRecorder
{
public function record(ShortLink $link, Request $request): void
{
$ipHash = hash('sha256', $request->ip().'|'.config('app.key'));
$windowHours = (int) config('link.click_unique_window_hours', 24);
$isUnique = ! LinkClick::query()
->where('short_link_id', $link->id)
->where('ip_hash', $ipHash)
->where('clicked_at', '>=', now()->subHours($windowHours))
->exists();
DB::transaction(function () use ($link, $request, $ipHash, $isUnique) {
LinkClick::create([
'short_link_id' => $link->id,
'ip_hash' => $ipHash,
'user_agent' => $this->truncate($request->userAgent(), 512),
'referer' => $this->truncate($request->header('referer'), 512),
'is_unique' => $isUnique,
'clicked_at' => now(),
]);
$link->increment('clicks_total');
if ($isUnique) {
$link->increment('unique_clicks_total');
}
$link->update(['last_clicked_at' => now()]);
LinkWallet::query()
->where('user_id', $link->user_id)
->increment('clicks_total');
});
}
private function truncate(?string $value, int $max): ?string
{
if ($value === null) {
return null;
}
return mb_strlen($value) > $max ? mb_substr($value, 0, $max) : $value;
}
}
+111
View File
@@ -0,0 +1,111 @@
<?php
namespace App\Services\Link;
use App\Models\LinkClick;
use App\Models\LinkWallet;
use App\Models\ShortLink;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use RuntimeException;
class LinkManagerService
{
public function __construct(
private LinkBillingService $billing,
private LinkClickRecorder $clickRecorder,
) {}
public function create(User $user, array $data): ShortLink
{
if (! $this->billing->canCreate($user)) {
throw new RuntimeException('Insufficient wallet balance. Top up to create links.');
}
return DB::transaction(function () use ($user, $data) {
$slug = $this->resolveSlug($data['custom_slug'] ?? null);
$wallet = $user->getOrCreateLinkWallet();
$link = ShortLink::create([
'user_id' => $user->id,
'slug' => $slug,
'label' => $data['label'] ?? null,
'destination_url' => $this->normalizeUrl($data['destination_url']),
'expires_at' => $data['expires_at'] ?? null,
]);
$this->billing->debitForLinkCreation($wallet, $link);
return $link;
});
}
public function update(ShortLink $link, array $data): ShortLink
{
$link->fill([
'label' => $data['label'] ?? $link->label,
'destination_url' => isset($data['destination_url'])
? $this->normalizeUrl($data['destination_url'])
: $link->destination_url,
'is_active' => $data['is_active'] ?? $link->is_active,
'expires_at' => array_key_exists('expires_at', $data) ? $data['expires_at'] : $link->expires_at,
])->save();
return $link->fresh();
}
public function resolve(Request $request, string $slug): ?ShortLink
{
$link = ShortLink::query()
->where('slug', $slug)
->where('is_active', true)
->first();
if ($link === null || $link->isExpired()) {
return null;
}
$this->clickRecorder->record($link, $request);
return $link;
}
private function resolveSlug(?string $custom): string
{
if ($custom !== null && $custom !== '') {
$custom = strtolower(trim($custom));
if (! preg_match('/^[a-z0-9][a-z0-9-]{1,18}[a-z0-9]$/', $custom)) {
throw new RuntimeException('Custom slug must be 320 characters: lowercase letters, numbers, and hyphens.');
}
if (ShortLink::where('slug', $custom)->exists()) {
throw new RuntimeException('That slug is already taken.');
}
return $custom;
}
do {
$slug = Str::lower(Str::random((int) config('link.slug_length', 8)));
} while (ShortLink::where('slug', $slug)->exists());
return $slug;
}
private function normalizeUrl(string $url): string
{
$url = trim($url);
if ($url === '') {
throw new RuntimeException('Destination URL is required.');
}
if (! preg_match('#^https?://#i', $url)) {
$url = 'https://'.$url;
}
if (! filter_var($url, FILTER_VALIDATE_URL)) {
throw new RuntimeException('Enter a valid URL.');
}
return $url;
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"$schema": "https://getcomposer.org/schema.json",
"name": "ladill/qr-plus",
"name": "ladill/link",
"type": "project",
"description": "Ladill QR Plus — utility QR codes at qrplus.ladill.com",
"description": "Ladill Link — short URLs at ladl.link, managed at link.ladill.com",
"keywords": [
"laravel",
"framework"
+3 -2
View File
@@ -123,11 +123,12 @@ return [
'store' => env('APP_MAINTENANCE_STORE', 'database'),
],
// Platform identity surfaces (Zoho One model). Ladill QR Plus (qrplus.ladill.com).
// Platform identity surfaces (Zoho One model). Ladill Link (link.ladill.com).
'platform_url' => env('PLATFORM_URL', 'https://ladill.com'),
'platform_domain' => env('PLATFORM_DOMAIN', parse_url((string) env('PLATFORM_URL', 'https://ladill.com'), PHP_URL_HOST) ?: 'ladill.com'),
'auth_domain' => env('AUTH_DOMAIN', 'auth.'.(parse_url((string) env('PLATFORM_URL', 'https://ladill.com'), PHP_URL_HOST) ?: 'ladill.com')),
'account_domain' => env('ACCOUNT_DOMAIN', 'account.'.(parse_url((string) env('PLATFORM_URL', 'https://ladill.com'), PHP_URL_HOST) ?: 'ladill.com')),
'qr_domain' => env('QR_DOMAIN', parse_url((string) env('APP_URL', 'https://qrplus.ladill.com'), PHP_URL_HOST) ?: 'qrplus.ladill.com'),
'link_domain' => env('LINK_DOMAIN', parse_url((string) env('APP_URL', 'https://link.ladill.com'), PHP_URL_HOST) ?: 'link.ladill.com'),
'servers_domain' => env('SERVERS_DOMAIN', 'servers.'.(parse_url((string) env('PLATFORM_URL', 'https://ladill.com'), PHP_URL_HOST) ?: 'ladill.com')),
'domains_domain' => env('DOMAINS_DOMAIN', 'domains.'.(parse_url((string) env('PLATFORM_URL', 'https://ladill.com'), PHP_URL_HOST) ?: 'ladill.com')),
+2 -2
View File
@@ -2,8 +2,8 @@
return [
'api_url' => env('BILLING_API_URL', 'https://ladill.com/api/billing'),
'api_key' => env('BILLING_API_KEY_QR'),
'service' => 'qr',
'api_key' => env('BILLING_API_KEY_LINK'),
'service' => 'link',
'wallet_balance_route' => 'wallet.balance',
'currency' => 'GHS',
];
+2 -2
View File
@@ -1,6 +1,6 @@
<?php
return [
'product_slug' => 'qrplus',
'marketing_url' => env('LADILL_MARKETING_URL', 'https://ladill.com/products/qrplus'),
'product_slug' => 'link',
'marketing_url' => env('LADILL_MARKETING_URL', 'https://ladill.com/products/link'),
];
+8
View File
@@ -0,0 +1,8 @@
<?php
return [
'price_per_link_ghs' => (float) env('LINK_PRICE_PER_LINK_GHS', 0.05),
'slug_length' => (int) env('LINK_SLUG_LENGTH', 8),
'click_unique_window_hours' => (int) env('LINK_CLICK_UNIQUE_WINDOW_HOURS', 24),
'public_domain' => env('LINK_PUBLIC_DOMAIN', 'ladl.link'),
];
@@ -0,0 +1,73 @@
<?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_wallets', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->unsignedInteger('links_total')->default(0);
$table->unsignedInteger('clicks_total')->default(0);
$table->string('status', 32)->default('active');
$table->timestamps();
});
Schema::create('short_links', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('slug', 32)->unique();
$table->string('label')->nullable();
$table->text('destination_url');
$table->boolean('is_active')->default(true);
$table->unsignedInteger('clicks_total')->default(0);
$table->unsignedInteger('unique_clicks_total')->default(0);
$table->timestamp('last_clicked_at')->nullable();
$table->timestamp('expires_at')->nullable();
$table->timestamps();
$table->index(['user_id', 'created_at']);
});
Schema::create('link_transactions', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('link_wallet_id')->constrained('link_wallets')->cascadeOnDelete();
$table->foreignId('short_link_id')->nullable()->constrained('short_links')->nullOnDelete();
$table->string('type', 16);
$table->decimal('amount_ghs', 10, 4);
$table->decimal('balance_after_ghs', 12, 4)->nullable();
$table->string('reference', 64)->unique();
$table->string('status', 32)->default('completed');
$table->string('description')->nullable();
$table->json('metadata')->nullable();
$table->timestamps();
});
Schema::create('link_clicks', function (Blueprint $table) {
$table->id();
$table->foreignId('short_link_id')->constrained('short_links')->cascadeOnDelete();
$table->string('ip_hash', 64)->nullable();
$table->string('user_agent', 512)->nullable();
$table->string('referer', 512)->nullable();
$table->string('country', 2)->nullable();
$table->boolean('is_unique')->default(false);
$table->timestamp('clicked_at');
$table->timestamps();
$table->index(['short_link_id', 'clicked_at']);
});
}
public function down(): void
{
Schema::dropIfExists('link_clicks');
Schema::dropIfExists('link_transactions');
Schema::dropIfExists('short_links');
Schema::dropIfExists('link_wallets');
}
};
+27 -43
View File
@@ -1,24 +1,19 @@
@php
$mobileFullScreenPage = request()->routeIs('account.settings')
|| request()->routeIs('user.qr-codes.create')
|| request()->routeIs('user.qr-codes.show')
|| request()->routeIs('qr.search');
$qrMobilePage = request()->routeIs('user.qr-codes.create') || request()->routeIs('user.qr-codes.show');
$mobileFullScreenPage = false;
@endphp
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" @class(['qr-mobile-page' => $qrMobilePage])>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<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</title>
<title>{{ $title ?? 'Dashboard' }} - 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'])
</head>
<body class="font-sans antialiased bg-slate-100" x-data="{ sidebarOpen: false }" data-ladill-search-url="{{ route('qr.search') }}">
<body class="font-sans antialiased bg-slate-100" x-data="{ sidebarOpen: false }">
<div class="min-h-screen max-lg:min-h-dvh">
{{-- Mobile sidebar overlay --}}
<div x-show="sidebarOpen" x-cloak class="fixed inset-0 z-30 bg-black/50 lg:hidden" @click="sidebarOpen = false"></div>
@@ -44,45 +39,34 @@
@include('partials.flash')
</div>
<main @class([
'flex-1 lg:p-6 lg:pb-6',
'px-4 pt-4 max-lg:pb-[calc(4rem+max(env(safe-area-inset-bottom,0px),20px))]' => $qrMobilePage,
'p-0 pb-24 lg:p-6 lg:pb-6' => $mobileFullScreenPage && ! $qrMobilePage,
'p-6 pb-24 lg:pb-6' => ! $mobileFullScreenPage,
])>
<main class="flex-1 p-6 pb-24 lg:pb-6">
{{ $slot }}
</main>
</div>
{{-- Mobile Bottom Navigation (hidden on QR create/show which have their own action bar) --}}
@unless(request()->routeIs('user.qr-codes.create') || request()->routeIs('user.qr-codes.show'))
@php
$navUser = auth()->user();
$navInitials = collect(explode(' ', trim((string) $navUser?->name)))
->filter()->take(2)
->map(fn ($part) => strtoupper(substr($part, 0, 1)))
->implode('');
@endphp
@include('partials.mobile-bottom-nav', [
'homeUrl' => route('qr.dashboard'),
'homeActive' => request()->routeIs('qr.dashboard'),
'searchUrl' => route('qr.search'),
'searchActive' => request()->routeIs('qr.search'),
'notificationsUrl' => route('notifications.index'),
'notificationsActive' => request()->routeIs('notifications.*'),
'unreadUrl' => route('notifications.unread'),
'profileActive' => request()->routeIs('account.settings'),
'profileName' => $navUser?->name ?? '',
'profileSubtitle' => $navUser?->email ?? '',
'profileMenuItems' => \App\Support\UserProfileMenu::items($navUser),
'avatarUrl' => $navUser?->avatarUrl(),
'initials' => $navInitials !== '' ? $navInitials : 'U',
])
@endunless
@if ($qrMobilePage)
<div class="qr-mobile-bottom-bleed lg:hidden" aria-hidden="true"></div>
@endif
@php
$navUser = auth()->user();
$navInitials = collect(explode(' ', trim((string) $navUser?->name)))
->filter()->take(2)
->map(fn ($part) => strtoupper(substr($part, 0, 1)))
->implode('');
@endphp
@include('partials.mobile-bottom-nav', [
'homeUrl' => route('link.dashboard'),
'homeActive' => request()->routeIs('link.dashboard'),
'searchUrl' => route('user.links.index'),
'searchActive' => request()->routeIs('user.links.*'),
'notificationsUrl' => route('notifications.index'),
'notificationsActive' => request()->routeIs('notifications.*'),
'unreadUrl' => route('notifications.unread'),
'profileActive' => request()->routeIs('account.settings'),
'profileName' => $navUser?->name ?? '',
'profileSubtitle' => $navUser?->email ?? '',
'profileMenuItems' => \App\Support\UserProfileMenu::items($navUser),
'avatarUrl' => $navUser?->avatarUrl(),
'initials' => $navInitials !== '' ? $navInitials : 'U',
])
</div>
<script>
document.addEventListener('alpine:init', () => {
+53
View File
@@ -0,0 +1,53 @@
<x-layouts.user :title="'Create Link'">
<div class="mx-auto max-w-xl space-y-6">
<div>
<h1 class="text-2xl font-semibold text-slate-900">Create short link</h1>
<p class="mt-1 text-sm text-slate-500">
Your link will be published on <span class="font-medium text-emerald-700">ladl.link</span>.
GHS {{ number_format($pricePerLink, 2) }} will be debited from your wallet.
</p>
</div>
@if($errors->has('balance'))
<div class="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
{{ $errors->first('balance') }}
</div>
@endif
<form method="POST" action="{{ route('user.links.store') }}" class="space-y-5 rounded-xl border border-slate-200 bg-white p-6">
@csrf
<div>
<label for="destination_url" class="block text-sm font-medium text-slate-700">Destination URL</label>
<input type="url" name="destination_url" id="destination_url" value="{{ old('destination_url') }}" required
placeholder="https://example.com/page"
class="mt-1 block w-full rounded-lg border-slate-300 shadow-sm focus:border-emerald-500 focus:ring-emerald-500">
@error('destination_url')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
<div>
<label for="label" class="block text-sm font-medium text-slate-700">Label (optional)</label>
<input type="text" name="label" id="label" value="{{ old('label') }}"
class="mt-1 block w-full rounded-lg border-slate-300 shadow-sm focus:border-emerald-500 focus:ring-emerald-500">
</div>
<div>
<label for="custom_slug" class="block text-sm font-medium text-slate-700">Custom slug (optional)</label>
<div class="mt-1 flex rounded-lg shadow-sm">
<span class="inline-flex items-center rounded-l-lg border border-r-0 border-slate-300 bg-slate-50 px-3 text-sm text-slate-500">ladl.link/</span>
<input type="text" name="custom_slug" id="custom_slug" value="{{ old('custom_slug') }}"
pattern="[a-z0-9][a-z0-9-]{1,18}[a-z0-9]"
class="block w-full rounded-r-lg border-slate-300 focus:border-emerald-500 focus:ring-emerald-500">
</div>
@error('custom_slug')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
<div class="flex justify-end gap-3">
<a href="{{ route('user.links.index') }}" class="rounded-lg px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-50">Cancel</a>
<button type="submit" class="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700">
Create link GHS {{ number_format($pricePerLink, 2) }}
</button>
</div>
</form>
</div>
</x-layouts.user>
+50
View File
@@ -0,0 +1,50 @@
<x-layouts.user :title="'Dashboard'">
<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>
<h1 class="text-2xl font-semibold text-slate-900">Overview</h1>
<p class="mt-1 text-sm text-slate-500">Short links on <span class="font-medium text-emerald-700">ladl.link</span> GHS {{ number_format($pricePerLink, 2) }} per link</p>
</div>
<a href="{{ route('user.links.create') }}"
class="inline-flex items-center justify-center rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700">
Create link
</a>
</div>
<div class="grid gap-4 sm:grid-cols-3">
<div class="rounded-xl border border-slate-200 bg-white p-5">
<p class="text-sm text-slate-500">Links created</p>
<p class="mt-1 text-3xl font-semibold text-slate-900">{{ number_format($wallet->links_total) }}</p>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-5">
<p class="text-sm text-slate-500">Total clicks</p>
<p class="mt-1 text-3xl font-semibold text-slate-900">{{ number_format($wallet->clicks_total) }}</p>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-5">
<p class="text-sm text-slate-500">Price per link</p>
<p class="mt-1 text-3xl font-semibold text-emerald-700">GHS {{ number_format($pricePerLink, 2) }}</p>
</div>
</div>
@if($recentLinks->isNotEmpty())
<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 links</h2>
</div>
<ul class="divide-y divide-slate-100">
@foreach($recentLinks as $link)
<li class="flex items-center justify-between gap-4 px-5 py-3">
<div class="min-w-0">
<a href="{{ route('user.links.show', $link) }}" class="font-medium text-slate-900 hover:text-emerald-700">
{{ $link->label ?: $link->slug }}
</a>
<p class="truncate text-sm text-emerald-600">{{ $link->publicUrl() }}</p>
</div>
<span class="shrink-0 text-sm text-slate-500">{{ number_format($link->clicks_total) }} clicks</span>
</li>
@endforeach
</ul>
</div>
@endif
</div>
</x-layouts.user>
+55
View File
@@ -0,0 +1,55 @@
<x-layouts.user :title="'My Links'">
<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>
<h1 class="text-2xl font-semibold text-slate-900">My Links</h1>
<p class="mt-1 text-sm text-slate-500">GHS {{ number_format($pricePerLink, 2) }} debited from your wallet per link</p>
</div>
<a href="{{ route('user.links.create') }}"
class="inline-flex items-center justify-center rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700">
Create link
</a>
</div>
<div class="overflow-hidden rounded-xl border border-slate-200 bg-white">
@if($links->isEmpty())
<div class="px-6 py-12 text-center">
<p class="text-slate-500">No links yet. Create your first short link on ladl.link.</p>
</div>
@else
<table class="min-w-full divide-y divide-slate-100">
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
<tr>
<th class="px-5 py-3">Link</th>
<th class="px-5 py-3">Destination</th>
<th class="px-5 py-3">Clicks</th>
<th class="px-5 py-3">Status</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 text-sm">
@foreach($links as $link)
<tr class="hover:bg-slate-50">
<td class="px-5 py-3">
<a href="{{ route('user.links.show', $link) }}" class="font-medium text-slate-900 hover:text-emerald-700">
{{ $link->label ?: $link->slug }}
</a>
<p class="text-emerald-600">{{ $link->publicUrl() }}</p>
</td>
<td class="max-w-xs truncate px-5 py-3 text-slate-500">{{ $link->destination_url }}</td>
<td class="px-5 py-3 text-slate-700">{{ number_format($link->clicks_total) }}</td>
<td class="px-5 py-3">
@if($link->is_active && ! $link->isExpired())
<span class="rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-medium text-emerald-700">Active</span>
@else
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600">Inactive</span>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
<div class="border-t border-slate-100 px-5 py-3">{{ $links->links() }}</div>
@endif
</div>
</div>
</x-layouts.user>
+58
View File
@@ -0,0 +1,58 @@
<x-layouts.user :title="$link->label ?: $link->slug">
<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>
<h1 class="text-2xl font-semibold text-slate-900">{{ $link->label ?: $link->slug }}</h1>
<a href="{{ $link->publicUrl() }}" target="_blank" rel="noopener"
class="mt-1 inline-flex items-center gap-1 text-emerald-600 hover:text-emerald-700">
{{ $link->publicUrl() }}
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" /></svg>
</a>
</div>
<form method="POST" action="{{ route('user.links.destroy', $link) }}" onsubmit="return confirm('Delete this link?')">
@csrf @method('DELETE')
<button type="submit" class="text-sm text-red-600 hover:text-red-700">Delete</button>
</form>
</div>
<div class="grid gap-4 sm:grid-cols-3">
<div class="rounded-xl border border-slate-200 bg-white p-4">
<p class="text-sm text-slate-500">Total clicks</p>
<p class="text-2xl font-semibold">{{ number_format($link->clicks_total) }}</p>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-4">
<p class="text-sm text-slate-500">Unique clicks</p>
<p class="text-2xl font-semibold">{{ number_format($link->unique_clicks_total) }}</p>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-4">
<p class="text-sm text-slate-500">Last clicked</p>
<p class="text-sm font-medium">{{ $link->last_clicked_at?->diffForHumans() ?? 'Never' }}</p>
</div>
</div>
<form method="POST" action="{{ route('user.links.update', $link) }}" class="space-y-4 rounded-xl border border-slate-200 bg-white p-6">
@csrf @method('PATCH')
<div>
<label for="destination_url" class="block text-sm font-medium text-slate-700">Destination URL</label>
<input type="url" name="destination_url" id="destination_url" value="{{ old('destination_url', $link->destination_url) }}" required
class="mt-1 block w-full rounded-lg border-slate-300 shadow-sm focus:border-emerald-500 focus:ring-emerald-500">
</div>
<div>
<label for="label" class="block text-sm font-medium text-slate-700">Label</label>
<input type="text" name="label" id="label" value="{{ old('label', $link->label) }}"
class="mt-1 block w-full rounded-lg border-slate-300 shadow-sm focus:border-emerald-500 focus:ring-emerald-500">
</div>
<label class="flex items-center gap-2 text-sm text-slate-700">
<input type="hidden" name="is_active" value="0">
<input type="checkbox" name="is_active" value="1" @checked(old('is_active', $link->is_active))
class="rounded border-slate-300 text-emerald-600 focus:ring-emerald-500">
Link is active
</label>
<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>
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Signed out Ladill Link</title>
@vite(['resources/css/app.css'])
</head>
<body class="flex min-h-screen items-center justify-center bg-slate-100 font-sans antialiased">
<div class="text-center">
<img src="{{ asset('images/logo/ladilllink-logo.svg') }}" alt="Ladill Link" class="mx-auto h-8">
<p class="mt-6 text-slate-600">You have been signed out.</p>
<a href="{{ route('sso.connect') }}" class="mt-4 inline-block text-sm font-medium text-emerald-600 hover:text-emerald-700">Sign in again</a>
</div>
</body>
</html>
+7 -19
View File
@@ -1,35 +1,23 @@
<div class="flex h-full flex-col bg-white border-r border-slate-200 text-slate-700">
<div class="flex h-16 shrink-0 items-center border-b border-slate-200 px-6">
<a href="{{ route('qr.dashboard') }}" class="flex items-center">
<img src="{{ asset('images/logo/ladillqrplus-logo.svg') }}?v={{ @filemtime(public_path('images/logo/ladillqrplus-logo.svg')) ?: '1' }}" alt="Ladill QR Plus" class="h-6 w-auto">
<a href="{{ route('link.dashboard') }}" class="flex items-center">
<img src="{{ asset('images/logo/ladilllink-logo.svg') }}?v={{ @filemtime(public_path('images/logo/ladilllink-logo.svg')) ?: '1' }}" alt="Ladill Link" class="h-6 w-auto">
</a>
</div>
@php
$main = [
['name' => 'Overview', 'route' => route('qr.dashboard'), 'active' => request()->routeIs('qr.dashboard'),
['name' => 'Overview', 'route' => route('link.dashboard'), 'active' => request()->routeIs('link.dashboard'),
'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 Codes', 'route' => route('user.qr-codes.index'), 'active' => request()->routeIs('user.qr-codes.*'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0 1 3.75 9.375v-4.5ZM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 0 1-1.125-1.125v-4.5ZM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0 1 13.5 9.375v-4.5Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 6.75h.75v.75h-.75v-.75ZM6.75 16.5h.75v.75h-.75v-.75ZM16.5 6.75h.75v.75h-.75v-.75ZM13.5 13.5h.75v.75h-.75v-.75ZM16.5 16.5h.75v.75h-.75v-.75ZM13.5 19.5h.75v.75h-.75v-.75ZM19.5 13.5h.75v.75h-.75v-.75ZM19.5 19.5h.75v.75h-.75v-.75ZM22.5 18.75a2.25 2.25 0 0 0-2.25-2.25h-1.5a2.25 2.25 0 0 0-2.25 2.25v1.5a2.25 2.25 0 0 0 2.25 2.25h1.5a2.25 2.25 0 0 0 2.25-2.25v-1.5Z" />'],
['name' => 'Analytics', 'route' => route('qr.analytics.index'), 'active' => request()->routeIs('qr.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' => '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" />'],
];
@endphp
<nav class="flex-1 space-y-0.5 overflow-y-auto px-3 py-4">
@foreach($main as $item)
<a href="{{ $item['route'] }}" class="group flex items-center gap-3 rounded-lg px-3 py-2 text-[13px] transition {{ $item['active'] ? 'bg-indigo-50 text-indigo-700 font-semibold' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900' }}">
<svg class="h-[18px] w-[18px] shrink-0 {{ $item['active'] ? 'text-indigo-600' : 'text-slate-400' }}" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">{!! $item['icon'] !!}</svg>
<a href="{{ $item['route'] }}" class="group flex items-center gap-3 rounded-lg px-3 py-2 text-[13px] transition {{ $item['active'] ? '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 {{ $item['active'] ? 'text-emerald-600' : 'text-slate-400' }}" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">{!! $item['icon'] !!}</svg>
<span>{{ $item['name'] }}</span>
</a>
@endforeach
</nav>
<div class="shrink-0 border-t border-slate-100 px-3 py-3">
<a href="{{ route('account.settings') }}"
class="group flex items-center gap-3 rounded-lg px-3 py-2 text-[13px] transition {{ request()->routeIs('account.settings') ? 'bg-indigo-50 text-indigo-700 font-semibold' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900' }}">
<svg class="h-[18px] w-[18px] shrink-0 {{ request()->routeIs('account.settings') ? 'text-indigo-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>
+1 -11
View File
@@ -1,15 +1,5 @@
<?php
use App\Http\Controllers\Api\MeController;
use App\Http\Controllers\Api\QrCodeController;
use Illuminate\Support\Facades\Route;
Route::middleware(['auth:sanctum', \App\Http\Middleware\SetActingAccount::class])->prefix('v1')->group(function () {
Route::get('/me', MeController::class);
Route::get('/qr-codes', [QrCodeController::class, 'index']);
Route::post('/qr-codes', [QrCodeController::class, 'store']);
Route::get('/qr-codes/{qrCode}', [QrCodeController::class, 'show'])->whereNumber('qrCode');
Route::patch('/qr-codes/{qrCode}', [QrCodeController::class, 'update'])->whereNumber('qrCode');
Route::get('/qr-codes/{qrCode}/analytics', [QrCodeController::class, 'analytics'])->whereNumber('qrCode');
});
// Ladill Link has no public API yet — management is web-only.
+20 -43
View File
@@ -1,23 +1,17 @@
<?php
use App\Http\Controllers\Auth\SsoLoginController;
use App\Http\Controllers\Link\LinkController;
use App\Http\Controllers\Link\OverviewController;
use App\Http\Controllers\NotificationController;
use App\Http\Controllers\Public\LinkRedirectController;
use App\Http\Controllers\WalletBalanceController;
use App\Http\Controllers\WalletTopupController;
use App\Http\Controllers\NotificationController;
use App\Http\Controllers\Public\QrScanController;
use App\Http\Controllers\Qr\AccountController;
use App\Http\Controllers\Qr\AfiaController;
use App\Http\Controllers\Qr\AnalyticsController;
use App\Http\Controllers\Qr\DeveloperController;
use App\Http\Controllers\Qr\OverviewController;
use App\Http\Controllers\Qr\TeamController;
use App\Http\Controllers\Qr\QrCodeController;
use App\Http\Controllers\SearchController;
use Illuminate\Support\Facades\Route;
Route::get('/', fn () => auth()->check()
? redirect()->route('qr.dashboard')
: redirect()->route('sso.connect'))->name('qr.root');
? redirect()->route('link.dashboard')
: redirect()->route('sso.connect'))->name('link.root');
Route::get('/login', [SsoLoginController::class, 'connect'])->name('login');
Route::get('/sso/connect', [SsoLoginController::class, 'connect'])->name('sso.connect');
@@ -26,14 +20,7 @@ Route::post('/logout', [SsoLoginController::class, 'logout'])->name('logout');
Route::get('/sso/logout-bridge', [SsoLoginController::class, 'logoutBridge'])->name('sso.logout-bridge');
Route::get('/sso/logout-frontchannel', [SsoLoginController::class, 'frontchannelLogout'])->name('sso.logout-frontchannel');
Route::get('/sso/platform-signed-out', [SsoLoginController::class, 'platformSignedOut'])->name('sso.platform-signed-out');
Route::get('/signed-out', fn () => auth()->check() ? redirect()->route('qr.dashboard') : view('qr.signed-out'))->name('qr.signed-out');
Route::get('/q/{shortCode}', [QrScanController::class, 'resolve'])->name('qr.public.resolve');
Route::get('/q/{shortCode}/view', [QrScanController::class, 'view'])->name('qr.public.view');
Route::get('/q/{shortCode}/file', [QrScanController::class, 'file'])->name('qr.public.file');
Route::get('/q/{shortCode}/business-logo', [QrScanController::class, 'businessLogo'])->name('qr.public.business.logo');
Route::get('/q/{shortCode}/business-cover', [QrScanController::class, 'businessCover'])->name('qr.public.business.cover');
Route::get('/q/{shortCode}/app-icon', [QrScanController::class, 'appIcon'])->name('qr.public.app.icon');
Route::get('/signed-out', fn () => auth()->check() ? redirect()->route('link.dashboard') : view('links.signed-out'))->name('link.signed-out');
Route::middleware(['auth', 'platform.session'])->group(function () {
Route::get('/wallet/balance', [WalletBalanceController::class, 'balance'])->name('wallet.balance');
@@ -43,30 +30,20 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
Route::post('/notifications/{id}/read', [NotificationController::class, 'markAsRead'])->name('notifications.mark-read');
Route::post('/notifications/mark-all-read', [NotificationController::class, 'markAllAsRead'])->name('notifications.mark-all-read');
Route::get('/dashboard', [OverviewController::class, 'index'])->name('qr.dashboard');
Route::get('/analytics', [AnalyticsController::class, 'index'])->name('qr.analytics.index');
Route::post('/afia/chat', [AfiaController::class, 'chat'])->name('qr.afia.chat');
Route::get('/search', SearchController::class)->name('qr.search');
Route::get('/qr-codes', [QrCodeController::class, 'index'])->name('user.qr-codes.index');
Route::get('/qr-codes/create', [QrCodeController::class, 'create'])->name('user.qr-codes.create');
Route::post('/qr-codes', [QrCodeController::class, 'store'])->name('user.qr-codes.store');
Route::get('/qr-codes/check-slug', [QrCodeController::class, 'checkSlug'])->name('user.qr-codes.check-slug');
Route::post('/qr-codes/style-preview', [QrCodeController::class, 'stylePreview'])->name('user.qr-codes.style-preview');
Route::get('/qr-codes/{qrCode}', [QrCodeController::class, 'show'])->name('user.qr-codes.show');
Route::patch('/qr-codes/{qrCode}', [QrCodeController::class, 'update'])->name('user.qr-codes.update');
Route::delete('/qr-codes/{qrCode}', [QrCodeController::class, 'destroy'])->name('user.qr-codes.destroy');
Route::post('/qr-codes/{qrCode}/canonical-image', [QrCodeController::class, 'storeCanonicalImage'])->name('user.qr-codes.canonical-image');
Route::get('/qr-codes/{qrCode}/preview.png', [QrCodeController::class, 'preview'])->name('user.qr-codes.preview');
Route::get('/qr-codes/{qrCode}/download/{format}', [QrCodeController::class, 'download'])->name('user.qr-codes.download')->whereIn('format', ['png', 'svg', 'pdf']);
Route::get('/dashboard', [OverviewController::class, 'index'])->name('link.dashboard');
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');
Route::get('/links/check-slug', [LinkController::class, 'checkSlug'])->name('user.links.check-slug');
Route::get('/links/{link}', [LinkController::class, 'show'])->name('user.links.show');
Route::patch('/links/{link}', [LinkController::class, 'update'])->name('user.links.update');
Route::delete('/links/{link}', [LinkController::class, 'destroy'])->name('user.links.destroy');
Route::get('/wallet', fn () => redirect()->away(ladill_account_url('/wallet')))->name('account.wallet');
Route::get('/billing', fn () => redirect()->away(ladill_account_url('/billing')))->name('account.billing');
Route::get('/settings', [AccountController::class, 'settings'])->name('account.settings');
Route::put('/settings', [AccountController::class, 'updateSettings'])->name('account.settings.update');
Route::get('/team', fn () => redirect()->away(ladill_account_url('/account/team')))->name('account.team');
Route::post('/switch-account', [TeamController::class, 'switchAccount'])->name('account.switch');
Route::get('/developers', fn () => redirect()->away(ladill_account_url('/account/developers')))->name('account.developers');
});
// Public short-link resolution — must be last so app routes are not shadowed.
Route::get('/{slug}', [LinkRedirectController::class, 'resolve'])
->where('slug', '[a-z0-9][a-z0-9-]{1,30}[a-z0-9]')
->name('link.public.resolve');