From d9c91ad7d852e003f460e89f50ab710c71976235 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Sat, 27 Jun 2026 10:58:41 +0000 Subject: [PATCH] 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 --- .env.example | 20 ++- DEPLOY.md | 86 ++++--------- .../Controllers/Auth/SsoLoginController.php | 8 +- app/Http/Controllers/Link/LinkController.php | 118 ++++++++++++++++++ .../Controllers/Link/OverviewController.php | 28 +++++ .../Public/LinkRedirectController.php | 31 +++++ app/Models/LinkClick.php | 29 +++++ app/Models/LinkTransaction.php | 45 +++++++ app/Models/LinkWallet.php | 39 ++++++ app/Models/ShortLink.php | 57 +++++++++ app/Models/User.php | 45 ++----- app/Providers/AppServiceProvider.php | 7 +- app/Services/Link/LinkBillingService.php | 71 +++++++++++ app/Services/Link/LinkClickRecorder.php | 53 ++++++++ app/Services/Link/LinkManagerService.php | 111 ++++++++++++++++ composer.json | 4 +- config/app.php | 5 +- config/billing.php | 4 +- config/ladill.php | 4 +- config/link.php | 8 ++ .../2026_06_27_000001_create_link_tables.php | 73 +++++++++++ resources/views/layouts/user.blade.php | 70 ++++------- resources/views/links/create.blade.php | 53 ++++++++ resources/views/links/dashboard.blade.php | 50 ++++++++ resources/views/links/index.blade.php | 55 ++++++++ resources/views/links/show.blade.php | 58 +++++++++ resources/views/links/signed-out.blade.php | 16 +++ resources/views/partials/sidebar.blade.php | 26 ++-- routes/api.php | 12 +- routes/web.php | 63 +++------- 30 files changed, 1002 insertions(+), 247 deletions(-) create mode 100644 app/Http/Controllers/Link/LinkController.php create mode 100644 app/Http/Controllers/Link/OverviewController.php create mode 100644 app/Http/Controllers/Public/LinkRedirectController.php create mode 100644 app/Models/LinkClick.php create mode 100644 app/Models/LinkTransaction.php create mode 100644 app/Models/LinkWallet.php create mode 100644 app/Models/ShortLink.php create mode 100644 app/Services/Link/LinkBillingService.php create mode 100644 app/Services/Link/LinkClickRecorder.php create mode 100644 app/Services/Link/LinkManagerService.php create mode 100644 config/link.php create mode 100644 database/migrations/2026_06_27_000001_create_link_tables.php create mode 100644 resources/views/links/create.blade.php create mode 100644 resources/views/links/dashboard.blade.php create mode 100644 resources/views/links/index.blade.php create mode 100644 resources/views/links/show.blade.php create mode 100644 resources/views/links/signed-out.blade.php diff --git a/.env.example b/.env.example index 7899371..8af40f5 100644 --- a/.env.example +++ b/.env.example @@ -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}" diff --git a/DEPLOY.md b/DEPLOY.md index 7a56b8c..ebdada9 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -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/` (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 '';" -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= -IDENTITY_API_KEY_QR= -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/` resolves (after nginx proxy) -- `account.ladill.com/qr-codes` redirects here +Database name: `ladill_link`. diff --git a/app/Http/Controllers/Auth/SsoLoginController.php b/app/Http/Controllers/Auth/SsoLoginController.php index 9e064b8..65b4d11 100644 --- a/app/Http/Controllers/Auth/SsoLoginController.php +++ b/app/Http/Controllers/Auth/SsoLoginController.php @@ -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 diff --git a/app/Http/Controllers/Link/LinkController.php b/app/Http/Controllers/Link/LinkController.php new file mode 100644 index 0000000..53a8229 --- /dev/null +++ b/app/Http/Controllers/Link/LinkController.php @@ -0,0 +1,118 @@ +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); + } + } +} diff --git a/app/Http/Controllers/Link/OverviewController.php b/app/Http/Controllers/Link/OverviewController.php new file mode 100644 index 0000000..438fe77 --- /dev/null +++ b/app/Http/Controllers/Link/OverviewController.php @@ -0,0 +1,28 @@ +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(), + ]); + } +} diff --git a/app/Http/Controllers/Public/LinkRedirectController.php b/app/Http/Controllers/Public/LinkRedirectController.php new file mode 100644 index 0000000..9f13e85 --- /dev/null +++ b/app/Http/Controllers/Public/LinkRedirectController.php @@ -0,0 +1,31 @@ +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); + } +} diff --git a/app/Models/LinkClick.php b/app/Models/LinkClick.php new file mode 100644 index 0000000..5677483 --- /dev/null +++ b/app/Models/LinkClick.php @@ -0,0 +1,29 @@ + 'boolean', + 'clicked_at' => 'datetime', + ]; + + public function shortLink(): BelongsTo + { + return $this->belongsTo(ShortLink::class); + } +} diff --git a/app/Models/LinkTransaction.php b/app/Models/LinkTransaction.php new file mode 100644 index 0000000..0d552d7 --- /dev/null +++ b/app/Models/LinkTransaction.php @@ -0,0 +1,45 @@ + '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); + } +} diff --git a/app/Models/LinkWallet.php b/app/Models/LinkWallet.php new file mode 100644 index 0000000..668e1f8 --- /dev/null +++ b/app/Models/LinkWallet.php @@ -0,0 +1,39 @@ + '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); + } +} diff --git a/app/Models/ShortLink.php b/app/Models/ShortLink.php new file mode 100644 index 0000000..563c032 --- /dev/null +++ b/app/Models/ShortLink.php @@ -0,0 +1,57 @@ + '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(); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 23dae3b..015e40d 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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 */ - 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], ); } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index ac11018..c42cb6a 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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()); }); - } } diff --git a/app/Services/Link/LinkBillingService.php b/app/Services/Link/LinkBillingService.php new file mode 100644 index 0000000..b458035 --- /dev/null +++ b/app/Services/Link/LinkBillingService.php @@ -0,0 +1,71 @@ +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], + ]); + }); + } +} diff --git a/app/Services/Link/LinkClickRecorder.php b/app/Services/Link/LinkClickRecorder.php new file mode 100644 index 0000000..ca8ca9c --- /dev/null +++ b/app/Services/Link/LinkClickRecorder.php @@ -0,0 +1,53 @@ +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; + } +} diff --git a/app/Services/Link/LinkManagerService.php b/app/Services/Link/LinkManagerService.php new file mode 100644 index 0000000..2d7a6cc --- /dev/null +++ b/app/Services/Link/LinkManagerService.php @@ -0,0 +1,111 @@ +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; + } +} diff --git a/composer.json b/composer.json index 0bbdef9..f3c1605 100644 --- a/composer.json +++ b/composer.json @@ -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" diff --git a/config/app.php b/config/app.php index c7ae22a..be6a259 100644 --- a/config/app.php +++ b/config/app.php @@ -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')), diff --git a/config/billing.php b/config/billing.php index 8e4c100..7fea527 100644 --- a/config/billing.php +++ b/config/billing.php @@ -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', ]; diff --git a/config/ladill.php b/config/ladill.php index fae3ab5..2a002d3 100644 --- a/config/ladill.php +++ b/config/ladill.php @@ -1,6 +1,6 @@ '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'), ]; diff --git a/config/link.php b/config/link.php new file mode 100644 index 0000000..d9cf3a2 --- /dev/null +++ b/config/link.php @@ -0,0 +1,8 @@ + (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'), +]; diff --git a/database/migrations/2026_06_27_000001_create_link_tables.php b/database/migrations/2026_06_27_000001_create_link_tables.php new file mode 100644 index 0000000..c19cb77 --- /dev/null +++ b/database/migrations/2026_06_27_000001_create_link_tables.php @@ -0,0 +1,73 @@ +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'); + } +}; diff --git a/resources/views/layouts/user.blade.php b/resources/views/layouts/user.blade.php index 27bc584..a664aab 100644 --- a/resources/views/layouts/user.blade.php +++ b/resources/views/layouts/user.blade.php @@ -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 - $qrMobilePage])> + @include('partials.favicon') - {{ $title ?? 'Dashboard' }} - Ladill + {{ $title ?? 'Dashboard' }} - Ladill Link @vite(['resources/css/app.css', 'resources/js/app.js']) - +
{{-- Mobile sidebar overlay --}}
@@ -44,45 +39,34 @@ @include('partials.flash')
-
$qrMobilePage, - 'p-0 pb-24 lg:p-6 lg:pb-6' => $mobileFullScreenPage && ! $qrMobilePage, - 'p-6 pb-24 lg:pb-6' => ! $mobileFullScreenPage, - ])> +
{{ $slot }}
{{-- 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) - - @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', + ])