From 0e5e0f6ca52b4bce48a357962cea6871ebd23428 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Sat, 27 Jun 2026 19:32:40 +0000 Subject: [PATCH] Add device, platform, and country analytics to link show page. Record parsed user-agent and geo data on clicks, then surface breakdowns and richer recent-click context on per-link and account analytics views. Co-authored-by: Cursor --- .../Controllers/Link/AnalyticsController.php | 4 + app/Http/Controllers/Link/LinkController.php | 11 +- app/Models/LinkClick.php | 19 +++ app/Services/Link/LinkAnalyticsService.php | 121 +++++++++++++++++- app/Services/Link/LinkClickRecorder.php | 10 +- app/Support/Link/LinkGeoResolver.php | 40 ++++++ app/Support/Link/LinkUserAgentParser.php | 43 +++++++ ...20000_add_device_fields_to_link_clicks.php | 24 ++++ .../views/links/analytics/index.blade.php | 23 ++-- .../partials/analytics-breakdown.blade.php | 15 +++ resources/views/links/show.blade.php | 79 ++++++++++-- tests/Feature/LinkAnalyticsTest.php | 96 ++++++++++++++ 12 files changed, 453 insertions(+), 32 deletions(-) create mode 100644 app/Support/Link/LinkGeoResolver.php create mode 100644 app/Support/Link/LinkUserAgentParser.php create mode 100644 database/migrations/2026_06_27_220000_add_device_fields_to_link_clicks.php create mode 100644 resources/views/links/partials/analytics-breakdown.blade.php create mode 100644 tests/Feature/LinkAnalyticsTest.php diff --git a/app/Http/Controllers/Link/AnalyticsController.php b/app/Http/Controllers/Link/AnalyticsController.php index bc738b4..f4b5c87 100644 --- a/app/Http/Controllers/Link/AnalyticsController.php +++ b/app/Http/Controllers/Link/AnalyticsController.php @@ -21,6 +21,10 @@ class AnalyticsController extends Controller 'topLinks' => $this->analytics->topLinksForAccount($account), 'recentClicks' => $this->analytics->recentClicksForAccount($account), 'referrers' => $this->analytics->referrersForAccount($account), + 'devices' => $this->analytics->breakdownForAccount($account, 'device_type'), + 'browsers' => $this->analytics->breakdownForAccount($account, 'browser'), + 'platforms' => $this->analytics->breakdownForAccount($account, 'os'), + 'countries' => $this->analytics->breakdownForAccount($account, 'country'), ]); } } diff --git a/app/Http/Controllers/Link/LinkController.php b/app/Http/Controllers/Link/LinkController.php index b4b00d6..dceb733 100644 --- a/app/Http/Controllers/Link/LinkController.php +++ b/app/Http/Controllers/Link/LinkController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Link; use App\Http\Controllers\Controller; use App\Models\ShortLink; +use App\Services\Link\LinkAnalyticsService; use App\Services\Link\LinkBillingService; use App\Services\Link\LinkCatalogSyncService; use App\Services\Link\LinkManagerService; @@ -18,6 +19,7 @@ class LinkController extends Controller private LinkManagerService $links, private LinkBillingService $billing, private LinkCatalogSyncService $catalogSync, + private LinkAnalyticsService $analytics, ) {} public function index(Request $request): View @@ -73,7 +75,14 @@ class LinkController extends Controller return view('links.show', [ 'link' => $link, - 'recentClicks' => $link->clicks()->latest('clicked_at')->limit(20)->get(), + 'summary' => $this->analytics->summaryForLink($link), + 'dailyClicks' => $this->analytics->dailyClicksForLink($link, 30), + 'devices' => $this->analytics->breakdownForLink($link, 'device_type'), + 'browsers' => $this->analytics->breakdownForLink($link, 'browser'), + 'platforms' => $this->analytics->breakdownForLink($link, 'os'), + 'countries' => $this->analytics->breakdownForLink($link, 'country'), + 'referrers' => $this->analytics->referrersForLink($link), + 'recentClicks' => $this->analytics->recentClicksForLink($link), ]); } diff --git a/app/Models/LinkClick.php b/app/Models/LinkClick.php index 5677483..e0be47a 100644 --- a/app/Models/LinkClick.php +++ b/app/Models/LinkClick.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Support\Link\LinkGeoResolver; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -12,6 +13,9 @@ class LinkClick extends Model 'ip_hash', 'user_agent', 'referer', + 'device_type', + 'browser', + 'os', 'country', 'is_unique', 'clicked_at', @@ -26,4 +30,19 @@ class LinkClick extends Model { return $this->belongsTo(ShortLink::class); } + + public function contextLabel(): string + { + $parts = array_filter([ + $this->device_type ? ucfirst($this->device_type) : null, + $this->browser, + $this->os, + $this->country ? LinkGeoResolver::countryLabel($this->country) : null, + ]); + + $context = $parts !== [] ? implode(' · ', $parts) : null; + $referer = $this->referer ? parse_url($this->referer, PHP_URL_HOST) ?: $this->referer : 'Direct'; + + return $context ? "{$context} · via {$referer}" : "via {$referer}"; + } } diff --git a/app/Services/Link/LinkAnalyticsService.php b/app/Services/Link/LinkAnalyticsService.php index d9fc03e..2577f39 100644 --- a/app/Services/Link/LinkAnalyticsService.php +++ b/app/Services/Link/LinkAnalyticsService.php @@ -5,11 +5,15 @@ namespace App\Services\Link; use App\Models\LinkClick; use App\Models\ShortLink; use App\Models\User; +use App\Support\Link\LinkGeoResolver; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; class LinkAnalyticsService { + /** @var list */ + private const BREAKDOWN_COLUMNS = ['device_type', 'browser', 'os', 'country']; + /** @return array{total_clicks:int,unique_clicks:int,clicks_7d:int,clicks_30d:int,links_total:int} */ public function summaryForAccount(User $account): array { @@ -25,12 +29,43 @@ class LinkAnalyticsService ]; } + /** @return array{total_clicks:int,unique_clicks:int,clicks_7d:int,clicks_30d:int,last_clicked_at:\Illuminate\Support\Carbon|null} */ + public function summaryForLink(ShortLink $link): array + { + $clicks = LinkClick::query()->where('short_link_id', $link->id); + + return [ + 'total_clicks' => (int) $link->clicks_total, + 'unique_clicks' => (int) $link->unique_clicks_total, + 'clicks_7d' => (int) (clone $clicks)->where('clicked_at', '>=', now()->subDays(7))->count(), + 'clicks_30d' => (int) (clone $clicks)->where('clicked_at', '>=', now()->subDays(30))->count(), + 'last_clicked_at' => $link->last_clicked_at, + ]; + } + /** @return Collection */ public function dailyClicksForAccount(User $account, int $days = 30): Collection { - $rows = LinkClick::query() + return $this->dailyClicks( + LinkClick::query()->whereIn('short_link_id', ShortLink::query()->where('user_id', $account->id)->select('id')), + $days, + ); + } + + /** @return Collection */ + public function dailyClicksForLink(ShortLink $link, int $days = 30): Collection + { + return $this->dailyClicks( + LinkClick::query()->where('short_link_id', $link->id), + $days, + ); + } + + /** @return Collection */ + private function dailyClicks($query, int $days): Collection + { + $rows = (clone $query) ->selectRaw('DATE(clicked_at) as date, COUNT(*) as total') - ->whereIn('short_link_id', ShortLink::query()->where('user_id', $account->id)->select('id')) ->where('clicked_at', '>=', now()->subDays($days - 1)->startOfDay()) ->groupBy('date') ->orderBy('date') @@ -47,6 +82,48 @@ class LinkAnalyticsService }); } + /** @return array */ + public function breakdownForAccount(User $account, string $column, int $limit = 8): array + { + return $this->breakdown( + LinkClick::query()->whereIn('short_link_id', ShortLink::query()->where('user_id', $account->id)->select('id')), + $column, + $limit, + ); + } + + /** @return array */ + public function breakdownForLink(ShortLink $link, string $column, int $limit = 8): array + { + return $this->breakdown( + LinkClick::query()->where('short_link_id', $link->id), + $column, + $limit, + ); + } + + /** @return array */ + private function breakdown($query, string $column, int $limit): array + { + if (! in_array($column, self::BREAKDOWN_COLUMNS, true)) { + throw new \InvalidArgumentException("Unsupported breakdown column: {$column}"); + } + + return (clone $query) + ->select($column, DB::raw('COUNT(*) as total')) + ->whereNotNull($column) + ->where($column, '!=', '') + ->groupBy($column) + ->orderByDesc('total') + ->limit($limit) + ->get() + ->map(fn ($row) => [ + 'label' => $this->breakdownLabel($column, (string) $row->{$column}), + 'total' => (int) $row->total, + ]) + ->all(); + } + /** @return Collection */ public function topLinksForAccount(User $account, int $limit = 8): Collection { @@ -74,12 +151,39 @@ class LinkAnalyticsService ->get(); } + /** @return Collection */ + public function recentClicksForLink(ShortLink $link, int $limit = 20): Collection + { + return LinkClick::query() + ->where('short_link_id', $link->id) + ->latest('clicked_at') + ->limit($limit) + ->get(); + } + /** @return Collection */ public function referrersForAccount(User $account, int $limit = 8): Collection { - return LinkClick::query() + return $this->referrers( + LinkClick::query()->whereIn('short_link_id', ShortLink::query()->where('user_id', $account->id)->select('id')), + $limit, + ); + } + + /** @return Collection */ + public function referrersForLink(ShortLink $link, int $limit = 8): Collection + { + return $this->referrers( + LinkClick::query()->where('short_link_id', $link->id), + $limit, + ); + } + + /** @return Collection */ + private function referrers($query, int $limit): Collection + { + return (clone $query) ->select('referer', DB::raw('COUNT(*) as total')) - ->whereIn('short_link_id', ShortLink::query()->where('user_id', $account->id)->select('id')) ->whereNotNull('referer') ->where('referer', '!=', '') ->groupBy('referer') @@ -92,6 +196,15 @@ class LinkAnalyticsService ]); } + private function breakdownLabel(string $column, string $value): string + { + return match ($column) { + 'device_type' => ucfirst($value), + 'country' => LinkGeoResolver::countryLabel($value), + default => $value, + }; + } + private function refererLabel(string $referer): string { $host = parse_url($referer, PHP_URL_HOST); diff --git a/app/Services/Link/LinkClickRecorder.php b/app/Services/Link/LinkClickRecorder.php index ca8ca9c..8765be0 100644 --- a/app/Services/Link/LinkClickRecorder.php +++ b/app/Services/Link/LinkClickRecorder.php @@ -5,6 +5,8 @@ namespace App\Services\Link; use App\Models\LinkClick; use App\Models\LinkWallet; use App\Models\ShortLink; +use App\Support\Link\LinkGeoResolver; +use App\Support\Link\LinkUserAgentParser; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -20,12 +22,18 @@ class LinkClickRecorder ->where('clicked_at', '>=', now()->subHours($windowHours)) ->exists(); - DB::transaction(function () use ($link, $request, $ipHash, $isUnique) { + $parsed = LinkUserAgentParser::parse((string) $request->userAgent()); + + DB::transaction(function () use ($link, $request, $ipHash, $isUnique, $parsed) { LinkClick::create([ 'short_link_id' => $link->id, 'ip_hash' => $ipHash, 'user_agent' => $this->truncate($request->userAgent(), 512), 'referer' => $this->truncate($request->header('referer'), 512), + 'device_type' => $parsed['device_type'], + 'browser' => $parsed['browser'], + 'os' => $parsed['os'], + 'country' => LinkGeoResolver::countryCode($request), 'is_unique' => $isUnique, 'clicked_at' => now(), ]); diff --git a/app/Support/Link/LinkGeoResolver.php b/app/Support/Link/LinkGeoResolver.php new file mode 100644 index 0000000..1944758 --- /dev/null +++ b/app/Support/Link/LinkGeoResolver.php @@ -0,0 +1,40 @@ +header($header); + if (! is_string($value) || strlen($value) !== 2) { + continue; + } + + $code = strtoupper($value); + if (! in_array($code, ['XX', 'T1'], true)) { + return $code; + } + } + + return null; + } + + public static function countryLabel(?string $code): string + { + if ($code === null || $code === '') { + return 'Unknown'; + } + + if (function_exists('locale_get_display_region')) { + $name = locale_get_display_region('_'.$code, 'en'); + + return is_string($name) && $name !== '' ? $name : $code; + } + + return $code; + } +} diff --git a/app/Support/Link/LinkUserAgentParser.php b/app/Support/Link/LinkUserAgentParser.php new file mode 100644 index 0000000..53a5884 --- /dev/null +++ b/app/Support/Link/LinkUserAgentParser.php @@ -0,0 +1,43 @@ + 'Edge', + str_contains($uaLower, 'chrome/') && ! str_contains($uaLower, 'edg/') => 'Chrome', + str_contains($uaLower, 'safari/') && ! str_contains($uaLower, 'chrome/') => 'Safari', + str_contains($uaLower, 'firefox/') => 'Firefox', + str_contains($uaLower, 'instagram') => 'Instagram', + str_contains($uaLower, 'whatsapp') => 'WhatsApp', + default => 'Other', + }; + + $os = match (true) { + str_contains($uaLower, 'iphone') || str_contains($uaLower, 'ipad') => 'iOS', + str_contains($uaLower, 'android') => 'Android', + str_contains($uaLower, 'windows') => 'Windows', + str_contains($uaLower, 'mac os') || str_contains($uaLower, 'macintosh') => 'macOS', + str_contains($uaLower, 'linux') => 'Linux', + default => 'Other', + }; + + return [ + 'device_type' => $device, + 'browser' => $browser, + 'os' => $os, + ]; + } +} diff --git a/database/migrations/2026_06_27_220000_add_device_fields_to_link_clicks.php b/database/migrations/2026_06_27_220000_add_device_fields_to_link_clicks.php new file mode 100644 index 0000000..74799eb --- /dev/null +++ b/database/migrations/2026_06_27_220000_add_device_fields_to_link_clicks.php @@ -0,0 +1,24 @@ +string('device_type', 16)->nullable()->after('referer'); + $table->string('browser', 32)->nullable()->after('device_type'); + $table->string('os', 32)->nullable()->after('browser'); + }); + } + + public function down(): void + { + Schema::table('link_clicks', function (Blueprint $table) { + $table->dropColumn(['device_type', 'browser', 'os']); + }); + } +}; diff --git a/resources/views/links/analytics/index.blade.php b/resources/views/links/analytics/index.blade.php index 105c32f..3a9de86 100644 --- a/resources/views/links/analytics/index.blade.php +++ b/resources/views/links/analytics/index.blade.php @@ -56,19 +56,14 @@ @endforelse -
-

