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:
+8
-12
@@ -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}"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 3–20 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
@@ -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
@@ -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
@@ -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
@@ -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'),
|
||||
];
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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,18 +39,12 @@
|
||||
@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)))
|
||||
@@ -64,10 +53,10 @@
|
||||
->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'),
|
||||
'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'),
|
||||
@@ -78,11 +67,6 @@
|
||||
'avatarUrl' => $navUser?->avatarUrl(),
|
||||
'initials' => $navInitials !== '' ? $navInitials : 'U',
|
||||
])
|
||||
@endunless
|
||||
|
||||
@if ($qrMobilePage)
|
||||
<div class="qr-mobile-bottom-bleed lg:hidden" aria-hidden="true"></div>
|
||||
@endif
|
||||
</div>
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user