Add device, platform, and country analytics to link show page.
Deploy Ladill Link / deploy (push) Successful in 32s
Deploy Ladill Link / deploy (push) Successful in 32s
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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string> */
|
||||
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<int, object{date:string,total:int}> */
|
||||
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<int, object{date:string,total:int}> */
|
||||
public function dailyClicksForLink(ShortLink $link, int $days = 30): Collection
|
||||
{
|
||||
return $this->dailyClicks(
|
||||
LinkClick::query()->where('short_link_id', $link->id),
|
||||
$days,
|
||||
);
|
||||
}
|
||||
|
||||
/** @return Collection<int, object{date:string,total:int}> */
|
||||
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<int, array{label:string,total:int}> */
|
||||
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<int, array{label:string,total:int}> */
|
||||
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<int, array{label:string,total:int}> */
|
||||
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<int, array{label:string,total:int}> */
|
||||
public function topLinksForAccount(User $account, int $limit = 8): Collection
|
||||
{
|
||||
@@ -74,12 +151,39 @@ class LinkAnalyticsService
|
||||
->get();
|
||||
}
|
||||
|
||||
/** @return Collection<int, LinkClick> */
|
||||
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<int, array{label:string,total:int}> */
|
||||
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<int, array{label:string,total:int}> */
|
||||
public function referrersForLink(ShortLink $link, int $limit = 8): Collection
|
||||
{
|
||||
return $this->referrers(
|
||||
LinkClick::query()->where('short_link_id', $link->id),
|
||||
$limit,
|
||||
);
|
||||
}
|
||||
|
||||
/** @return Collection<int, array{label:string,total:int}> */
|
||||
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);
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Link;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class LinkGeoResolver
|
||||
{
|
||||
public static function countryCode(Request $request): ?string
|
||||
{
|
||||
foreach (['CF-IPCountry', 'X-Country-Code', 'CloudFront-Viewer-Country'] as $header) {
|
||||
$value = $request->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Link;
|
||||
|
||||
final class LinkUserAgentParser
|
||||
{
|
||||
/**
|
||||
* @return array{device_type: string, browser: string, os: string}
|
||||
*/
|
||||
public static function parse(string $userAgent): array
|
||||
{
|
||||
$uaLower = strtolower($userAgent);
|
||||
|
||||
$device = str_contains($uaLower, 'mobile') || str_contains($uaLower, 'android') || str_contains($uaLower, 'iphone')
|
||||
? 'mobile'
|
||||
: (str_contains($uaLower, 'tablet') || str_contains($uaLower, 'ipad') ? 'tablet' : 'desktop');
|
||||
|
||||
$browser = match (true) {
|
||||
str_contains($uaLower, 'edg/') => '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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?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::table('link_clicks', function (Blueprint $table) {
|
||||
$table->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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -56,19 +56,14 @@
|
||||
@endforelse
|
||||
</ul>
|
||||
</div>
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Top referrers</h2>
|
||||
<ul class="mt-4 space-y-2.5">
|
||||
@forelse($referrers as $row)
|
||||
<li class="flex items-center justify-between gap-3 text-sm">
|
||||
<span class="truncate text-slate-600">{{ $row['label'] }}</span>
|
||||
<span class="shrink-0 font-semibold text-slate-900">{{ number_format($row['total']) }}</span>
|
||||
</li>
|
||||
@empty
|
||||
<li class="text-sm text-slate-400">No referrer data yet</li>
|
||||
@endforelse
|
||||
</ul>
|
||||
@include('links.partials.analytics-breakdown', ['title' => 'Top referrers', 'rows' => $referrers, 'empty' => 'No referrer data yet'])
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
@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'])
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-slate-200 bg-white">
|
||||
@@ -80,7 +75,7 @@
|
||||
<li class="flex items-center justify-between gap-4 px-5 py-3 text-sm">
|
||||
<div class="min-w-0">
|
||||
<p class="font-medium text-slate-900">{{ $click->shortLink?->label ?: $click->shortLink?->slug }}</p>
|
||||
<p class="truncate text-xs text-slate-500">{{ $click->referer ?: 'Direct / unknown' }}</p>
|
||||
<p class="truncate text-xs text-slate-500">{{ $click->contextLabel() }}</p>
|
||||
</div>
|
||||
<time class="shrink-0 text-xs text-slate-400">{{ $click->clicked_at->diffForHumans() }}</time>
|
||||
</li>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
@props(['title', 'rows', 'empty' => 'No data yet'])
|
||||
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-5">
|
||||
<h3 class="text-sm font-semibold text-slate-900">{{ $title }}</h3>
|
||||
<ul class="mt-4 space-y-2.5">
|
||||
@forelse($rows as $row)
|
||||
<li class="flex items-center justify-between gap-3 text-sm">
|
||||
<span class="truncate text-slate-600">{{ $row['label'] }}</span>
|
||||
<span class="shrink-0 font-semibold text-slate-900">{{ number_format($row['total']) }}</span>
|
||||
</li>
|
||||
@empty
|
||||
<li class="text-sm text-slate-400">{{ $empty }}</li>
|
||||
@endforelse
|
||||
</ul>
|
||||
</div>
|
||||
@@ -1,6 +1,6 @@
|
||||
<x-user-layout>
|
||||
<x-slot name="title">{{ $link->label ?: $link->slug }}</x-slot>
|
||||
<div class="mx-auto max-w-3xl space-y-6">
|
||||
<div class="mx-auto max-w-6xl space-y-6">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@@ -32,18 +32,71 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<div class="space-y-5">
|
||||
<h2 class="text-base font-semibold text-slate-900">Analytics</h2>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<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>
|
||||
<p class="text-2xl font-bold text-slate-900">{{ number_format($summary['total_clicks']) }}</p>
|
||||
<p class="mt-0.5 text-xs text-slate-500">Total clicks</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-4">
|
||||
<p class="text-sm text-slate-500">Unique clicks</p>
|
||||
<p class="text-2xl font-semibold">{{ number_format($link->unique_clicks_total) }}</p>
|
||||
<p class="text-2xl font-bold text-slate-900">{{ number_format($summary['unique_clicks']) }}</p>
|
||||
<p class="mt-0.5 text-xs text-slate-500">Unique clicks</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-4">
|
||||
<p class="text-sm text-slate-500">Last clicked</p>
|
||||
<p class="text-sm font-medium">{{ $link->last_clicked_at?->diffForHumans() ?? 'Never' }}</p>
|
||||
<p class="text-2xl font-bold text-slate-900">{{ number_format($summary['clicks_7d']) }}</p>
|
||||
<p class="mt-0.5 text-xs text-slate-500">Last 7 days</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-4">
|
||||
<p class="text-2xl font-bold text-slate-900">{{ number_format($summary['clicks_30d']) }}</p>
|
||||
<p class="mt-0.5 text-xs text-slate-500">Last 30 days</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-6">
|
||||
<h3 class="text-sm font-semibold text-slate-900">Clicks — last 30 days</h3>
|
||||
@php $maxDaily = max(1, $dailyClicks->max('total')); @endphp
|
||||
<div class="mt-5 flex h-28 items-end gap-px">
|
||||
@foreach($dailyClicks as $day)
|
||||
<div class="group relative flex flex-1 flex-col items-center justify-end" title="{{ $day->date }}: {{ $day->total }}">
|
||||
<div class="w-full rounded-t-sm bg-emerald-400/70 transition-colors group-hover:bg-emerald-600"
|
||||
style="height: {{ max(2, ($day->total / $maxDaily) * 100) }}%"></div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
@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'])
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
@include('links.partials.analytics-breakdown', ['title' => 'Top referrers', 'rows' => $referrers, 'empty' => 'No referrer data yet'])
|
||||
|
||||
<div class="rounded-xl border border-slate-200 bg-white">
|
||||
<div class="border-b border-slate-100 px-5 py-4">
|
||||
<h3 class="text-sm font-semibold text-slate-900">Recent clicks</h3>
|
||||
</div>
|
||||
<ul class="divide-y divide-slate-100">
|
||||
@forelse($recentClicks as $click)
|
||||
<li class="flex items-center justify-between gap-4 px-5 py-3 text-sm">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-slate-700">{{ $click->contextLabel() }}</p>
|
||||
@if ($click->is_unique)
|
||||
<p class="text-xs text-emerald-600">Unique visit</p>
|
||||
@endif
|
||||
</div>
|
||||
<time class="shrink-0 text-xs text-slate-400">{{ $click->clicked_at->diffForHumans() }}</time>
|
||||
</li>
|
||||
@empty
|
||||
<li class="px-5 py-8 text-center text-sm text-slate-400">No clicks recorded yet.</li>
|
||||
@endforelse
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -51,6 +104,8 @@
|
||||
<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')
|
||||
|
||||
<h2 class="text-base font-semibold text-slate-900">Settings</h2>
|
||||
|
||||
<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
|
||||
@@ -74,7 +129,7 @@
|
||||
</form>
|
||||
@else
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-6">
|
||||
<p class="text-sm font-medium text-slate-700">Destination</p>
|
||||
<h2 class="text-base font-semibold text-slate-900">Destination</h2>
|
||||
<p class="mt-1 text-sm text-slate-600">{{ $link->destination_url }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\LinkClick;
|
||||
use App\Models\ShortLink;
|
||||
use App\Models\User;
|
||||
use App\Services\Link\LinkAnalyticsService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
class LinkAnalyticsTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function user(): User
|
||||
{
|
||||
return User::create([
|
||||
'public_id' => (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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user