Top referrers

-
    - @forelse($referrers as $row) -
  • - {{ $row['label'] }} - {{ number_format($row['total']) }} -
  • - @empty -
  • No referrer data yet
  • - @endforelse -
-
+ @include('links.partials.analytics-breakdown', ['title' => 'Top referrers', 'rows' => $referrers, 'empty' => 'No referrer data yet']) + + +
+ @include('links.partials.analytics-breakdown', ['title' => 'Devices', 'rows' => $devices, 'empty' => 'No clicks yet']) + @include('links.partials.analytics-breakdown', ['title' => 'Browsers', 'rows' => $browsers, 'empty' => 'No clicks yet']) + @include('links.partials.analytics-breakdown', ['title' => 'Platforms', 'rows' => $platforms, 'empty' => 'No clicks yet']) + @include('links.partials.analytics-breakdown', ['title' => 'Countries', 'rows' => $countries, 'empty' => 'No location data yet'])
@@ -80,7 +75,7 @@
  • {{ $click->shortLink?->label ?: $click->shortLink?->slug }}

    -

    {{ $click->referer ?: 'Direct / unknown' }}

    +

    {{ $click->contextLabel() }}

  • diff --git a/resources/views/links/partials/analytics-breakdown.blade.php b/resources/views/links/partials/analytics-breakdown.blade.php new file mode 100644 index 0000000..ee72119 --- /dev/null +++ b/resources/views/links/partials/analytics-breakdown.blade.php @@ -0,0 +1,15 @@ +@props(['title', 'rows', 'empty' => 'No data yet']) + +
    +

    {{ $title }}

    +
      + @forelse($rows as $row) +
    • + {{ $row['label'] }} + {{ number_format($row['total']) }} +
    • + @empty +
    • {{ $empty }}
    • + @endforelse +
    +
    diff --git a/resources/views/links/show.blade.php b/resources/views/links/show.blade.php index d21052a..987696c 100644 --- a/resources/views/links/show.blade.php +++ b/resources/views/links/show.blade.php @@ -1,6 +1,6 @@ {{ $link->label ?: $link->slug }} -
    +
    @@ -32,18 +32,71 @@
    @endif -
    -
    -

    Total clicks

    -

    {{ number_format($link->clicks_total) }}

    +
    +

    Analytics

    + +
    +
    +

    {{ number_format($summary['total_clicks']) }}

    +

    Total clicks

    +
    +
    +

    {{ number_format($summary['unique_clicks']) }}

    +

    Unique clicks

    +
    +
    +

    {{ number_format($summary['clicks_7d']) }}

    +

    Last 7 days

    +
    +
    +

    {{ number_format($summary['clicks_30d']) }}

    +

    Last 30 days

    +
    -
    -

    Unique clicks

    -

    {{ number_format($link->unique_clicks_total) }}

    + +
    +

    Clicks — last 30 days

    + @php $maxDaily = max(1, $dailyClicks->max('total')); @endphp +
    + @foreach($dailyClicks as $day) +
    +
    +
    + @endforeach +
    -
    -

    Last clicked

    -

    {{ $link->last_clicked_at?->diffForHumans() ?? 'Never' }}

    + +
    + @include('links.partials.analytics-breakdown', ['title' => 'Devices', 'rows' => $devices, 'empty' => 'No clicks yet']) + @include('links.partials.analytics-breakdown', ['title' => 'Browsers', 'rows' => $browsers, 'empty' => 'No clicks yet']) + @include('links.partials.analytics-breakdown', ['title' => 'Platforms', 'rows' => $platforms, 'empty' => 'No clicks yet']) + @include('links.partials.analytics-breakdown', ['title' => 'Countries', 'rows' => $countries, 'empty' => 'No location data yet']) +
    + +
    + @include('links.partials.analytics-breakdown', ['title' => 'Top referrers', 'rows' => $referrers, 'empty' => 'No referrer data yet']) + +
    +
    +

    Recent clicks

    +
    +
      + @forelse($recentClicks as $click) +
    • +
      +

      {{ $click->contextLabel() }}

      + @if ($click->is_unique) +

      Unique visit

      + @endif +
      + +
    • + @empty +
    • No clicks recorded yet.
    • + @endforelse +
    +
    @@ -51,6 +104,8 @@
    @csrf @method('PATCH') +

    Settings

    +
    @else
    -

    Destination

    +

    Destination

    {{ $link->destination_url }}

    @endif diff --git a/tests/Feature/LinkAnalyticsTest.php b/tests/Feature/LinkAnalyticsTest.php new file mode 100644 index 0000000..f53ec31 --- /dev/null +++ b/tests/Feature/LinkAnalyticsTest.php @@ -0,0 +1,96 @@ + (string) Str::uuid(), + 'name' => 'Test', + 'email' => 'test+'.uniqid().'@example.com', + ]); + } + + public function test_redirect_records_device_browser_os_and_country(): void + { + $user = $this->user(); + ShortLink::create([ + 'user_id' => $user->id, + 'slug' => 'tracked', + 'source_app' => 'link', + 'source_kind' => 'redirect', + 'is_managed_here' => true, + 'destination_url' => 'https://example.com/target', + 'is_active' => true, + ]); + + $this->withHeaders([ + 'User-Agent' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1', + 'CF-IPCountry' => 'GH', + ])->get('https://ladl.link/tracked') + ->assertRedirect('https://example.com/target'); + + $click = LinkClick::first(); + $this->assertNotNull($click); + $this->assertSame('mobile', $click->device_type); + $this->assertSame('Safari', $click->browser); + $this->assertSame('iOS', $click->os); + $this->assertSame('GH', $click->country); + } + + public function test_breakdown_for_link_groups_clicks_by_device(): void + { + $user = $this->user(); + $link = ShortLink::create([ + 'user_id' => $user->id, + 'slug' => 'breakdown', + 'source_app' => 'link', + 'source_kind' => 'redirect', + 'is_managed_here' => true, + 'destination_url' => 'https://example.com', + 'is_active' => true, + 'clicks_total' => 2, + 'unique_clicks_total' => 2, + ]); + + LinkClick::create([ + 'short_link_id' => $link->id, + 'ip_hash' => 'a', + 'device_type' => 'mobile', + 'browser' => 'Chrome', + 'os' => 'Android', + 'country' => 'GH', + 'is_unique' => true, + 'clicked_at' => now(), + ]); + LinkClick::create([ + 'short_link_id' => $link->id, + 'ip_hash' => 'b', + 'device_type' => 'desktop', + 'browser' => 'Chrome', + 'os' => 'Windows', + 'country' => 'US', + 'is_unique' => true, + 'clicked_at' => now(), + ]); + + $devices = app(LinkAnalyticsService::class)->breakdownForLink($link, 'device_type'); + + $this->assertCount(2, $devices); + $labels = array_column($devices, 'label'); + $this->assertContains('Mobile', $labels); + $this->assertContains('Desktop', $labels); + } +}