Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69f6cef31b | ||
|
|
2ab85dd264 | ||
|
|
f518d4dae9 | ||
|
|
bdbe9b2ffd | ||
|
|
c842096a22 | ||
|
|
7035e5b84b | ||
|
|
3b97ad3d00 | ||
|
|
a5f5afa198 | ||
|
|
429896d8e3 | ||
|
|
985aa43155 | ||
|
|
cd9a4a1620 |
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Bartek Janczak
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
183
README.md
183
README.md
@@ -1,4 +1,17 @@
|
||||
# Filament Short URL
|
||||
<p align="center">
|
||||
<img src="https://cdn3d.iconscout.com/3d/premium/thumb/url-3d-icon-png-download-9292351.png" width="180" alt="Filament Short URL Logo">
|
||||
</p>
|
||||
|
||||
<h1 align="center">Filament Short URL</h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://packagist.org/packages/janczakb/filament-short-url"><img src="https://img.shields.io/packagist/v/janczakb/filament-short-url.svg?style=flat-square" alt="Latest Version"></a>
|
||||
<a href="https://github.com/janczakb/filament-short-url/blob/main/LICENSE"><img src="https://img.shields.io/github/license/janczakb/filament-short-url.svg?style=flat-square" alt="License"></a>
|
||||
<a href="https://packagist.org/packages/janczakb/filament-short-url"><img src="https://img.shields.io/packagist/dt/janczakb/filament-short-url.svg?style=flat-square" alt="Total Downloads"></a>
|
||||
<a href="https://github.com/janczakb/filament-short-url/stargazers"><img src="https://img.shields.io/github/stars/janczakb/filament-short-url.svg?style=flat-square" alt="GitHub Stars"></a>
|
||||
<a href="https://github.com/janczakb/filament-short-url/issues"><img src="https://img.shields.io/github/issues/janczakb/filament-short-url.svg?style=flat-square" alt="GitHub Issues"></a>
|
||||
<a href="https://github.com/janczakb/filament-short-url/actions"><img src="https://img.shields.io/badge/tests-passing-success.svg?style=flat-square" alt="Tests"></a>
|
||||
</p>
|
||||
|
||||
A professional, high-performance **Short URL Manager** plugin for [Filament v5](https://filamentphp.com). Built from scratch with cutting-edge practices, proxy resistance, offline Geo-IP engines, and zero external shortening API dependencies.
|
||||
|
||||
@@ -6,17 +19,17 @@ A professional, high-performance **Short URL Manager** plugin for [Filament v5](
|
||||
|
||||
## Features
|
||||
|
||||
- 🔗 **Short URL Generation** — custom or auto-generated collision-free base62 keys.
|
||||
- 🌍 **Multiple Geo-IP Drivers** — offline detection using local MaxMind databases, edge-provided CDN headers (Cloudflare, CloudFront), or fallback API integration.
|
||||
- 📈 **Real-Time Statistics Dashboard** — sortable log of visits with cached aggregate performance metrics (browsers, devices, OS, referers, country maps, timeline charts).
|
||||
- 🎨 **QR Code Designer** — live design canvas (sizes, dot styles, gradient configurations, and background transparency triggers) with instant SVG download.
|
||||
- ⚡ **Ultra-Fast Redirects** — redirects resolve in milliseconds; analytical tasks and GA4 payloads are processed asynchronously via Laravel Queue jobs.
|
||||
- 🎯 **Google Analytics 4 Measurement Protocol** — native server-side event tracking, bypassing browser-side AdBlockers completely.
|
||||
- ⚙️ **Reactive UTM Campaign Builder** — double-way synchronized UTM builder panel inside the Filament form, syncing seamlessly with destination URLs in real-time.
|
||||
- 🔒 **Single-Use & Expirable Links** — automatically deactivates links after a single visit or at a specified date/time.
|
||||
- ➡️ **Query Parameter Forwarding** — dynamically appends incoming client query parameters (e.g. ad clicks, discount codes) to the final destination URL.
|
||||
- 🛠️ **Dedicated Settings Interface** — full administrator control panel inside Filament to manage routing, Geo-IP, and GA4 credentials without editing `.env` files.
|
||||
- 💻 **Fluent Developer Builder** — static Model builder patterns and trace tagging support.
|
||||
- 🔗 **Short URL Generation** — Create custom links or let the system auto-generate collision-free Base62 keys.
|
||||
- 🌍 **Multiple Geo-IP Drivers** — High-speed offline detection using local MaxMind databases, edge-provided CDN headers (Cloudflare, CloudFront, generic), or fallback API integration.
|
||||
- 📈 **Real-Time Statistics Dashboard** — Track visits in real-time with cached aggregate metrics (countries, devices, browsers, operating systems, referrers, traffic charts, and maps).
|
||||
- 🎨 **SVG QR Code Designer** — Built-in interactive design canvas in Filament to customize dot styles, margins, gradient coloring, and background transparency with instant SVG download.
|
||||
- ⚡ **Ultra-Fast Redirects** — Redirections resolve in milliseconds. Analytical tasks, event dispatching, and GA4 payloads are processed asynchronously via Laravel Queue jobs.
|
||||
- 🎯 **Google Analytics 4 server-side tracking** — Native integration with the GA4 Measurement Protocol to bypass client-side AdBlockers completely.
|
||||
- ⚙️ **Dual-way UTM Campaign Builder** — Built-in form builder synchronizes UTM parameters with your destination URLs in real-time (two-way binding).
|
||||
- 🔒 **Link Rules & Expiry** — Disable links manually, set expiration dates, or activate single-use links that deactivate automatically after the first click.
|
||||
- ➡️ **Query Parameter Forwarding** — Dynamically forward client query parameters (e.g. ad tokens, discount codes) to the destination URL.
|
||||
- 🛠️ **Dedicated Settings GUI** — Manage global configuration (routing, Geo-IP, GA4, cache) directly inside the Filament panel without modifying files or `.env` files.
|
||||
- 💻 **Fluent Developer Builder** — Native model query builder pattern and robust programmatic generation APIs.
|
||||
|
||||
---
|
||||
|
||||
@@ -33,7 +46,7 @@ A professional, high-performance **Short URL Manager** plugin for [Filament v5](
|
||||
Install the package via Composer:
|
||||
|
||||
```bash
|
||||
composer require bjanczak/filament-short-url
|
||||
composer require janczakb/filament-short-url
|
||||
```
|
||||
|
||||
Publish and run the database migrations:
|
||||
@@ -47,27 +60,27 @@ php artisan migrate
|
||||
|
||||
## Publishing Package Assets
|
||||
|
||||
The package is built with standard publishing tags to let developers easily customize and override the default assets within the host application directory:
|
||||
Customize and override views, translations, or configuration files by publishing the package assets:
|
||||
|
||||
### 1. Publish Config File
|
||||
Copies the default config file to `config/filament-short-url.php` for code-level customization:
|
||||
Copies the default config file to `config/filament-short-url.php`:
|
||||
```bash
|
||||
php artisan vendor:publish --tag=filament-short-url-config
|
||||
```
|
||||
|
||||
### 2. Publish Translation Files
|
||||
Copies localization files to `lang/vendor/filament-short-url/` so you can modify or add new languages (Polish and English included by default):
|
||||
Copies localization files to `lang/vendor/filament-short-url/` (English and Polish included by default):
|
||||
```bash
|
||||
php artisan vendor:publish --tag=filament-short-url-translations
|
||||
```
|
||||
|
||||
### 3. Publish Blade Views & Templates
|
||||
Copies the dashboard components, charts, and QR designer templates to `resources/views/vendor/filament-short-url/` to completely override the styling:
|
||||
Copies the dashboard components, charts, and QR designer templates to `resources/views/vendor/filament-short-url/`:
|
||||
```bash
|
||||
php artisan vendor:publish --tag=filament-short-url-views
|
||||
```
|
||||
|
||||
### 4. Publish Everything at Once
|
||||
### 4. Publish All Assets
|
||||
```bash
|
||||
php artisan vendor:publish --provider="Bjanczak\FilamentShortUrl\FilamentShortUrlServiceProvider"
|
||||
```
|
||||
@@ -86,28 +99,100 @@ public function panel(Panel $panel): Panel
|
||||
return $panel
|
||||
->plugins([
|
||||
FilamentShortUrlPlugin::make()
|
||||
->navigationGroup('Tools') // optional
|
||||
->navigationSort(50), // optional
|
||||
->navigationGroup('Marketing') // optional — sidebar group name
|
||||
->navigationLabel('Short Links') // optional — override menu item name
|
||||
->navigationIcon('heroicon-o-link') // optional — override menu icon
|
||||
->navigationSort(50), // optional — sort order in sidebar
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
### Navigation Configuration Options
|
||||
All fluent methods on the plugin are optional. If not called, the plugin falls back to defaults or translation files:
|
||||
|
||||
| Method | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `navigationGroup(string)` | `null` | Groups the resource menu item under a sidebar section. |
|
||||
| `navigationLabel(string)` | `'Short URLs'` | Overrides the menu item display name. |
|
||||
| `navigationIcon(string)` | `heroicon-o-link` | Overrides the Heroicon used in the sidebar. |
|
||||
| `navigationSort(int)` | `50` | Controls the sort order within the navigation list. |
|
||||
|
||||
---
|
||||
|
||||
## Usage in Code
|
||||
## Global Settings GUI
|
||||
|
||||
### 1. Programmatic Creation via Fluent Builder
|
||||
You can easily create, configure, and append tracking tags to short URLs programmatically using the fluent builder:
|
||||
The package comes with a built-in admin settings dashboard. You can access it by clicking the **Settings** action button on the top-right header of the Short URLs resource.
|
||||
|
||||
Settings are stored dynamically in `storage/app/filament-short-url-settings.json` and immediately override config defaults.
|
||||
|
||||
The settings panel allows you to configure:
|
||||
|
||||
### 1. General Routing & Queueing
|
||||
* **Route Prefix**: The slug prepended to short URLs (e.g. `s` for `/s/{key}`).
|
||||
* **Default Redirect Status**: Choose `302 (Found / Temporary)` or `301 (Moved Permanently)`.
|
||||
* *Note: `302` is highly recommended for analytics accuracy because browsers cache `301` redirects, skipping subsequent logs.*
|
||||
* **Key Length**: Default character count (base62) for auto-generated keys (default: `6`).
|
||||
* **Queue Connection**: Define the Laravel queue connection (e.g. `redis`, `database`, `sync`) used for processing visit analytics asynchronously.
|
||||
|
||||
### 2. Geo-IP Country Detection
|
||||
Toggle country tracking and select from three drivers:
|
||||
* **Headers** (Edge Resolution): Automatically detects client country using standard edge headers (e.g. Cloudflare's `CF-IPCountry`, AWS CloudFront's `CloudFront-Viewer-Country`, or generic proxies).
|
||||
* **MaxMind** (Offline Resolution): Reads from a local GeoIP2 database (such as the free GeoLite2-Country database).
|
||||
* **IP-API** (Online Fallback): Makes an external API call to `ip-api.com` with configurable timeout.
|
||||
|
||||
### 3. Google Analytics 4 (GA4) Integration
|
||||
Sends server-side `short_url_visit` hits using the **GA4 Measurement Protocol API**. This bypasses browser-side AdBlockers entirely.
|
||||
* **GA4 API Secret**: Create this secret in Google Analytics under `Admin -> Data Streams -> Measurement Protocol API secrets`.
|
||||
* **Firebase App ID / Measurement ID**: The target analytics stream identifier.
|
||||
|
||||
### 4. Counter Buffering (Write-back Caching)
|
||||
For extremely high-traffic applications, direct database writes for click counts can cause row-locking bottlenecks.
|
||||
* **Buffer Click Counts**: Toggling this option buffers total and unique visit count increments in the application cache.
|
||||
* **Cron Synchronization**: When enabled, you must schedule the synchronization command to run periodically (e.g., every minute) to flush counts to the database:
|
||||
```bash
|
||||
php artisan short-url:sync-counters
|
||||
```
|
||||
In your scheduler (`routes/console.php` or `app/Console/Kernel.php`):
|
||||
```php
|
||||
$schedule->command('short-url:sync-counters')->everyMinute();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration Reference (.env)
|
||||
|
||||
You can also pre-configure all parameters via your `.env` file:
|
||||
|
||||
| Environment Variable | Config Path | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `SHORT_URL_PREFIX` | `route_prefix` | `'s'` | URL prefix for short URL redirects. |
|
||||
| `SHORT_URL_GEO_IP` | `geo_ip.enabled` | `true` | Globally enable/disable Geo-IP tracking. |
|
||||
| `SHORT_URL_GEO_IP_DRIVER` | `geo_ip.driver` | `'headers'` | Geo-IP resolver driver (`headers`, `maxmind`, `ip-api`). |
|
||||
| `SHORT_URL_MAXMIND_DB` | `geo_ip.maxmind.database_path` | `database_path('geoip/GeoLite2-Country.mmdb')` | Path to local MaxMind db. |
|
||||
| `SHORT_URL_STATS_CACHE_TTL` | `geo_ip.stats_cache_ttl` | `300` | Caching TTL in seconds for dashboard charts. |
|
||||
| `SHORT_URL_QUEUE` | `queue_connection` | `'sync'` | Queue connection for recording visits. |
|
||||
| `SHORT_URL_CACHE_TTL` | `cache_ttl` | `3600` | Redirection model caching TTL (set to `0` to disable). |
|
||||
| `GA4_API_SECRET` | `ga4.api_secret` | `null` | Google Analytics 4 Measurement Protocol API Secret. |
|
||||
| `FIREBASE_APP_ID` | `ga4.firebase_app_id` | `null` | Google Analytics 4 Firebase App ID (or uses Measurement ID). |
|
||||
| `SHORT_URL_COUNTER_BUFFERING` | `counter_buffering.enabled` | `false` | Buffer click counts in cache (flushed via console command). |
|
||||
|
||||
---
|
||||
|
||||
## Programmatic Usage (Fluent Builder)
|
||||
|
||||
You can programmatically generate, customize, and trace short URLs anywhere in your code using the static `destination` builder on the `ShortUrl` model:
|
||||
|
||||
```php
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
|
||||
$shortUrl = ShortUrl::destination('https://example.com/very/long/url')
|
||||
->urlKey('promo2026') // optional, auto-generated if empty
|
||||
->notes('Spring campaign promo')
|
||||
->singleUse() // deactivates after first visit
|
||||
->forwardQueryParams() // forward incoming visitor query strings
|
||||
->withTracing([ // dynamically filters and appends UTM parameters
|
||||
$shortUrl = ShortUrl::destination('https://example.com/products/promo')
|
||||
->urlKey('promo2026') // Optional, auto-generated base62 key if omitted
|
||||
->notes('Spring social promo')
|
||||
->singleUse() // Deactivates the link automatically after the first visit
|
||||
->forwardQueryParams() // Appends incoming URL query strings to destination
|
||||
->expiresAt(now()->addDays(7)) // Automatic link expiration
|
||||
->trackVisits(true) // Enable or disable click logs
|
||||
->withTracing([ // Dynamically filter and append UTM parameters
|
||||
'utm_source' => 'linkedin',
|
||||
'utm_medium' => 'social',
|
||||
'utm_campaign' => 'spring_sale',
|
||||
@@ -115,11 +200,36 @@ $shortUrl = ShortUrl::destination('https://example.com/very/long/url')
|
||||
])
|
||||
->create();
|
||||
|
||||
// Output the public short URL string
|
||||
echo $shortUrl->getShortUrl(); // https://yourapp.com/s/promo2026
|
||||
// Get the full URL string
|
||||
echo $shortUrl->getShortUrl(); // https://yourdomain.com/s/promo2026
|
||||
```
|
||||
|
||||
### 2. Standard Service Injection
|
||||
### Fluent Builder API Reference
|
||||
|
||||
| Method | Argument Type | Description |
|
||||
|--------|---------------|-------------|
|
||||
| `urlKey()` | `string` | Sets a custom redirection key (slug). |
|
||||
| `notes()` | `string` | Appends internal notes for administrators. |
|
||||
| `singleUse()` | `bool` (default `true`) | Makes the short URL expire immediately after the first successful click. |
|
||||
| `forwardQueryParams()` | `bool` (default `true`) | Forwards client-side incoming parameters (e.g. `?gclid=xxx`) to the final target. |
|
||||
| `expiresAt()` | `DateTimeInterface\|Carbon\|null` | Automatically sets a timestamp after which the URL returns a `410 Gone` error. |
|
||||
| `trackVisits()` | `bool` (default `true`) | Toggles statistical logging for this link. |
|
||||
| `withTracing()` | `array` | Appends non-empty tracking parameters (like UTM codes) directly to the target URL. |
|
||||
| `create()` | `void` | Persists the model to the database and returns the `ShortUrl` instance. |
|
||||
|
||||
---
|
||||
|
||||
## Model Helpers & Service
|
||||
|
||||
### ShortUrl Model Methods
|
||||
* `isActive(): bool` — Checks if the short URL is enabled and within its activation/expiration timestamps.
|
||||
* `isExpired(): bool` — Checks if the URL has passed its expiration date.
|
||||
* `getShortUrl(): string` — Resolves the complete URL string.
|
||||
* `incrementVisits(bool $isUnique = false): void` — Atomically increments visit counts.
|
||||
* `getCachedStats(): array` — Returns cached key metrics for dashboard charts.
|
||||
|
||||
### Injecting the Service
|
||||
For standard creations, you can inject `ShortUrlService`:
|
||||
```php
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
|
||||
|
||||
@@ -135,7 +245,7 @@ $shortUrl = $service->create([
|
||||
|
||||
## Events
|
||||
|
||||
You can listen to short URL visits dynamically in your application (e.g. for real-time alerts or external webhooks):
|
||||
The package fires the `ShortUrlVisited` event inside the queue job. You can listen to it to trigger custom logic, Slack notifications, or syncing external CRMs:
|
||||
|
||||
```php
|
||||
use Bjanczak\FilamentShortUrl\Events\ShortUrlVisited;
|
||||
@@ -143,12 +253,17 @@ use Illuminate\Support\Facades\Event;
|
||||
|
||||
Event::listen(ShortUrlVisited::class, function (ShortUrlVisited $event) {
|
||||
$event->shortUrl; // The ShortUrl model instance
|
||||
$event->visit; // The ShortUrlVisit model instance (contains resolved client IP, browser, OS, country, etc.)
|
||||
$event->visit; // The ShortUrlVisit model (contains resolved IP, country, device, browser, OS, referrer)
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Visitor Filtering (Bot Detection)
|
||||
Visits from scrapers, search bots, and web crawlers are automatically ignored to keep stats clean. The system matches user agents against a custom list (including Googlebot, Bingbot, Applebot, Facebook, Twitter, and generic curl/http request clients).
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
@@ -132,4 +132,42 @@ return [
|
||||
'gradient_type' => 'linear',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Counter Buffering (Write-back Caching)
|
||||
|--------------------------------------------------------------------------
|
||||
| For high-traffic applications, direct database writes on every visit
|
||||
| can cause locks. Enable this to buffer total/unique visit counts in
|
||||
| cache, then sync them to the database periodically via a scheduled task.
|
||||
*/
|
||||
'counter_buffering' => [
|
||||
'enabled' => (bool) env('SHORT_URL_COUNTER_BUFFERING', false),
|
||||
'cache_key_prefix' => 'filament-short-url:buffer:',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Trust CDN/Proxy Headers
|
||||
|--------------------------------------------------------------------------
|
||||
| If your application sits behind a CDN (like Cloudflare, AWS CloudFront)
|
||||
| or a reverse proxy, set this to true to parse real visitor IP addresses
|
||||
| and country codes from proxy headers. Only enable this if you are
|
||||
| actually behind a proxy to prevent client IP spoofing!
|
||||
|
|
||||
*/
|
||||
'trust_cdn_headers' => (bool) env('SHORT_URL_TRUST_CDN_HEADERS', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Redirect Route Middleware
|
||||
|--------------------------------------------------------------------------
|
||||
| The middleware list applied to the short URL redirect route.
|
||||
| By default, standard web middleware and rate limiting are applied.
|
||||
|
|
||||
*/
|
||||
'middleware' => [
|
||||
'web',
|
||||
'throttle:120,1',
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?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('short_url_visits', function (Blueprint $table): void {
|
||||
$table->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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -180,4 +180,51 @@ return [
|
||||
'settings_ga4_api_secret_helper' => 'Generate this in GA4 → Admin → Data Streams → your stream → Measurement Protocol API secrets.',
|
||||
'settings_ga4_firebase_app_id' => 'Firebase App ID (optional)',
|
||||
'settings_ga4_firebase_app_id_helper' => 'Required only if tracking a Firebase / app stream. Leave empty for standard web GA4 streams.',
|
||||
|
||||
'settings_section_buffering' => 'Visit Counters Buffering',
|
||||
'settings_buffering_enabled' => 'Buffer Visit Counts in Cache',
|
||||
'settings_buffering_helper' => 'When enabled, visit count increments are temporarily buffered in the application cache and must be flushed periodically to the database via "php artisan short-url:sync-counters". This prevents row-locking issues and performance degradation under high-traffic spikes.',
|
||||
|
||||
// CDN Trust Settings
|
||||
'settings_trust_cdn_headers' => 'Trust CDN & Proxy Headers',
|
||||
'settings_trust_cdn_headers_helper' => 'Enable this if your app sits behind a CDN (like Cloudflare, AWS CloudFront) or a reverse proxy. This allows extracting the real client IP and country code from CDN headers. Warning: only enable this if you are actually behind a proxy, otherwise client IP addresses can be spoofed!',
|
||||
|
||||
// Stats View & Export Localization
|
||||
'stats_filter_visited_from' => 'Visited From',
|
||||
'stats_filter_visited_until' => 'Visited Until',
|
||||
'stats_action_export' => 'Export CSV',
|
||||
'stats_csv_time' => 'Time',
|
||||
'stats_csv_ip' => 'IP Address',
|
||||
'stats_csv_country' => 'Country',
|
||||
'stats_csv_device' => 'Device',
|
||||
'stats_csv_browser' => 'Browser',
|
||||
'stats_csv_os' => 'OS',
|
||||
'stats_csv_referer' => 'Referer',
|
||||
|
||||
// Bulk Actions Localization
|
||||
'action_enable_selected' => 'Enable selected',
|
||||
'action_disable_selected' => 'Disable selected',
|
||||
|
||||
// Form Sections Localization
|
||||
'form_section_options' => 'Options',
|
||||
'form_section_notes' => 'Internal Notes',
|
||||
'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.',
|
||||
];
|
||||
|
||||
@@ -181,4 +181,51 @@ return [
|
||||
'settings_ga4_api_secret_helper' => 'Wygeneruj w GA4 → Admin → Strumienie danych → twój strumień → Klucze API Measurement Protocol.',
|
||||
'settings_ga4_firebase_app_id' => 'Firebase App ID (opcjonalne)',
|
||||
'settings_ga4_firebase_app_id_helper' => 'Wymagane tylko przy śledzeniu strumienia Firebase/aplikacji. Zostaw puste dla standardowych strumieni GA4.',
|
||||
|
||||
'settings_section_buffering' => 'Buforowanie liczników wizyt',
|
||||
'settings_buffering_enabled' => 'Buforuj kliknięcia w pamięci podręcznej (Cache)',
|
||||
'settings_buffering_helper' => 'Po włączeniu, zliczenia kliknięć będą buforowane w pamięci podręcznej aplikacji i muszą być okresowo synchronizowane z bazą danych za pomocą komendy "php artisan short-url:sync-counters". Zapobiega to blokowaniu wierszy bazy danych i spadkom wydajności przy masowym ruchu.',
|
||||
|
||||
// CDN Trust Settings
|
||||
'settings_trust_cdn_headers' => 'Ufaj nagłówkom CDN i proxy',
|
||||
'settings_trust_cdn_headers_helper' => 'Włącz tę opcję, jeśli Twoja aplikacja działa za CDN (np. Cloudflare, AWS CloudFront) lub reverse proxy. Pozwala to na pobieranie prawdziwego adresu IP klienta i kodu kraju z nagłówków CDN. Uwaga: włączaj tylko wtedy, gdy faktycznie korzystasz z proxy, w przeciwnym razie adresy IP mogą być sfałszowane!',
|
||||
|
||||
// Stats View & Export Localization
|
||||
'stats_filter_visited_from' => 'Odwiedzono od',
|
||||
'stats_filter_visited_until' => 'Odwiedzono do',
|
||||
'stats_action_export' => 'Eksportuj CSV',
|
||||
'stats_csv_time' => 'Czas',
|
||||
'stats_csv_ip' => 'Adres IP',
|
||||
'stats_csv_country' => 'Kraj',
|
||||
'stats_csv_device' => 'Urządzenie',
|
||||
'stats_csv_browser' => 'Przeglądarka',
|
||||
'stats_csv_os' => 'System operacyjny',
|
||||
'stats_csv_referer' => 'Źródło (Referer)',
|
||||
|
||||
// Bulk Actions Localization
|
||||
'action_enable_selected' => 'Włącz zaznaczone',
|
||||
'action_disable_selected' => 'Wyłącz zaznaczone',
|
||||
|
||||
// Form Sections Localization
|
||||
'form_section_options' => 'Opcje',
|
||||
'form_section_notes' => 'Wewnętrzne notatki',
|
||||
'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.',
|
||||
];
|
||||
|
||||
@@ -108,6 +108,15 @@
|
||||
background-color:#f3f4f6;
|
||||
}
|
||||
@keyframes qr-spin { to { transform: rotate(360deg); } }
|
||||
.qr-margin-top {
|
||||
margin-top: 12px !important;
|
||||
}
|
||||
.qr-space-y > div {
|
||||
margin-top: 12px !important;
|
||||
}
|
||||
.qr-space-y > div:first-child {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
{{-- Preload QR library as soon as the tab renders --}}
|
||||
@@ -245,7 +254,7 @@
|
||||
|
||||
{{-- Size & Margin --}}
|
||||
<div class="qr-section">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="grid grid-cols-2 gap-3" style="display:grid;grid-template-columns:repeat(2, minmax(0, 1fr));gap:12px">
|
||||
<div>
|
||||
<span class="qr-label">{{ __('filament-short-url::default.qr_label_size') }}</span>
|
||||
<input type="number" x-model.number="size" min="100" max="1000" step="10" class="qr-input-num" />
|
||||
@@ -279,7 +288,7 @@
|
||||
<span class="qr-label">{{ __('filament-short-url::default.qr_label_foreground_color') }}</span>
|
||||
|
||||
{{-- Mode radio --}}
|
||||
<div class="flex gap-2">
|
||||
<div class="flex gap-2" style="display:flex;gap:8px">
|
||||
<button type="button"
|
||||
:class="colorMode === 'solid' ? 'active' : ''"
|
||||
class="qr-radio-option flex-1 justify-center text-center"
|
||||
@@ -297,9 +306,9 @@
|
||||
</div>
|
||||
|
||||
{{-- Single color picker --}}
|
||||
<div x-show="colorMode === 'solid'" x-transition style="display:block" class="mt-3">
|
||||
<div x-show="colorMode === 'solid'" x-transition style="display:block" class="mt-3 qr-margin-top">
|
||||
<span class="qr-label">{{ __('filament-short-url::default.qr_label_color') }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-2" style="display:flex;align-items:center;gap:8px">
|
||||
<div class="qr-color-swatch qr-color-picker" :style="'background:'+fgColor">
|
||||
<input type="color" x-model="fgColor" />
|
||||
</div>
|
||||
@@ -311,11 +320,11 @@
|
||||
</div>
|
||||
|
||||
{{-- Gradient pickers --}}
|
||||
<div x-show="colorMode === 'gradient'" x-transition style="display:none" class="mt-3 space-y-3">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div x-show="colorMode === 'gradient'" x-transition style="display:none" class="mt-3 space-y-3 qr-space-y qr-margin-top">
|
||||
<div class="grid grid-cols-2 gap-2" style="display:grid;grid-template-columns:repeat(2, minmax(0, 1fr));gap:8px">
|
||||
<div>
|
||||
<span class="qr-label">{{ __('filament-short-url::default.qr_label_from') }}</span>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="flex items-center gap-1.5" style="display:flex;align-items:center;gap:6px">
|
||||
<div class="qr-color-swatch qr-color-picker" :style="'background:'+gradientFrom">
|
||||
<input type="color" x-model="gradientFrom" />
|
||||
</div>
|
||||
@@ -327,7 +336,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<span class="qr-label">{{ __('filament-short-url::default.qr_label_to') }}</span>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="flex items-center gap-1.5" style="display:flex;align-items:center;gap:6px">
|
||||
<div class="qr-color-swatch qr-color-picker" :style="'background:'+gradientTo">
|
||||
<input type="color" x-model="gradientTo" />
|
||||
</div>
|
||||
@@ -351,7 +360,7 @@
|
||||
{{-- Background --}}
|
||||
<div class="qr-section">
|
||||
<span class="qr-label">{{ __('filament-short-url::default.qr_label_background') }}</span>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center justify-between" style="display:flex;align-items:center;justify-content:space-between">
|
||||
<span style="font-size:13px;font-weight:500;color:#374151" class="dark:text-gray-300">{{ __('filament-short-url::default.qr_label_transparent') }}</span>
|
||||
<button type="button"
|
||||
:class="bgTransparent ? 'on' : 'off'"
|
||||
@@ -360,9 +369,9 @@
|
||||
<span class="qr-toggle-thumb"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div x-show="!bgTransparent" x-transition style="display:block" class="mt-3">
|
||||
<div x-show="!bgTransparent" x-transition style="display:block;margin-top:12px" class="mt-3 qr-margin-top">
|
||||
<span class="qr-label">{{ __('filament-short-url::default.qr_label_color') }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-2" style="display:flex;align-items:center;gap:8px">
|
||||
<div class="qr-color-swatch qr-color-picker" :style="'background:'+bgColor">
|
||||
<input type="color" x-model="bgColor" />
|
||||
</div>
|
||||
@@ -372,8 +381,8 @@
|
||||
maxlength="7" placeholder="#ffffff" />
|
||||
</div>
|
||||
</div>
|
||||
<div x-show="bgTransparent" x-transition style="display:none" class="mt-3">
|
||||
<div class="qr-checker flex h-9 w-full items-center justify-center rounded-lg border border-dashed border-gray-300" style="font-size:11px;color:#9ca3af;font-weight:600">
|
||||
<div x-show="bgTransparent" x-transition style="display:none;margin-top:12px" class="mt-3 qr-margin-top">
|
||||
<div class="qr-checker flex h-9 w-full items-center justify-center rounded-lg border border-dashed border-gray-300" style="display:flex;align-items:center;justify-content:center;font-size:11px;color:#9ca3af;font-weight:600">
|
||||
TRANSPARENT
|
||||
</div>
|
||||
</div>
|
||||
@@ -381,7 +390,7 @@
|
||||
|
||||
{{-- Eye Config --}}
|
||||
<div class="qr-section">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center justify-between" style="display:flex;align-items:center;justify-content:space-between">
|
||||
<span class="qr-label" style="margin-bottom:0">{{ __('filament-short-url::default.qr_label_eye_config') }}</span>
|
||||
<button type="button"
|
||||
:class="eyeConfigEnabled ? 'on' : 'off'"
|
||||
@@ -390,7 +399,7 @@
|
||||
<span class="qr-toggle-thumb"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div x-show="eyeConfigEnabled" x-transition style="display:none" class="mt-3 space-y-3">
|
||||
<div x-show="eyeConfigEnabled" x-transition style="display:none" class="mt-3 space-y-3 qr-space-y qr-margin-top">
|
||||
<div>
|
||||
<span class="qr-label">{{ __('filament-short-url::default.qr_label_eye_square_style') }}</span>
|
||||
<select x-model="eyeSquareStyle" class="qr-select">
|
||||
@@ -408,7 +417,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<span class="qr-label">{{ __('filament-short-url::default.qr_label_eye_color') }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-2" style="display:flex;align-items:center;gap:8px">
|
||||
<div class="qr-color-swatch qr-color-picker" :style="'background:'+eyeColor">
|
||||
<input type="color" x-model="eyeColor" />
|
||||
</div>
|
||||
@@ -427,9 +436,9 @@
|
||||
<div style="display:flex;flex-direction:column;min-width:0;height:100%">
|
||||
|
||||
{{-- Top bar --}}
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<div class="mb-4 flex items-center justify-between" style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
|
||||
<span style="font-size:13px;font-weight:600;color:#9ca3af">{{ __('filament-short-url::default.qr_label_preview') }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-2" style="display:flex;align-items:center;gap:8px">
|
||||
<button type="button" x-on:click="download('png')" class="qr-dl-btn">
|
||||
<svg style="width:13px;height:13px" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"/>
|
||||
|
||||
@@ -16,18 +16,52 @@
|
||||
</x-filament::tabs.item>
|
||||
</x-filament::tabs>
|
||||
|
||||
<div class="space-y-6">
|
||||
@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
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<div class="lg:col-span-2">
|
||||
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsChart::class, ['record' => $record])
|
||||
</div>
|
||||
<div>
|
||||
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsRightBreakdown::class, ['record' => $record])
|
||||
<div class="space-y-6">
|
||||
<div class="p-6 bg-white rounded-xl border border-gray-200 dark:bg-gray-900 dark:border-gray-800">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ __('filament-short-url::default.stats_filter_date_range') }}
|
||||
</h3>
|
||||
<div class="flex-1 max-w-lg">
|
||||
<form class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
{{ $this->form }}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@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))
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<div class="lg:col-span-2">
|
||||
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsChart::class, [
|
||||
'record' => $record,
|
||||
'dateFrom' => $dateFrom,
|
||||
'dateTo' => $dateTo,
|
||||
], key('stats-chart-' . $dateFrom . '-' . $dateTo))
|
||||
</div>
|
||||
<div>
|
||||
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsRightBreakdown::class, [
|
||||
'record' => $record,
|
||||
'dateFrom' => $dateFrom,
|
||||
'dateTo' => $dateTo,
|
||||
], key('stats-right-breakdown-' . $dateFrom . '-' . $dateTo))
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsBottomBreakdown::class, [
|
||||
'record' => $record,
|
||||
'dateFrom' => $dateFrom,
|
||||
'dateTo' => $dateTo,
|
||||
], key('stats-bottom-breakdown-' . $dateFrom . '-' . $dateTo))
|
||||
</div>
|
||||
</x-filament-panels::page>
|
||||
|
||||
@@ -27,118 +27,199 @@
|
||||
@endphp
|
||||
|
||||
<x-filament-widgets::widget>
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<div class="space-y-6">
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
|
||||
{{-- Devices --}}
|
||||
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
|
||||
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-50 text-emerald-500 dark:bg-emerald-950/50 dark:text-emerald-400">
|
||||
<x-filament::icon icon="heroicon-o-device-phone-mobile" class="h-5 w-5" />
|
||||
{{-- Devices --}}
|
||||
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
|
||||
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-50 text-emerald-500 dark:bg-emerald-950/50 dark:text-emerald-400">
|
||||
<x-filament::icon icon="heroicon-o-device-phone-mobile" class="h-5 w-5" />
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_devices') }}</h3>
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_devices') }}</h3>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
@forelse ($visitsByDevice as $device => $count)
|
||||
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gray-50 text-gray-500 dark:bg-gray-800 dark:text-gray-400">
|
||||
<x-filament::icon :icon="$deviceIcons[$device] ?? 'heroicon-m-question-mark-circle'" class="h-4 w-4" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="capitalize font-medium text-gray-700 dark:text-gray-300 truncate">{{ $device }}</span>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">{{ $pct }}%</span>
|
||||
<div class="space-y-4">
|
||||
@forelse ($visitsByDevice as $device => $count)
|
||||
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gray-50 text-gray-500 dark:bg-gray-800 dark:text-gray-400">
|
||||
<x-filament::icon :icon="$deviceIcons[$device] ?? 'heroicon-m-question-mark-circle'" class="h-4 w-4" />
|
||||
</div>
|
||||
<div class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
|
||||
<div class="h-full rounded-full bg-emerald-500 transition-all duration-500" style="width: {{ $pct }}%"></div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="capitalize font-medium text-gray-700 dark:text-gray-300 truncate">{{ $device }}</span>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">{{ $pct }}%</span>
|
||||
</div>
|
||||
<div class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
|
||||
<div class="h-full rounded-full bg-emerald-500 transition-all duration-500" style="width: {{ $pct }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_device_data') }}</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Browsers --}}
|
||||
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
|
||||
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-amber-50 text-amber-500 dark:bg-amber-950/50 dark:text-amber-400">
|
||||
<x-filament::icon icon="heroicon-o-globe-asia-australia" class="h-5 w-5" />
|
||||
@empty
|
||||
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_device_data') }}</p>
|
||||
@endforelse
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_browsers') }}</h3>
|
||||
</div>
|
||||
<div class="space-y-3.5">
|
||||
@forelse ($visitsByBrowser as $browser => $count)
|
||||
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<x-filament::icon :icon="$browserIcons[$browser] ?? 'heroicon-m-globe-alt'" class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" />
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300 truncate">{{ $browser }}</span>
|
||||
</div>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0 ml-2">
|
||||
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
|
||||
</span>
|
||||
</div>
|
||||
@empty
|
||||
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_browser_data') }}</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Operating Systems --}}
|
||||
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
|
||||
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-blue-50 text-blue-500 dark:bg-blue-950/50 dark:text-blue-400">
|
||||
<x-filament::icon icon="heroicon-o-computer-desktop" class="h-5 w-5" />
|
||||
{{-- Browsers --}}
|
||||
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
|
||||
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-amber-50 text-amber-500 dark:bg-amber-950/50 dark:text-amber-400">
|
||||
<x-filament::icon icon="heroicon-o-globe-asia-australia" class="h-5 w-5" />
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_browsers') }}</h3>
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_os') }}</h3>
|
||||
</div>
|
||||
<div class="space-y-3.5">
|
||||
@forelse ($visitsByOs as $os => $count)
|
||||
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<x-filament::icon :icon="$osIcons[$os] ?? 'heroicon-m-computer-desktop'" class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" />
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300 truncate">{{ $os }}</span>
|
||||
<div class="space-y-3.5">
|
||||
@forelse ($visitsByBrowser as $browser => $count)
|
||||
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<x-filament::icon :icon="$browserIcons[$browser] ?? 'heroicon-m-globe-alt'" class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" />
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300 truncate">{{ $browser }}</span>
|
||||
</div>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0 ml-2">
|
||||
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
|
||||
</span>
|
||||
</div>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0 ml-2">
|
||||
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
|
||||
</span>
|
||||
</div>
|
||||
@empty
|
||||
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_os_data') }}</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Referers --}}
|
||||
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
|
||||
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-purple-50 text-purple-500 dark:bg-purple-950/50 dark:text-purple-400">
|
||||
<x-filament::icon icon="heroicon-o-link" class="h-5 w-5" />
|
||||
@empty
|
||||
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_browser_data') }}</p>
|
||||
@endforelse
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_referers') }}</h3>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
@forelse ($visitsByReferer as $referer => $count)
|
||||
<div class="flex items-center justify-between text-sm gap-4">
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<x-filament::icon icon="heroicon-m-link" class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" />
|
||||
<a href="{{ $referer }}" target="_blank" rel="noopener"
|
||||
class="truncate text-indigo-600 hover:underline dark:text-indigo-400 font-medium">
|
||||
{{ $referer }}
|
||||
</a>
|
||||
</div>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0">
|
||||
{{ number_format($count) }}
|
||||
</span>
|
||||
|
||||
{{-- Operating Systems --}}
|
||||
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
|
||||
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-blue-50 text-blue-500 dark:bg-blue-950/50 dark:text-blue-400">
|
||||
<x-filament::icon icon="heroicon-o-computer-desktop" class="h-5 w-5" />
|
||||
</div>
|
||||
@empty
|
||||
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_referer_data') }}</p>
|
||||
@endforelse
|
||||
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_os') }}</h3>
|
||||
</div>
|
||||
<div class="space-y-3.5">
|
||||
@forelse ($visitsByOs as $os => $count)
|
||||
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<x-filament::icon :icon="$osIcons[$os] ?? 'heroicon-m-computer-desktop'" class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" />
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300 truncate">{{ $os }}</span>
|
||||
</div>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0 ml-2">
|
||||
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
|
||||
</span>
|
||||
</div>
|
||||
@empty
|
||||
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_os_data') }}</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Referers --}}
|
||||
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
|
||||
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-purple-50 text-purple-500 dark:bg-purple-950/50 dark:text-purple-400">
|
||||
<x-filament::icon icon="heroicon-o-link" class="h-5 w-5" />
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_referers') }}</h3>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
@forelse ($visitsByReferer as $referer => $count)
|
||||
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
|
||||
<div class="flex items-center justify-between text-sm gap-4">
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<x-filament::icon icon="heroicon-m-link" class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" />
|
||||
@if ($referer === 'Direct')
|
||||
<span class="font-medium text-gray-500 dark:text-gray-400 truncate">{{ $referer }}</span>
|
||||
@else
|
||||
<a href="https://{{ $referer }}" target="_blank" rel="noopener"
|
||||
class="truncate text-indigo-600 hover:underline dark:text-indigo-400 font-medium">
|
||||
{{ $referer }}
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0 ml-2">
|
||||
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
|
||||
</span>
|
||||
</div>
|
||||
@empty
|
||||
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_referer_data') }}</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{{-- UTM Campaigns Section --}}
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
|
||||
{{-- UTM Sources --}}
|
||||
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
|
||||
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-rose-50 text-rose-500 dark:bg-rose-950/50 dark:text-rose-400">
|
||||
<x-filament::icon icon="heroicon-o-megaphone" class="h-5 w-5" />
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_utm_source') }}</h3>
|
||||
</div>
|
||||
<div class="space-y-3.5">
|
||||
@forelse ($utmSources as $source => $count)
|
||||
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300 truncate mr-2">{{ $source }}</span>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0">
|
||||
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
|
||||
</span>
|
||||
</div>
|
||||
@empty
|
||||
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_utm_data') }}</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- UTM Mediums --}}
|
||||
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
|
||||
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-teal-50 text-teal-500 dark:bg-teal-950/50 dark:text-teal-400">
|
||||
<x-filament::icon icon="heroicon-o-tag" class="h-5 w-5" />
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_utm_medium') }}</h3>
|
||||
</div>
|
||||
<div class="space-y-3.5">
|
||||
@forelse ($utmMediums as $medium => $count)
|
||||
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300 truncate mr-2">{{ $medium }}</span>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0">
|
||||
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
|
||||
</span>
|
||||
</div>
|
||||
@empty
|
||||
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_utm_data') }}</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- UTM Campaigns --}}
|
||||
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
|
||||
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-cyan-50 text-cyan-500 dark:bg-cyan-950/50 dark:text-cyan-400">
|
||||
<x-filament::icon icon="heroicon-o-flag" class="h-5 w-5" />
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_utm_campaign') }}</h3>
|
||||
</div>
|
||||
<div class="space-y-3.5">
|
||||
@forelse ($utmCampaigns as $campaign => $count)
|
||||
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300 truncate mr-2">{{ $campaign }}</span>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0">
|
||||
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
|
||||
</span>
|
||||
</div>
|
||||
@empty
|
||||
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_utm_data') }}</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</x-filament-widgets::widget>
|
||||
|
||||
@@ -27,5 +27,31 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Cities --}}
|
||||
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
|
||||
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-50 text-emerald-500 dark:bg-emerald-950/50 dark:text-emerald-400">
|
||||
<x-filament::icon icon="heroicon-o-map-pin" class="h-5 w-5" />
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_cities') }}</h3>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
@forelse ($visitsByCity as $city => $count)
|
||||
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
|
||||
<div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ $city }}</span>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span></span>
|
||||
</div>
|
||||
<div class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
|
||||
<div class="h-full rounded-full bg-emerald-500 transition-all duration-500" style="width: {{ $pct }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_city_data') }}</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</x-filament-widgets::widget>
|
||||
|
||||
@@ -9,4 +9,4 @@ Route::get(
|
||||
)
|
||||
->name('short-url.redirect')
|
||||
->where('key', '[a-zA-Z0-9_-]+')
|
||||
->middleware('throttle:120,1');
|
||||
->middleware(config('filament-short-url.middleware', ['web', 'throttle:120,1']));
|
||||
|
||||
69
src/Console/Commands/SyncBufferedCountersCommand.php
Normal file
69
src/Console/Commands/SyncBufferedCountersCommand.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Console\Commands;
|
||||
|
||||
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
|
||||
{
|
||||
/** @var string */
|
||||
protected $signature = 'short-url:sync-counters';
|
||||
|
||||
/** @var string */
|
||||
protected $description = 'Sync buffered short URL visit counters from cache to the database';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$prefix = config('filament-short-url.counter_buffering.cache_key_prefix', 'filament-short-url:buffer:');
|
||||
$dirtyKey = "{$prefix}dirty_ids";
|
||||
|
||||
// Pull the list atomically to avoid race conditions with incoming clicks
|
||||
if (Cache::getDefaultDriver() === 'redis' && class_exists(Redis::class)) {
|
||||
$tempKey = "{$dirtyKey}:temp:".time();
|
||||
try {
|
||||
Redis::rename($dirtyKey, $tempKey);
|
||||
$dirtyIds = Redis::smembers($tempKey);
|
||||
Redis::del($tempKey);
|
||||
} catch (\Throwable) {
|
||||
// If key does not exist or rename fails, fallback
|
||||
$dirtyIds = [];
|
||||
}
|
||||
} else {
|
||||
$dirtyIds = Cache::pull($dirtyKey, []);
|
||||
}
|
||||
|
||||
if (empty($dirtyIds)) {
|
||||
$this->info('No buffered counters to synchronize.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
$dirtyIds = array_unique(array_filter($dirtyIds));
|
||||
$processed = 0;
|
||||
|
||||
foreach ($dirtyIds as $id) {
|
||||
$totalKey = "{$prefix}total:{$id}";
|
||||
$uniqueKey = "{$prefix}unique:{$id}";
|
||||
|
||||
$totalDelta = (int) Cache::pull($totalKey, 0);
|
||||
$uniqueDelta = (int) Cache::pull($uniqueKey, 0);
|
||||
|
||||
if ($totalDelta > 0 || $uniqueDelta > 0) {
|
||||
// Perform a single atomic update query for this URL
|
||||
ShortUrl::where('id', $id)->update([
|
||||
'total_visits' => DB::raw("total_visits + {$totalDelta}"),
|
||||
'unique_visits' => DB::raw("unique_visits + {$uniqueDelta}"),
|
||||
]);
|
||||
$processed++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("Successfully synchronized counters for {$processed} short URLs.");
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,15 @@ class ShortUrlResource extends Resource
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
try {
|
||||
$label = FilamentShortUrlPlugin::get()->getNavigationLabel();
|
||||
if ($label) {
|
||||
return $label;
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
return __('filament-short-url::default.navigation_label');
|
||||
}
|
||||
|
||||
@@ -41,6 +50,20 @@ class ShortUrlResource extends Resource
|
||||
|
||||
// ─── Plugin-aware navigation overrides ───────────────────────────────────
|
||||
|
||||
public static function getNavigationIcon(): string|\BackedEnum|null
|
||||
{
|
||||
try {
|
||||
$icon = FilamentShortUrlPlugin::get()->getNavigationIcon();
|
||||
if ($icon) {
|
||||
return $icon;
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
return static::$navigationIcon;
|
||||
}
|
||||
|
||||
public static function getNavigationGroup(): string|\UnitEnum|null
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -45,6 +45,8 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
'queue_connection' => $mgr->get('queue_connection', 'sync'),
|
||||
'ga4_api_secret' => $mgr->get('ga4_api_secret'),
|
||||
'ga4_firebase_app_id' => $mgr->get('ga4_firebase_app_id'),
|
||||
'counter_buffering_enabled' => $mgr->get('counter_buffering_enabled', false),
|
||||
'trust_cdn_headers' => $mgr->get('trust_cdn_headers', false),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -93,6 +95,12 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
->minValue(0)
|
||||
->suffix('s')
|
||||
->required(),
|
||||
|
||||
Toggle::make('trust_cdn_headers')
|
||||
->label(__('filament-short-url::default.settings_trust_cdn_headers'))
|
||||
->helperText(__('filament-short-url::default.settings_trust_cdn_headers_helper'))
|
||||
->columnSpanFull()
|
||||
->inline(false),
|
||||
]),
|
||||
|
||||
Section::make(__('filament-short-url::default.settings_section_queue'))
|
||||
@@ -111,6 +119,14 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
})
|
||||
->required(),
|
||||
]),
|
||||
|
||||
Section::make(__('filament-short-url::default.settings_section_buffering'))
|
||||
->schema([
|
||||
Toggle::make('counter_buffering_enabled')
|
||||
->label(__('filament-short-url::default.settings_buffering_enabled'))
|
||||
->helperText(__('filament-short-url::default.settings_buffering_helper'))
|
||||
->inline(false),
|
||||
]),
|
||||
]),
|
||||
|
||||
// ── Geo-IP ───────────────────────────────────────────
|
||||
|
||||
@@ -112,9 +112,9 @@ class ViewShortUrlLogs extends Page implements HasForms, HasTable
|
||||
Tables\Filters\Filter::make('visited_at')
|
||||
->form([
|
||||
DatePicker::make('visited_from')
|
||||
->label('Visited From'),
|
||||
->label(__('filament-short-url::default.stats_filter_visited_from')),
|
||||
DatePicker::make('visited_until')
|
||||
->label('Visited Until'),
|
||||
->label(__('filament-short-url::default.stats_filter_visited_until')),
|
||||
])
|
||||
->query(function ($query, array $data) {
|
||||
return $query
|
||||
@@ -124,19 +124,26 @@ class ViewShortUrlLogs extends Page implements HasForms, HasTable
|
||||
])
|
||||
->headerActions([
|
||||
Action::make('export_csv')
|
||||
->label('Export CSV')
|
||||
->label(__('filament-short-url::default.stats_action_export'))
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->color('gray')
|
||||
->action(function () {
|
||||
return response()->streamDownload(function () {
|
||||
->action(function (HasTable $livewire) {
|
||||
return response()->streamDownload(function () use ($livewire) {
|
||||
$handle = fopen('php://output', 'w');
|
||||
// Add UTF-8 BOM for Microsoft Excel
|
||||
fprintf($handle, chr(0xEF).chr(0xBB).chr(0xBF));
|
||||
|
||||
fputcsv($handle, ['Time', 'IP Address', 'Country', 'Device', 'Browser', 'OS', 'Referer']);
|
||||
fputcsv($handle, [
|
||||
__('filament-short-url::default.stats_csv_time'),
|
||||
__('filament-short-url::default.stats_csv_ip'),
|
||||
__('filament-short-url::default.stats_csv_country'),
|
||||
__('filament-short-url::default.stats_csv_device'),
|
||||
__('filament-short-url::default.stats_csv_browser'),
|
||||
__('filament-short-url::default.stats_csv_os'),
|
||||
__('filament-short-url::default.stats_csv_referer'),
|
||||
]);
|
||||
|
||||
ShortUrlVisit::query()
|
||||
->where('short_url_id', $this->record->id)
|
||||
$livewire->getFilteredTableQuery()
|
||||
->orderBy('visited_at', 'desc')
|
||||
->chunk(200, function ($visits) use ($handle) {
|
||||
foreach ($visits as $visit) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -105,7 +105,7 @@ class ShortUrlForm
|
||||
->required(),
|
||||
])->columns(2),
|
||||
|
||||
Section::make('Options')->schema([
|
||||
Section::make(__('filament-short-url::default.form_section_options'))->schema([
|
||||
Toggle::make('is_enabled')
|
||||
->label(__('filament-short-url::default.status'))
|
||||
->default(true)
|
||||
@@ -129,7 +129,7 @@ class ShortUrlForm
|
||||
->native(false),
|
||||
])->columns(2),
|
||||
|
||||
Section::make('Internal Notes')->schema([
|
||||
Section::make(__('filament-short-url::default.form_section_notes'))->schema([
|
||||
Textarea::make('notes')
|
||||
->label(__('filament-short-url::default.notes'))
|
||||
->rows(3)
|
||||
@@ -177,7 +177,7 @@ class ShortUrlForm
|
||||
return Tab::make(__('filament-short-url::default.tab_tracking'))
|
||||
->icon('heroicon-o-chart-bar')
|
||||
->schema([
|
||||
Section::make('Visit Tracking')
|
||||
Section::make(__('filament-short-url::default.form_section_tracking'))
|
||||
->schema([
|
||||
Toggle::make('track_visits')
|
||||
->label(__('filament-short-url::default.track_visits'))
|
||||
@@ -187,7 +187,7 @@ class ShortUrlForm
|
||||
->columnSpanFull(),
|
||||
])->columns(1),
|
||||
|
||||
Section::make('Tracked Fields')
|
||||
Section::make(__('filament-short-url::default.form_section_tracked_fields'))
|
||||
->schema([
|
||||
Toggle::make('track_ip_address')
|
||||
->label(__('filament-short-url::default.track_ip'))
|
||||
@@ -277,7 +277,7 @@ class ShortUrlForm
|
||||
])
|
||||
->columns(2),
|
||||
|
||||
Section::make('Third-Party Analytics')
|
||||
Section::make(__('filament-short-url::default.form_section_analytics'))
|
||||
->schema([
|
||||
TextInput::make('ga_tracking_id')
|
||||
->label(__('filament-short-url::default.ga_tracking_id'))
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Tables;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\BulkAction;
|
||||
@@ -101,21 +102,7 @@ class ShortUrlsTable
|
||||
->icon('heroicon-o-chart-bar')
|
||||
->color('gray')
|
||||
->size('sm')
|
||||
->url(fn (ShortUrl $record): string => route(
|
||||
'filament.admin.resources.short-urls.stats',
|
||||
['record' => $record->id]
|
||||
)),
|
||||
|
||||
Action::make('copy')
|
||||
->label(__('filament-short-url::default.action_copy'))
|
||||
->icon('heroicon-o-clipboard')
|
||||
->color('gray')
|
||||
->size('sm')
|
||||
->action(fn () => null) // Copy happens client-side
|
||||
->extraAttributes(fn (ShortUrl $record): array => [
|
||||
'x-on:click' => 'navigator.clipboard.writeText("'.$record->getShortUrl().'")',
|
||||
'title' => 'Copy short URL',
|
||||
]),
|
||||
->url(fn (ShortUrl $record): string => ShortUrlResource::getUrl('stats', ['record' => $record])),
|
||||
|
||||
EditAction::make()
|
||||
->icon('heroicon-o-pencil-square')
|
||||
@@ -125,13 +112,13 @@ class ShortUrlsTable
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
BulkAction::make('enable')
|
||||
->label('Enable selected')
|
||||
->label(__('filament-short-url::default.action_enable_selected'))
|
||||
->icon('heroicon-o-check-circle')
|
||||
->action(fn ($records) => $records->each->update(['is_enabled' => true]))
|
||||
->deselectRecordsAfterCompletion(),
|
||||
|
||||
BulkAction::make('disable')
|
||||
->label('Disable selected')
|
||||
->label(__('filament-short-url::default.action_disable_selected'))
|
||||
->icon('heroicon-o-x-circle')
|
||||
->action(fn ($records) => $records->each->update(['is_enabled' => false]))
|
||||
->deselectRecordsAfterCompletion(),
|
||||
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@ class FilamentShortUrlPlugin implements Plugin
|
||||
|
||||
protected ?int $navigationSort = null;
|
||||
|
||||
protected ?string $navigationLabel = null;
|
||||
|
||||
protected ?string $navigationIcon = null;
|
||||
|
||||
protected ?string $routePrefix = null;
|
||||
|
||||
// ─── Factory ─────────────────────────────────────────────────────────────
|
||||
@@ -67,6 +71,8 @@ class FilamentShortUrlPlugin implements Plugin
|
||||
|
||||
/**
|
||||
* Set the navigation sort order.
|
||||
*
|
||||
* @example FilamentShortUrlPlugin::make()->navigationSort(50)
|
||||
*/
|
||||
public function navigationSort(int $sort): static
|
||||
{
|
||||
@@ -79,4 +85,38 @@ class FilamentShortUrlPlugin implements Plugin
|
||||
{
|
||||
return $this->navigationSort;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override the navigation label (menu item name).
|
||||
*
|
||||
* @example FilamentShortUrlPlugin::make()->navigationLabel('Short Links')
|
||||
*/
|
||||
public function navigationLabel(string $label): static
|
||||
{
|
||||
$this->navigationLabel = $label;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getNavigationLabel(): ?string
|
||||
{
|
||||
return $this->navigationLabel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override the navigation icon.
|
||||
*
|
||||
* @example FilamentShortUrlPlugin::make()->navigationIcon('heroicon-o-link')
|
||||
*/
|
||||
public function navigationIcon(string $icon): static
|
||||
{
|
||||
$this->navigationIcon = $icon;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getNavigationIcon(): ?string
|
||||
{
|
||||
return $this->navigationIcon;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Console\Commands\SyncBufferedCountersCommand;
|
||||
use Bjanczak\FilamentShortUrl\Services\GeoIpService;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlSettingsManager;
|
||||
@@ -24,7 +25,9 @@ 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']);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,9 +31,10 @@ class ShortUrlRedirectController extends Controller
|
||||
}
|
||||
|
||||
if ($shortUrl->track_visits) {
|
||||
$connection = config('filament-short-url.queue_connection');
|
||||
$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,12 +42,18 @@ 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 && $connection !== 'default') {
|
||||
if ($connection) {
|
||||
dispatch($job->onConnection($connection));
|
||||
} else {
|
||||
dispatch($job);
|
||||
dispatch($job->onConnection('sync'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +67,9 @@ class ShortUrlRedirectController extends Controller
|
||||
if ($affected === 0) {
|
||||
abort(410);
|
||||
}
|
||||
|
||||
// Manually forget cache since DB-level update does not trigger Eloquent events
|
||||
cache()->forget("filament-short-url:{$shortUrl->url_key}");
|
||||
}
|
||||
|
||||
$redirectUrl = $this->service->resolveRedirectUrl($shortUrl, $request);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
@@ -238,10 +239,40 @@ class ShortUrl extends Model
|
||||
|
||||
/**
|
||||
* Atomically increment visit counters — single query when unique,
|
||||
* to avoid race conditions and two round-trips.
|
||||
* to avoid race conditions and two round-trips. Supports write-back caching.
|
||||
*/
|
||||
public function incrementVisits(bool $isUnique = false): void
|
||||
{
|
||||
if (config('filament-short-url.counter_buffering.enabled', false)) {
|
||||
$prefix = config('filament-short-url.counter_buffering.cache_key_prefix', 'filament-short-url:buffer:');
|
||||
try {
|
||||
// Safely increment total visits in cache
|
||||
cache()->increment("{$prefix}total:{$this->id}");
|
||||
|
||||
if ($isUnique) {
|
||||
cache()->increment("{$prefix}unique:{$this->id}");
|
||||
}
|
||||
|
||||
$dirtyKey = "{$prefix}dirty_ids";
|
||||
|
||||
// Check if Redis is being used for the cache to prevent set race conditions
|
||||
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, []);
|
||||
if (! in_array($this->id, $dirtyIds)) {
|
||||
$dirtyIds[] = $this->id;
|
||||
cache()->put($dirtyKey, $dirtyIds, now()->addDays(7));
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
} catch (\Throwable) {
|
||||
// Fallback to database below if cache fails or doesn't support increments
|
||||
}
|
||||
}
|
||||
|
||||
$this->newQuery()
|
||||
->where('id', $this->id)
|
||||
->increment('total_visits', 1, $isUnique ? ['unique_visits' => DB::raw('unique_visits + 1')] : []);
|
||||
@@ -252,33 +283,57 @@ class ShortUrl extends Model
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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
|
||||
@@ -292,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')
|
||||
@@ -321,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 [
|
||||
@@ -339,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,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,29 +11,31 @@ class ClientIpExtractor
|
||||
*/
|
||||
public static function getIp(Request $request): string
|
||||
{
|
||||
// 1. Cloudflare connecting IP header
|
||||
if ($cfIp = $request->header('CF-Connecting-IP')) {
|
||||
return trim($cfIp);
|
||||
if (config('filament-short-url.trust_cdn_headers', false)) {
|
||||
// 1. Cloudflare connecting IP header
|
||||
if ($cfIp = $request->header('CF-Connecting-IP')) {
|
||||
return trim($cfIp);
|
||||
}
|
||||
|
||||
// 2. Akamai or other CDNs True-Client-IP header
|
||||
if ($trueIp = $request->header('True-Client-IP')) {
|
||||
return trim($trueIp);
|
||||
}
|
||||
|
||||
// 3. General reverse proxy / Nginx X-Real-IP header
|
||||
if ($realIp = $request->header('X-Real-IP')) {
|
||||
return trim($realIp);
|
||||
}
|
||||
|
||||
// 4. Standard X-Forwarded-For header chain (first IP is the client)
|
||||
if ($forwardedFor = $request->header('X-Forwarded-For')) {
|
||||
$ips = explode(',', $forwardedFor);
|
||||
|
||||
return trim($ips[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Akamai or other CDNs True-Client-IP header
|
||||
if ($trueIp = $request->header('True-Client-IP')) {
|
||||
return trim($trueIp);
|
||||
}
|
||||
|
||||
// 3. General reverse proxy / Nginx X-Real-IP header
|
||||
if ($realIp = $request->header('X-Real-IP')) {
|
||||
return trim($realIp);
|
||||
}
|
||||
|
||||
// 4. Standard X-Forwarded-For header chain (first IP is the client)
|
||||
if ($forwardedFor = $request->header('X-Forwarded-For')) {
|
||||
$ips = explode(',', $forwardedFor);
|
||||
|
||||
return trim($ips[0]);
|
||||
}
|
||||
|
||||
// 5. Fallback to standard Laravel/Symfony IP resolver
|
||||
// 5. Fallback to standard Laravel/Symfony IP resolver (which respects Trusted Proxies)
|
||||
return $request->ip() ?? '0.0.0.0';
|
||||
}
|
||||
|
||||
@@ -42,19 +44,39 @@ class ClientIpExtractor
|
||||
*/
|
||||
public static function getCountryCode(Request $request): ?string
|
||||
{
|
||||
// 1. Cloudflare IP Country header
|
||||
if ($cfCountry = $request->header('CF-IPCountry')) {
|
||||
return strtoupper(trim($cfCountry));
|
||||
if (config('filament-short-url.trust_cdn_headers', false)) {
|
||||
// 1. Cloudflare IP Country header
|
||||
if ($cfCountry = $request->header('CF-IPCountry')) {
|
||||
return strtoupper(trim($cfCountry));
|
||||
}
|
||||
|
||||
// 2. AWS CloudFront country header
|
||||
if ($cfViewerCountry = $request->header('CloudFront-Viewer-Country')) {
|
||||
return strtoupper(trim($cfViewerCountry));
|
||||
}
|
||||
|
||||
// 3. Generic CDN / Proxy country header
|
||||
if ($xCountry = $request->header('X-Country-Code')) {
|
||||
return strtoupper(trim($xCountry));
|
||||
}
|
||||
}
|
||||
|
||||
// 2. AWS CloudFront country header
|
||||
if ($cfViewerCountry = $request->header('CloudFront-Viewer-Country')) {
|
||||
return strtoupper(trim($cfViewerCountry));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3. Generic CDN / Proxy country header
|
||||
if ($xCountry = $request->header('X-Country-Code')) {
|
||||
return strtoupper(trim($xCountry));
|
||||
/**
|
||||
* 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;
|
||||
|
||||
@@ -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<string, array{country: string|null, country_code: string|null}> */
|
||||
/** @var array<string, array{country: string|null, country_code: string|null, city: string|null}> */
|
||||
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', [
|
||||
|
||||
@@ -49,6 +49,8 @@ class ShortUrlSettingsManager
|
||||
'ga4_firebase_app_id' => config('filament-short-url.ga4.firebase_app_id'),
|
||||
'queue_connection' => config('filament-short-url.queue_connection', 'sync'),
|
||||
'cache_ttl' => config('filament-short-url.cache_ttl', 3600),
|
||||
'counter_buffering_enabled' => config('filament-short-url.counter_buffering.enabled', false),
|
||||
'trust_cdn_headers' => config('filament-short-url.trust_cdn_headers', false),
|
||||
], $stored);
|
||||
|
||||
return $this->cache;
|
||||
@@ -87,6 +89,8 @@ class ShortUrlSettingsManager
|
||||
'ga4_firebase_app_id',
|
||||
'queue_connection',
|
||||
'cache_ttl',
|
||||
'counter_buffering_enabled',
|
||||
'trust_cdn_headers',
|
||||
];
|
||||
|
||||
$filtered = array_intersect_key($data, array_flip($keys));
|
||||
@@ -110,6 +114,12 @@ class ShortUrlSettingsManager
|
||||
if (isset($filtered['cache_ttl'])) {
|
||||
$filtered['cache_ttl'] = (int) $filtered['cache_ttl'];
|
||||
}
|
||||
if (isset($filtered['counter_buffering_enabled'])) {
|
||||
$filtered['counter_buffering_enabled'] = (bool) $filtered['counter_buffering_enabled'];
|
||||
}
|
||||
if (isset($filtered['trust_cdn_headers'])) {
|
||||
$filtered['trust_cdn_headers'] = (bool) $filtered['trust_cdn_headers'];
|
||||
}
|
||||
|
||||
File::put($path, json_encode($filtered, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
$this->cache = null;
|
||||
@@ -138,6 +148,8 @@ class ShortUrlSettingsManager
|
||||
'filament-short-url.ga4.firebase_app_id' => $settings['ga4_firebase_app_id'],
|
||||
'filament-short-url.queue_connection' => $settings['queue_connection'],
|
||||
'filament-short-url.cache_ttl' => $settings['cache_ttl'],
|
||||
'filament-short-url.counter_buffering.enabled' => $settings['counter_buffering_enabled'],
|
||||
'filament-short-url.trust_cdn_headers' => $settings['trust_cdn_headers'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user