-
-
+ {{-- Browsers --}}
+
+
+
+
+
+
{{ __('filament-short-url::default.stats_breakdown_browsers') }}
-
{{ __('filament-short-url::default.stats_breakdown_os') }}
-
-
- @forelse ($visitsByOs as $os => $count)
- @php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
-
-
-
-
{{ $os }}
+
+ @forelse ($visitsByBrowser as $browser => $count)
+ @php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
+
+
+
+ {{ $browser }}
+
+
+ {{ number_format($count) }} ({{ $pct }}%)
+
-
- {{ number_format($count) }} ({{ $pct }}%)
-
-
- @empty
-
{{ __('filament-short-url::default.stats_no_os_data') }}
- @endforelse
-
-
-
- {{-- Referers --}}
-
-
-
-
+ @empty
+
{{ __('filament-short-url::default.stats_no_browser_data') }}
+ @endforelse
-
{{ __('filament-short-url::default.stats_breakdown_referers') }}
-
- @forelse ($visitsByReferer as $referer => $count)
-
-
-
- {{ number_format($count) }}
-
+
+ {{-- Operating Systems --}}
+
+
+
+
- @empty
-
{{ __('filament-short-url::default.stats_no_referer_data') }}
- @endforelse
+
{{ __('filament-short-url::default.stats_breakdown_os') }}
+
+
+ @forelse ($visitsByOs as $os => $count)
+ @php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
+
+
+
+ {{ $os }}
+
+
+ {{ number_format($count) }} ({{ $pct }}%)
+
+
+ @empty
+
{{ __('filament-short-url::default.stats_no_os_data') }}
+ @endforelse
+
+
+ {{-- Referers --}}
+
+
+
+
+
+
{{ __('filament-short-url::default.stats_breakdown_referers') }}
+
+
+ @forelse ($visitsByReferer as $referer => $count)
+ @php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
+
+
+
+ {{ number_format($count) }} ({{ $pct }}%)
+
+
+ @empty
+
{{ __('filament-short-url::default.stats_no_referer_data') }}
+ @endforelse
+
+
+
+ {{-- UTM Campaigns Section --}}
+
+
+ {{-- UTM Sources --}}
+
+
+
+
+
+
{{ __('filament-short-url::default.stats_breakdown_utm_source') }}
+
+
+ @forelse ($utmSources as $source => $count)
+ @php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
+
+ {{ $source }}
+
+ {{ number_format($count) }} ({{ $pct }}%)
+
+
+ @empty
+
{{ __('filament-short-url::default.stats_no_utm_data') }}
+ @endforelse
+
+
+
+ {{-- UTM Mediums --}}
+
+
+
+
+
+
{{ __('filament-short-url::default.stats_breakdown_utm_medium') }}
+
+
+ @forelse ($utmMediums as $medium => $count)
+ @php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
+
+ {{ $medium }}
+
+ {{ number_format($count) }} ({{ $pct }}%)
+
+
+ @empty
+
{{ __('filament-short-url::default.stats_no_utm_data') }}
+ @endforelse
+
+
+
+ {{-- UTM Campaigns --}}
+
+
+
+
+
+
{{ __('filament-short-url::default.stats_breakdown_utm_campaign') }}
+
+
+ @forelse ($utmCampaigns as $campaign => $count)
+ @php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
+
+ {{ $campaign }}
+
+ {{ number_format($count) }} ({{ $pct }}%)
+
+
+ @empty
+
{{ __('filament-short-url::default.stats_no_utm_data') }}
+ @endforelse
+
+
+
+
diff --git a/resources/views/widgets/visits-right-breakdown.blade.php b/resources/views/widgets/visits-right-breakdown.blade.php
index 23f0e4c..c02ba68 100644
--- a/resources/views/widgets/visits-right-breakdown.blade.php
+++ b/resources/views/widgets/visits-right-breakdown.blade.php
@@ -27,5 +27,31 @@
+ {{-- Cities --}}
+
+
+
+
+
+
{{ __('filament-short-url::default.stats_breakdown_cities') }}
+
+
+ @forelse ($visitsByCity as $city => $count)
+ @php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
+
+
+ {{ $city }}
+ {{ number_format($count) }} ({{ $pct }}%)
+
+
+
+ @empty
+
{{ __('filament-short-url::default.stats_no_city_data') }}
+ @endforelse
+
+
+
diff --git a/src/Console/Commands/SyncBufferedCountersCommand.php b/src/Console/Commands/SyncBufferedCountersCommand.php
index 3ef3295..9260444 100644
--- a/src/Console/Commands/SyncBufferedCountersCommand.php
+++ b/src/Console/Commands/SyncBufferedCountersCommand.php
@@ -6,6 +6,7 @@ use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Redis;
class SyncBufferedCountersCommand extends Command
{
@@ -21,12 +22,12 @@ class SyncBufferedCountersCommand extends Command
$dirtyKey = "{$prefix}dirty_ids";
// Pull the list atomically to avoid race conditions with incoming clicks
- if (Cache::getDefaultDriver() === 'redis' && class_exists(\Illuminate\Support\Facades\Redis::class)) {
- $tempKey = "{$dirtyKey}:temp:" . time();
+ if (Cache::getDefaultDriver() === 'redis' && class_exists(Redis::class)) {
+ $tempKey = "{$dirtyKey}:temp:".time();
try {
- \Illuminate\Support\Facades\Redis::rename($dirtyKey, $tempKey);
- $dirtyIds = \Illuminate\Support\Facades\Redis::smembers($tempKey);
- \Illuminate\Support\Facades\Redis::del($tempKey);
+ Redis::rename($dirtyKey, $tempKey);
+ $dirtyIds = Redis::smembers($tempKey);
+ Redis::del($tempKey);
} catch (\Throwable) {
// If key does not exist or rename fails, fallback
$dirtyIds = [];
diff --git a/src/Filament/Resources/ShortUrlResource/Pages/ViewShortUrlLogs.php b/src/Filament/Resources/ShortUrlResource/Pages/ViewShortUrlLogs.php
index fa20c8f..e8f725d 100644
--- a/src/Filament/Resources/ShortUrlResource/Pages/ViewShortUrlLogs.php
+++ b/src/Filament/Resources/ShortUrlResource/Pages/ViewShortUrlLogs.php
@@ -127,7 +127,7 @@ class ViewShortUrlLogs extends Page implements HasForms, HasTable
->label(__('filament-short-url::default.stats_action_export'))
->icon('heroicon-o-arrow-down-tray')
->color('gray')
- ->action(function (Tables\Contracts\HasTable $livewire) {
+ ->action(function (HasTable $livewire) {
return response()->streamDownload(function () use ($livewire) {
$handle = fopen('php://output', 'w');
// Add UTF-8 BOM for Microsoft Excel
diff --git a/src/Filament/Resources/ShortUrlResource/Pages/ViewShortUrlStats.php b/src/Filament/Resources/ShortUrlResource/Pages/ViewShortUrlStats.php
index e20b800..c3126ef 100644
--- a/src/Filament/Resources/ShortUrlResource/Pages/ViewShortUrlStats.php
+++ b/src/Filament/Resources/ShortUrlResource/Pages/ViewShortUrlStats.php
@@ -5,9 +5,12 @@ namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Filament\Actions\Action;
+use Filament\Forms\Components\DatePicker;
+use Filament\Forms\Components\Select;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Resources\Pages\Page;
+use Filament\Schemas\Schema;
class ViewShortUrlStats extends Page implements HasForms
{
@@ -23,10 +26,74 @@ class ViewShortUrlStats extends Page implements HasForms
public int $totalVisits = 0;
+ public ?array $filterData = [];
+
public function mount(ShortUrl $record): void
{
$this->record = $record;
- $this->totalVisits = $record->getCachedStats()['totalVisits'] ?? 0;
+
+ $this->form->fill([
+ 'preset' => '30_days',
+ 'date_from' => now()->subDays(29)->format('Y-m-d'),
+ 'date_to' => now()->format('Y-m-d'),
+ ]);
+
+ if (empty($this->filterData)) {
+ $this->filterData = [
+ 'preset' => '30_days',
+ 'date_from' => now()->subDays(29)->format('Y-m-d'),
+ 'date_to' => now()->format('Y-m-d'),
+ ];
+ }
+
+ $this->totalVisits = $record->getCachedStats($this->filterData['date_from'], $this->filterData['date_to'])['totalVisits'] ?? 0;
+ }
+
+ public function form(Schema $schema): Schema
+ {
+ return $schema
+ ->components([
+ Select::make('preset')
+ ->label(__('filament-short-url::default.stats_filter_date_range'))
+ ->options([
+ '24_hours' => __('filament-short-url::default.stats_preset_24_hours'),
+ '7_days' => __('filament-short-url::default.stats_preset_7_days'),
+ '30_days' => __('filament-short-url::default.stats_preset_30_days'),
+ '90_days' => __('filament-short-url::default.stats_preset_90_days'),
+ 'custom' => __('filament-short-url::default.stats_preset_custom'),
+ ])
+ ->live()
+ ->afterStateUpdated(function ($state, $set) {
+ $to = now()->format('Y-m-d');
+ $from = match ($state) {
+ '24_hours' => now()->subDay()->format('Y-m-d'),
+ '7_days' => now()->subDays(6)->format('Y-m-d'),
+ '30_days' => now()->subDays(29)->format('Y-m-d'),
+ '90_days' => now()->subDays(89)->format('Y-m-d'),
+ 'custom' => now()->subDays(29)->format('Y-m-d'),
+ default => null,
+ };
+ if ($from) {
+ $set('date_from', $from);
+ $set('date_to', $to);
+ }
+ }),
+
+ DatePicker::make('date_from')
+ ->label(__('filament-short-url::default.stats_filter_visited_from'))
+ ->native(false)
+ ->live()
+ ->visible(fn ($get) => $get('preset') === 'custom')
+ ->required(),
+
+ DatePicker::make('date_to')
+ ->label(__('filament-short-url::default.stats_filter_visited_until'))
+ ->native(false)
+ ->live()
+ ->visible(fn ($get) => $get('preset') === 'custom')
+ ->required(),
+ ])
+ ->statePath('filterData');
}
protected function getHeaderWidgets(): array
diff --git a/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlStatsOverview.php b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlStatsOverview.php
index bfbdc04..c56469d 100644
--- a/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlStatsOverview.php
+++ b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlStatsOverview.php
@@ -10,13 +10,27 @@ class ShortUrlStatsOverview extends BaseWidget
{
public ?ShortUrl $record = null;
+ public ?string $dateFrom = null;
+
+ public ?string $dateTo = null;
+
protected function getStats(): array
{
if (! $this->record) {
return [];
}
- $stats = $this->record->getCachedStats();
+ $stats = $this->record->getCachedStats($this->dateFrom, $this->dateTo);
+
+ $topSource = 'Direct';
+ if (! empty($stats['utmSources'])) {
+ $topSource = array_key_first($stats['utmSources']);
+ }
+
+ $topCountry = '—';
+ if (! empty($stats['visitsByCountry'])) {
+ $topCountry = array_key_first($stats['visitsByCountry']);
+ }
return [
Stat::make(__('filament-short-url::default.stats_card_total'), number_format($stats['totalVisits'] ?? 0))
@@ -27,17 +41,13 @@ class ShortUrlStatsOverview extends BaseWidget
->icon('heroicon-o-user-group')
->color('info'),
- Stat::make(__('filament-short-url::default.stats_card_today'), number_format($stats['visitsToday'] ?? 0))
- ->icon('heroicon-o-sun')
+ Stat::make(__('filament-short-url::default.stats_card_top_source'), $topSource)
+ ->icon('heroicon-o-megaphone')
->color('warning'),
- Stat::make(__('filament-short-url::default.stats_card_week'), number_format($stats['visitsThisWeek'] ?? 0))
- ->icon('heroicon-o-calendar-days')
+ Stat::make(__('filament-short-url::default.stats_card_top_country'), $topCountry)
+ ->icon('heroicon-o-globe-alt')
->color('success'),
-
- Stat::make(__('filament-short-url::default.stats_card_month'), number_format($stats['visitsThisMonth'] ?? 0))
- ->icon('heroicon-o-calendar')
- ->color('gray'),
];
}
}
diff --git a/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsBottomBreakdown.php b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsBottomBreakdown.php
index 57a38b7..4dd0d31 100644
--- a/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsBottomBreakdown.php
+++ b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsBottomBreakdown.php
@@ -9,6 +9,10 @@ class ShortUrlVisitsBottomBreakdown extends Widget
{
public ?ShortUrl $record = null;
+ public ?string $dateFrom = null;
+
+ public ?string $dateTo = null;
+
protected string $view = 'filament-short-url::widgets.visits-bottom-breakdown';
protected int|string|array $columnSpan = 'full';
@@ -29,17 +33,23 @@ class ShortUrlVisitsBottomBreakdown extends Widget
'visitsByBrowser' => [],
'visitsByOs' => [],
'visitsByReferer' => [],
+ 'utmSources' => [],
+ 'utmMediums' => [],
+ 'utmCampaigns' => [],
'totalVisits' => 0,
];
}
- $stats = $this->record->getCachedStats();
+ $stats = $this->record->getCachedStats($this->dateFrom, $this->dateTo);
return [
'visitsByDevice' => $stats['visitsByDevice'] ?? [],
'visitsByBrowser' => $stats['visitsByBrowser'] ?? [],
'visitsByOs' => $stats['visitsByOs'] ?? [],
'visitsByReferer' => $stats['visitsByReferer'] ?? [],
+ 'utmSources' => $stats['utmSources'] ?? [],
+ 'utmMediums' => $stats['utmMediums'] ?? [],
+ 'utmCampaigns' => $stats['utmCampaigns'] ?? [],
'totalVisits' => $stats['totalVisits'] ?? 0,
];
}
diff --git a/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsChart.php b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsChart.php
index 5d47b12..55f0138 100644
--- a/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsChart.php
+++ b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsChart.php
@@ -9,6 +9,10 @@ class ShortUrlVisitsChart extends ChartWidget
{
public ?ShortUrl $record = null;
+ public ?string $dateFrom = null;
+
+ public ?string $dateTo = null;
+
protected ?string $maxHeight = '200px';
protected int|string|array $columnSpan = [
@@ -29,7 +33,7 @@ class ShortUrlVisitsChart extends ChartWidget
];
}
- $stats = $this->record->getCachedStats();
+ $stats = $this->record->getCachedStats($this->dateFrom, $this->dateTo);
$visitsByDay = $stats['visitsByDay'] ?? [];
return [
diff --git a/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsRightBreakdown.php b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsRightBreakdown.php
index 7d56657..0671a68 100644
--- a/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsRightBreakdown.php
+++ b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsRightBreakdown.php
@@ -9,6 +9,10 @@ class ShortUrlVisitsRightBreakdown extends Widget
{
public ?ShortUrl $record = null;
+ public ?string $dateFrom = null;
+
+ public ?string $dateTo = null;
+
protected string $view = 'filament-short-url::widgets.visits-right-breakdown';
protected int|string|array $columnSpan = [
@@ -28,14 +32,16 @@ class ShortUrlVisitsRightBreakdown extends Widget
if (! $this->record) {
return [
'visitsByCountry' => [],
+ 'visitsByCity' => [],
'totalVisits' => 0,
];
}
- $stats = $this->record->getCachedStats();
+ $stats = $this->record->getCachedStats($this->dateFrom, $this->dateTo);
return [
'visitsByCountry' => $stats['visitsByCountry'] ?? [],
+ 'visitsByCity' => $stats['visitsByCity'] ?? [],
'totalVisits' => $stats['totalVisits'] ?? 0,
];
}
diff --git a/src/FilamentShortUrlServiceProvider.php b/src/FilamentShortUrlServiceProvider.php
index 6e9e296..50cdeca 100644
--- a/src/FilamentShortUrlServiceProvider.php
+++ b/src/FilamentShortUrlServiceProvider.php
@@ -25,6 +25,7 @@ class FilamentShortUrlServiceProvider extends PackageServiceProvider
->hasMigrations([
'2024_01_01_000001_create_short_urls_table',
'2024_01_01_000002_create_short_url_visits_table',
+ '2026_06_01_000003_add_utm_city_referer_to_short_url_visits_table',
])
->hasCommand(SyncBufferedCountersCommand::class)
->hasRoutes(['web']);
diff --git a/src/Http/Controllers/ShortUrlRedirectController.php b/src/Http/Controllers/ShortUrlRedirectController.php
index dedb01a..f6477eb 100644
--- a/src/Http/Controllers/ShortUrlRedirectController.php
+++ b/src/Http/Controllers/ShortUrlRedirectController.php
@@ -34,6 +34,7 @@ class ShortUrlRedirectController extends Controller
$connection = config('filament-short-url.queue_connection', 'sync');
$ipAddress = ClientIpExtractor::getIp($request);
$countryCode = ClientIpExtractor::getCountryCode($request);
+ $city = ClientIpExtractor::getCity($request);
$job = new TrackShortUrlVisitJob(
shortUrl: $shortUrl,
@@ -41,6 +42,12 @@ class ShortUrlRedirectController extends Controller
userAgent: $request->userAgent() ?? '',
refererUrl: $request->header('Referer'),
countryCode: $countryCode,
+ city: $city,
+ utmSource: $request->query('utm_source'),
+ utmMedium: $request->query('utm_medium'),
+ utmCampaign: $request->query('utm_campaign'),
+ utmTerm: $request->query('utm_term'),
+ utmContent: $request->query('utm_content'),
);
if ($connection) {
diff --git a/src/Jobs/TrackShortUrlVisitJob.php b/src/Jobs/TrackShortUrlVisitJob.php
index 9c5df88..a477a43 100644
--- a/src/Jobs/TrackShortUrlVisitJob.php
+++ b/src/Jobs/TrackShortUrlVisitJob.php
@@ -41,6 +41,12 @@ class TrackShortUrlVisitJob implements ShouldQueue
public readonly string $userAgent,
public readonly ?string $refererUrl,
public readonly ?string $countryCode = null,
+ public readonly ?string $city = null,
+ public readonly ?string $utmSource = null,
+ public readonly ?string $utmMedium = null,
+ public readonly ?string $utmCampaign = null,
+ public readonly ?string $utmTerm = null,
+ public readonly ?string $utmContent = null,
) {
$this->onQueue(config('filament-short-url.queue_name', 'default'));
}
@@ -62,7 +68,19 @@ class TrackShortUrlVisitJob implements ShouldQueue
]);
$countryCode = isset($this->countryCode) ? $this->countryCode : null;
- $visit = $tracker->record($shortUrl, $request, $countryCode);
+ $city = isset($this->city) ? $this->city : null;
+
+ $visit = $tracker->record(
+ shortUrl: $shortUrl,
+ request: $request,
+ preResolvedCountryCode: $countryCode,
+ preResolvedCity: $city,
+ utmSource: $this->utmSource,
+ utmMedium: $this->utmMedium,
+ utmCampaign: $this->utmCampaign,
+ utmTerm: $this->utmTerm,
+ utmContent: $this->utmContent,
+ );
// Null means bot/crawler — nothing to dispatch or report
if ($visit === null) {
diff --git a/src/Models/ShortUrl.php b/src/Models/ShortUrl.php
index 1f34b3a..624ba7d 100644
--- a/src/Models/ShortUrl.php
+++ b/src/Models/ShortUrl.php
@@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Redis;
/**
* @property int $id
@@ -255,8 +256,8 @@ class ShortUrl extends Model
$dirtyKey = "{$prefix}dirty_ids";
// Check if Redis is being used for the cache to prevent set race conditions
- if (cache()->getDefaultDriver() === 'redis' && class_exists(\Illuminate\Support\Facades\Redis::class)) {
- \Illuminate\Support\Facades\Redis::sadd($dirtyKey, $this->id);
+ if (cache()->getDefaultDriver() === 'redis' && class_exists(Redis::class)) {
+ Redis::sadd($dirtyKey, $this->id);
} else {
// Fallback to array for standard cache drivers
$dirtyIds = cache()->get($dirtyKey, []);
@@ -282,33 +283,57 @@ class ShortUrl extends Model
*
* @return array
*/
- public function getCachedStats(): array
+ public function getCachedStats(?string $dateFrom = null, ?string $dateTo = null): array
{
- $cacheTtl = (int) config('filament-short-url.geo_ip.stats_cache_ttl', 300);
- $cacheKey = "short_url_stats_{$this->id}";
+ $dateFromClean = $dateFrom ? Carbon::parse($dateFrom)->toDateString() : null;
+ $dateToClean = $dateTo ? Carbon::parse($dateTo)->toDateString() : null;
- return cache()->remember($cacheKey, $cacheTtl, function () {
+ $cacheTtl = (int) config('filament-short-url.geo_ip.stats_cache_ttl', 300);
+ $cacheKey = "short_url_stats_{$this->id}_".($dateFromClean ?: 'all').'_'.($dateToClean ?: 'all');
+
+ return cache()->remember($cacheKey, $cacheTtl, function () use ($dateFromClean, $dateToClean) {
$visits = $this->visits();
- $totalVisits = (int) ($this->total_visits ?? 0);
- $uniqueVisits = (int) ($this->unique_visits ?? 0);
- $visitsToday = (clone $visits)->where('visited_at', '>=', today()->startOfDay())->count();
- $visitsThisWeek = (clone $visits)->where('visited_at', '>=', now()->startOfWeek())->count();
- $visitsThisMonth = (clone $visits)->where('visited_at', '>=', now()->startOfMonth())->count();
+ if ($dateFromClean) {
+ $visits->whereDate('visited_at', '>=', $dateFromClean);
+ }
+ if ($dateToClean) {
+ $visits->whereDate('visited_at', '<=', $dateToClean);
+ }
- // Visits by day — last 30 days
- $visitsByDayRaw = (clone $visits)
- ->where('visited_at', '>=', now()->subDays(29)->startOfDay())
- ->selectRaw('DATE(visited_at) as date, COUNT(*) as count')
- ->groupBy('date')
- ->orderBy('date')
- ->pluck('count', 'date')
- ->toArray();
+ $chartFrom = $dateFromClean ? Carbon::parse($dateFromClean) : now()->subDays(29)->startOfDay();
+ $chartTo = $dateToClean ? Carbon::parse($dateToClean) : now()->endOfDay();
- $visitsByDay = [];
- for ($i = 29; $i >= 0; $i--) {
- $date = now()->subDays($i)->format('Y-m-d');
- $visitsByDay[$date] = $visitsByDayRaw[$date] ?? 0;
+ $totalVisits = (clone $visits)->count();
+ $uniqueVisits = (clone $visits)->distinct('ip_hash')->count('ip_hash');
+
+ $visitsToday = $this->visits()->where('visited_at', '>=', today()->startOfDay())->count();
+ $visitsThisWeek = $this->visits()->where('visited_at', '>=', now()->startOfWeek())->count();
+ $visitsThisMonth = $this->visits()->where('visited_at', '>=', now()->startOfMonth())->count();
+
+ $daysDiff = (int) $chartFrom->diffInDays($chartTo);
+
+ if ($daysDiff > 90) {
+ $visitsByDayRaw = (clone $visits)
+ ->selectRaw('DATE_FORMAT(visited_at, "%Y-%m") as date, COUNT(*) as count')
+ ->groupBy('date')
+ ->orderBy('date')
+ ->pluck('count', 'date')
+ ->toArray();
+ $visitsByDay = $visitsByDayRaw;
+ } else {
+ $visitsByDayRaw = (clone $visits)
+ ->selectRaw('DATE(visited_at) as date, COUNT(*) as count')
+ ->groupBy('date')
+ ->orderBy('date')
+ ->pluck('count', 'date')
+ ->toArray();
+
+ $visitsByDay = [];
+ for ($i = $daysDiff; $i >= 0; $i--) {
+ $date = (clone $chartTo)->subDays($i)->format('Y-m-d');
+ $visitsByDay[$date] = $visitsByDayRaw[$date] ?? 0;
+ }
}
// Top countries
@@ -322,6 +347,17 @@ class ShortUrl extends Model
->mapWithKeys(fn ($row) => [$row->country => $row->count])
->toArray();
+ // Top cities
+ $visitsByCity = (clone $visits)
+ ->whereNotNull('city')
+ ->selectRaw('city, country_code, COUNT(*) as count')
+ ->groupBy('city', 'country_code')
+ ->orderByDesc('count')
+ ->limit(10)
+ ->get()
+ ->mapWithKeys(fn ($row) => ["{$row->city} ({$row->country_code})" => $row->count])
+ ->toArray();
+
// Device types
$visitsByDevice = (clone $visits)
->whereNotNull('device_type')
@@ -351,14 +387,42 @@ class ShortUrl extends Model
->pluck('count', 'operating_system')
->toArray();
- // Top referers
+ // Top referrer hosts
$visitsByReferer = (clone $visits)
- ->whereNotNull('referer_url')
- ->selectRaw('referer_url, COUNT(*) as count')
- ->groupBy('referer_url')
+ ->whereNotNull('referer_host')
+ ->selectRaw('referer_host, COUNT(*) as count')
+ ->groupBy('referer_host')
->orderByDesc('count')
->limit(10)
- ->pluck('count', 'referer_url')
+ ->pluck('count', 'referer_host')
+ ->toArray();
+
+ // UTM Breakdowns
+ $utmSources = (clone $visits)
+ ->whereNotNull('utm_source')
+ ->selectRaw('utm_source, COUNT(*) as count')
+ ->groupBy('utm_source')
+ ->orderByDesc('count')
+ ->limit(8)
+ ->pluck('count', 'utm_source')
+ ->toArray();
+
+ $utmMediums = (clone $visits)
+ ->whereNotNull('utm_medium')
+ ->selectRaw('utm_medium, COUNT(*) as count')
+ ->groupBy('utm_medium')
+ ->orderByDesc('count')
+ ->limit(8)
+ ->pluck('count', 'utm_medium')
+ ->toArray();
+
+ $utmCampaigns = (clone $visits)
+ ->whereNotNull('utm_campaign')
+ ->selectRaw('utm_campaign, COUNT(*) as count')
+ ->groupBy('utm_campaign')
+ ->orderByDesc('count')
+ ->limit(8)
+ ->pluck('count', 'utm_campaign')
->toArray();
return [
@@ -369,10 +433,14 @@ class ShortUrl extends Model
'visitsThisMonth' => $visitsThisMonth,
'visitsByDay' => $visitsByDay,
'visitsByCountry' => $visitsByCountry,
+ 'visitsByCity' => $visitsByCity,
'visitsByDevice' => $visitsByDevice,
'visitsByBrowser' => $visitsByBrowser,
'visitsByOs' => $visitsByOs,
'visitsByReferer' => $visitsByReferer,
+ 'utmSources' => $utmSources,
+ 'utmMediums' => $utmMediums,
+ 'utmCampaigns' => $utmCampaigns,
];
});
}
diff --git a/src/Services/ClientIpExtractor.php b/src/Services/ClientIpExtractor.php
index 9a15c24..cd3cd3b 100644
--- a/src/Services/ClientIpExtractor.php
+++ b/src/Services/ClientIpExtractor.php
@@ -63,4 +63,22 @@ class ClientIpExtractor
return null;
}
+
+ /**
+ * Extract the edge-provided city name from CDN/proxy headers.
+ */
+ public static function getCity(Request $request): ?string
+ {
+ if (config('filament-short-url.trust_cdn_headers', false)) {
+ if ($cfCity = $request->header('CF-IPCity')) {
+ return trim($cfCity);
+ }
+
+ if ($cfViewerCity = $request->header('CloudFront-Viewer-City')) {
+ return trim($cfViewerCity);
+ }
+ }
+
+ return null;
+ }
}
diff --git a/src/Services/GeoIpService.php b/src/Services/GeoIpService.php
index 8e88188..d5c4ad9 100644
--- a/src/Services/GeoIpService.php
+++ b/src/Services/GeoIpService.php
@@ -17,31 +17,32 @@ use Illuminate\Support\Facades\Log;
*/
class GeoIpService
{
- private const API_URL = 'http://ip-api.com/json/%s?fields=status,country,countryCode';
+ private const API_URL = 'http://ip-api.com/json/%s?fields=status,country,countryCode,city';
- /** @var array */
+ /** @var array */
private static array $runtimeCache = [];
/**
- * Resolve country data for the given IP address.
+ * Resolve country and city data for the given IP address.
*
- * @return array{country: string|null, country_code: string|null}
+ * @return array{country: string|null, country_code: string|null, city: string|null}
*/
- public function resolve(string $ip, ?string $preResolvedCountryCode = null): array
+ public function resolve(string $ip, ?string $preResolvedCountryCode = null, ?string $preResolvedCity = null): array
{
- $empty = ['country' => null, 'country_code' => null];
+ $empty = ['country' => null, 'country_code' => null, 'city' => null];
if (! config('filament-short-url.geo_ip.enabled', true)) {
return $empty;
}
- // 1. Prioritise edge-provided CDN country code (extremely fast, zero latency)
+ // 1. Prioritise edge-provided CDN country code & city (extremely fast, zero latency)
if ($preResolvedCountryCode) {
$code = strtoupper(trim($preResolvedCountryCode));
return [
'country' => $this->getCountryName($code),
'country_code' => $code,
+ 'city' => $preResolvedCity,
];
}
@@ -129,9 +130,9 @@ class GeoIpService
}
/**
- * Resolve country using local MaxMind GeoIP2 DB.
+ * Resolve country and city using local MaxMind GeoIP2 DB.
*
- * @return array{country: string, country_code: string}|null
+ * @return array{country: string|null, country_code: string|null, city: string|null}|null
*/
private function lookupMaxMind(string $ip): ?array
{
@@ -151,11 +152,24 @@ class GeoIpService
try {
$reader = new Reader($dbPath);
+ $dbType = $reader->metadata()->databaseType;
+
+ if (str_contains($dbType, 'City')) {
+ $record = $reader->city($ip);
+
+ return [
+ 'country' => $record->country->name ?? null,
+ 'country_code' => $record->country->isoCode ?? null,
+ 'city' => $record->city->name ?? null,
+ ];
+ }
+
$record = $reader->country($ip);
return [
'country' => $record->country->name ?? null,
'country_code' => $record->country->isoCode ?? null,
+ 'city' => null,
];
} catch (\Throwable $e) {
Log::warning('[FilamentShortUrl] MaxMind GeoIP lookup failed', [
@@ -168,9 +182,9 @@ class GeoIpService
}
/**
- * Resolve country using free ip-api.com.
+ * Resolve country and city using free ip-api.com.
*
- * @return array{country: string, country_code: string}|null
+ * @return array{country: string|null, country_code: string|null, city: string|null}|null
*/
private function fetchFromApi(string $ip): ?array
{
@@ -193,6 +207,7 @@ class GeoIpService
return [
'country' => $data['country'] ?? null,
'country_code' => $data['countryCode'] ?? null,
+ 'city' => $data['city'] ?? null,
];
} catch (\Throwable $e) {
Log::warning('[FilamentShortUrl] GeoIP lookup failed', [
diff --git a/src/Services/ShortUrlTracker.php b/src/Services/ShortUrlTracker.php
index d5b04a5..54a46eb 100644
--- a/src/Services/ShortUrlTracker.php
+++ b/src/Services/ShortUrlTracker.php
@@ -23,8 +23,17 @@ class ShortUrlTracker
*
* Returns null if the visit was from a bot/crawler (we don't track those).
*/
- public function record(ShortUrl $shortUrl, Request $request, ?string $preResolvedCountryCode = null): ?ShortUrlVisit
- {
+ public function record(
+ ShortUrl $shortUrl,
+ Request $request,
+ ?string $preResolvedCountryCode = null,
+ ?string $preResolvedCity = null,
+ ?string $utmSource = null,
+ ?string $utmMedium = null,
+ ?string $utmCampaign = null,
+ ?string $utmTerm = null,
+ ?string $utmContent = null,
+ ): ?ShortUrlVisit {
$ip = ClientIpExtractor::getIp($request);
$ipHash = hash('sha256', $ip);
$ua = $request->userAgent() ?? '';
@@ -36,8 +45,8 @@ class ShortUrlTracker
}
$geo = config('filament-short-url.geo_ip.enabled', true)
- ? $this->geoIp->resolve($ip, $preResolvedCountryCode)
- : ['country' => null, 'country_code' => null];
+ ? $this->geoIp->resolve($ip, $preResolvedCountryCode, $preResolvedCity)
+ : ['country' => null, 'country_code' => null, 'city' => null];
// Determine uniqueness: first time this IP hash visits this URL.
// We check BEFORE insert to avoid a self-referential race.
@@ -56,9 +65,42 @@ class ShortUrlTracker
$visit->operating_system_version = $shortUrl->track_operating_system_version
? $parsed['operating_system_version'] : null;
$visit->device_type = $shortUrl->track_device_type ? $parsed['device_type'] : null;
- $visit->referer_url = $shortUrl->track_referer_url ? $request->header('Referer') : null;
$visit->country = $geo['country'];
$visit->country_code = $geo['country_code'];
+ $visit->city = $geo['city'] ?? null;
+
+ $visit->utm_source = $utmSource;
+ $visit->utm_medium = $utmMedium;
+ $visit->utm_campaign = $utmCampaign;
+ $visit->utm_term = $utmTerm;
+ $visit->utm_content = $utmContent;
+
+ // Clean & normalize referer URL to host domain
+ if ($shortUrl->track_referer_url && $referer = $request->header('Referer')) {
+ $visit->referer_url = $referer;
+ $refererHost = parse_url($referer, PHP_URL_HOST);
+ if ($refererHost) {
+ $refererHost = preg_replace('/^www\./', '', strtolower($refererHost));
+ if (str_contains($refererHost, 'facebook.com')) {
+ $refererHost = 'facebook.com';
+ } elseif (str_contains($refererHost, 'linkedin.com')) {
+ $refererHost = 'linkedin.com';
+ } elseif (str_contains($refererHost, 'twitter.com') || str_contains($refererHost, 't.co')) {
+ $refererHost = 'twitter.com';
+ } elseif (str_contains($refererHost, 'google.')) {
+ $refererHost = 'google.com';
+ } elseif (str_contains($refererHost, 'instagram.com')) {
+ $refererHost = 'instagram.com';
+ } elseif (str_contains($refererHost, 'youtube.com')) {
+ $refererHost = 'youtube.com';
+ }
+ $visit->referer_host = $refererHost;
+ } else {
+ $visit->referer_host = 'Direct';
+ }
+ } else {
+ $visit->referer_host = 'Direct';
+ }
$visit->save();