diff --git a/database/migrations/2026_06_01_000003_add_utm_city_referer_to_short_url_visits_table.php b/database/migrations/2026_06_01_000003_add_utm_city_referer_to_short_url_visits_table.php new file mode 100644 index 0000000..c5385c9 --- /dev/null +++ b/database/migrations/2026_06_01_000003_add_utm_city_referer_to_short_url_visits_table.php @@ -0,0 +1,36 @@ +string('city', 100)->nullable()->after('country_code'); + $table->string('referer_host', 255)->nullable()->after('referer_url')->index(); + $table->string('utm_source', 100)->nullable()->after('visited_at')->index(); + $table->string('utm_medium', 100)->nullable()->after('utm_source')->index(); + $table->string('utm_campaign', 100)->nullable()->after('utm_medium')->index(); + $table->string('utm_term', 100)->nullable()->after('utm_campaign'); + $table->string('utm_content', 100)->nullable()->after('utm_term'); + }); + } + + public function down(): void + { + Schema::table('short_url_visits', function (Blueprint $table): void { + $table->dropColumn([ + 'city', + 'referer_host', + 'utm_source', + 'utm_medium', + 'utm_campaign', + 'utm_term', + 'utm_content', + ]); + }); + } +}; diff --git a/resources/lang/en/default.php b/resources/lang/en/default.php index 34eacad..9096fe7 100644 --- a/resources/lang/en/default.php +++ b/resources/lang/en/default.php @@ -211,4 +211,20 @@ return [ 'form_section_tracking' => 'Visit Tracking', 'form_section_tracked_fields' => 'Tracked Fields', 'form_section_analytics' => 'Third-Party Analytics', + + // New Dashboard Analytics Keys + 'stats_card_top_source' => 'Top UTM Source', + 'stats_card_top_country' => 'Top Country', + 'stats_filter_date_range' => 'Date Range', + 'stats_preset_24_hours' => 'Last 24 Hours', + 'stats_preset_7_days' => 'Last 7 Days', + 'stats_preset_30_days' => 'Last 30 Days', + 'stats_preset_90_days' => 'Last 90 Days', + 'stats_preset_custom' => 'Custom Range', + 'stats_breakdown_cities' => 'Top Cities', + 'stats_no_city_data' => 'No city data.', + 'stats_breakdown_utm_source' => 'Campaign Sources', + 'stats_breakdown_utm_medium' => 'Campaign Mediums', + 'stats_breakdown_utm_campaign' => 'Campaign Names', + 'stats_no_utm_data' => 'No UTM data recorded.', ]; diff --git a/resources/lang/pl/default.php b/resources/lang/pl/default.php index 79ed21b..9e66808 100644 --- a/resources/lang/pl/default.php +++ b/resources/lang/pl/default.php @@ -212,4 +212,20 @@ return [ 'form_section_tracking' => 'Śledzenie wizyt', 'form_section_tracked_fields' => 'Śledzone pola', 'form_section_analytics' => 'Zewnętrzne systemy analityczne', + + // New Dashboard Analytics Keys + 'stats_card_top_source' => 'Główne źródło UTM', + 'stats_card_top_country' => 'Główny kraj', + 'stats_filter_date_range' => 'Zakres dat', + 'stats_preset_24_hours' => 'Ostatnie 24 godziny', + 'stats_preset_7_days' => 'Ostatnie 7 dni', + 'stats_preset_30_days' => 'Ostatnie 30 dni', + 'stats_preset_90_days' => 'Ostatnie 90 dni', + 'stats_preset_custom' => 'Własny zakres', + 'stats_breakdown_cities' => 'Najpopularniejsze miasta', + 'stats_no_city_data' => 'Brak danych o miastach.', + 'stats_breakdown_utm_source' => 'Źródła kampanii', + 'stats_breakdown_utm_medium' => 'Media kampanii', + 'stats_breakdown_utm_campaign' => 'Nazwy kampanii', + 'stats_no_utm_data' => 'Brak danych o parametrach UTM.', ]; diff --git a/resources/views/stats.blade.php b/resources/views/stats.blade.php index ddf4c35..dedf8a8 100644 --- a/resources/views/stats.blade.php +++ b/resources/views/stats.blade.php @@ -16,18 +16,52 @@ -
- @livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlStatsOverview::class, ['record' => $record]) + @php + $dateFrom = $this->filterData['date_from'] ?? null; + $dateTo = $this->filterData['date_to'] ?? null; + @endphp -
-
- @livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsChart::class, ['record' => $record]) -
-
- @livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsRightBreakdown::class, ['record' => $record]) +
+
+
+

+ {{ __('filament-short-url::default.stats_filter_date_range') }} +

+
+
+ {{ $this->form }} +
+
- @livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsBottomBreakdown::class, ['record' => $record]) + @livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlStatsOverview::class, [ + 'record' => $record, + 'dateFrom' => $dateFrom, + 'dateTo' => $dateTo, + ], key('stats-overview-' . $dateFrom . '-' . $dateTo)) + +
+
+ @livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsChart::class, [ + 'record' => $record, + 'dateFrom' => $dateFrom, + 'dateTo' => $dateTo, + ], key('stats-chart-' . $dateFrom . '-' . $dateTo)) +
+
+ @livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsRightBreakdown::class, [ + 'record' => $record, + 'dateFrom' => $dateFrom, + 'dateTo' => $dateTo, + ], key('stats-right-breakdown-' . $dateFrom . '-' . $dateTo)) +
+
+ + @livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsBottomBreakdown::class, [ + 'record' => $record, + 'dateFrom' => $dateFrom, + 'dateTo' => $dateTo, + ], key('stats-bottom-breakdown-' . $dateFrom . '-' . $dateTo))
diff --git a/resources/views/widgets/visits-bottom-breakdown.blade.php b/resources/views/widgets/visits-bottom-breakdown.blade.php index 6b10a0c..3370ebc 100644 --- a/resources/views/widgets/visits-bottom-breakdown.blade.php +++ b/resources/views/widgets/visits-bottom-breakdown.blade.php @@ -27,118 +27,199 @@ @endphp -
+
+
- {{-- Devices --}} -
-
-
- + {{-- Devices --}} +
+
+
+ +
+

{{ __('filament-short-url::default.stats_breakdown_devices') }}

-

{{ __('filament-short-url::default.stats_breakdown_devices') }}

-
-
- @forelse ($visitsByDevice as $device => $count) - @php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp -
-
- -
-
-
- {{ $device }} - {{ $pct }}% +
+ @forelse ($visitsByDevice as $device => $count) + @php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp +
+
+
-
-
+
+
+ {{ $device }} + {{ $pct }}% +
+
+
+
-
- @empty -

{{ __('filament-short-url::default.stats_no_device_data') }}

- @endforelse -
-
- - {{-- Browsers --}} -
-
-
- + @empty +

{{ __('filament-short-url::default.stats_no_device_data') }}

+ @endforelse
-

{{ __('filament-short-url::default.stats_breakdown_browsers') }}

-
- @forelse ($visitsByBrowser as $browser => $count) - @php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp -
-
- - {{ $browser }} -
- - {{ number_format($count) }} ({{ $pct }}%) - -
- @empty -

{{ __('filament-short-url::default.stats_no_browser_data') }}

- @endforelse -
-
- {{-- Operating Systems --}} -
-
-
- + {{-- 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 +
+
+ + @if ($referer === 'Direct') + {{ $referer }} + @else + + {{ $referer }} + + @endif +
+ + {{ 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();