From 20a75bc72a008cef18fcc5937d415fa940827d4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Janczak?= Date: Mon, 1 Jun 2026 10:10:26 +0200 Subject: [PATCH] feat: initial release v1.0.0 --- .gitignore | 11 + README.md | 154 ++++++ composer.json | 51 ++ config/filament-short-url.php | 135 +++++ ...4_01_01_000001_create_short_urls_table.php | 60 +++ ...1_000002_create_short_url_visits_table.php | 52 ++ filament-short-url | 1 + resources/lang/en/default.php | 183 +++++++ resources/lang/pl/default.php | 184 +++++++ resources/views/logs.blade.php | 22 + resources/views/qr-designer.blade.php | 481 ++++++++++++++++++ resources/views/settings.blade.php | 13 + resources/views/stats.blade.php | 33 ++ .../widgets/visits-bottom-breakdown.blade.php | 144 ++++++ .../widgets/visits-right-breakdown.blade.php | 31 ++ routes/web.php | 12 + src/Events/ShortUrlVisited.php | 29 ++ src/Filament/Resources/ShortUrlResource.php | 96 ++++ .../ShortUrlResource/Pages/ListShortUrls.php | 45 ++ .../Pages/ShortUrlSettingsPage.php | 230 +++++++++ .../Pages/ViewShortUrlLogs.php | 184 +++++++ .../Pages/ViewShortUrlStats.php | 60 +++ .../ShortUrlResource/Schemas/ShortUrlForm.php | 316 ++++++++++++ .../Tables/ShortUrlsTable.php | 144 ++++++ .../Widgets/ShortUrlStatsOverview.php | 43 ++ .../Widgets/ShortUrlVisitsBottomBreakdown.php | 46 ++ .../Widgets/ShortUrlVisitsChart.php | 57 +++ .../Widgets/ShortUrlVisitsRightBreakdown.php | 42 ++ src/FilamentShortUrlPlugin.php | 82 +++ src/FilamentShortUrlServiceProvider.php | 42 ++ .../ShortUrlRedirectController.php | 69 +++ src/Jobs/TrackShortUrlVisitJob.php | 125 +++++ src/Models/ShortUrl.php | 349 +++++++++++++ src/Models/ShortUrlVisit.php | 54 ++ src/Services/ClientIpExtractor.php | 62 +++ src/Services/GeoIpService.php | 222 ++++++++ src/Services/ShortUrlBuilder.php | 92 ++++ src/Services/ShortUrlService.php | 126 +++++ src/Services/ShortUrlSettingsManager.php | 143 ++++++ src/Services/ShortUrlTracker.php | 69 +++ src/Services/UserAgentParser.php | 178 +++++++ 41 files changed, 4472 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 composer.json create mode 100644 config/filament-short-url.php create mode 100644 database/migrations/2024_01_01_000001_create_short_urls_table.php create mode 100644 database/migrations/2024_01_01_000002_create_short_url_visits_table.php create mode 160000 filament-short-url create mode 100644 resources/lang/en/default.php create mode 100644 resources/lang/pl/default.php create mode 100644 resources/views/logs.blade.php create mode 100644 resources/views/qr-designer.blade.php create mode 100644 resources/views/settings.blade.php create mode 100644 resources/views/stats.blade.php create mode 100644 resources/views/widgets/visits-bottom-breakdown.blade.php create mode 100644 resources/views/widgets/visits-right-breakdown.blade.php create mode 100644 routes/web.php create mode 100644 src/Events/ShortUrlVisited.php create mode 100644 src/Filament/Resources/ShortUrlResource.php create mode 100644 src/Filament/Resources/ShortUrlResource/Pages/ListShortUrls.php create mode 100644 src/Filament/Resources/ShortUrlResource/Pages/ShortUrlSettingsPage.php create mode 100644 src/Filament/Resources/ShortUrlResource/Pages/ViewShortUrlLogs.php create mode 100644 src/Filament/Resources/ShortUrlResource/Pages/ViewShortUrlStats.php create mode 100644 src/Filament/Resources/ShortUrlResource/Schemas/ShortUrlForm.php create mode 100644 src/Filament/Resources/ShortUrlResource/Tables/ShortUrlsTable.php create mode 100644 src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlStatsOverview.php create mode 100644 src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsBottomBreakdown.php create mode 100644 src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsChart.php create mode 100644 src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsRightBreakdown.php create mode 100644 src/FilamentShortUrlPlugin.php create mode 100644 src/FilamentShortUrlServiceProvider.php create mode 100644 src/Http/Controllers/ShortUrlRedirectController.php create mode 100644 src/Jobs/TrackShortUrlVisitJob.php create mode 100644 src/Models/ShortUrl.php create mode 100644 src/Models/ShortUrlVisit.php create mode 100644 src/Services/ClientIpExtractor.php create mode 100644 src/Services/GeoIpService.php create mode 100644 src/Services/ShortUrlBuilder.php create mode 100644 src/Services/ShortUrlService.php create mode 100644 src/Services/ShortUrlSettingsManager.php create mode 100644 src/Services/ShortUrlTracker.php create mode 100644 src/Services/UserAgentParser.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..30a5fd4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +/vendor/ +/node_modules/ +/.env +/.env.* +!/.env.example +/coverage/ +/.phpunit.cache/ +/.pest/ +*.log +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..bad2eee --- /dev/null +++ b/README.md @@ -0,0 +1,154 @@ +# Filament Short URL + +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. + +--- + +## 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. + +--- + +## Requirements + +- PHP 8.3+ +- Laravel 11+ +- Filament 5+ + +--- + +## Installation + +Install the package via Composer: + +```bash +composer require bjanczak/filament-short-url +``` + +Publish and run the database migrations: + +```bash +php artisan vendor:publish --tag=filament-short-url-migrations +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: + +### 1. Publish Config File +Copies the default config file to `config/filament-short-url.php` for code-level customization: +```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): +```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: +```bash +php artisan vendor:publish --tag=filament-short-url-views +``` + +### 4. Publish Everything at Once +```bash +php artisan vendor:publish --provider="Bjanczak\FilamentShortUrl\FilamentShortUrlServiceProvider" +``` + +--- + +## Setup + +Register the plugin in your Filament Panel Provider (`app/Providers/Filament/AdminPanelProvider.php`): + +```php +use Bjanczak\FilamentShortUrl\FilamentShortUrlPlugin; + +public function panel(Panel $panel): Panel +{ + return $panel + ->plugins([ + FilamentShortUrlPlugin::make() + ->navigationGroup('Tools') // optional + ->navigationSort(50), // optional + ]); +} +``` + +--- + +## Usage in Code + +### 1. Programmatic Creation via Fluent Builder +You can easily create, configure, and append tracking tags to short URLs programmatically using the fluent builder: + +```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 + 'utm_source' => 'linkedin', + 'utm_medium' => 'social', + 'utm_campaign' => 'spring_sale', + 'utm_content' => null, // skipped automatically + ]) + ->create(); + +// Output the public short URL string +echo $shortUrl->getShortUrl(); // https://yourapp.com/s/promo2026 +``` + +### 2. Standard Service Injection +```php +use Bjanczak\FilamentShortUrl\Services\ShortUrlService; + +$service = app(ShortUrlService::class); + +$shortUrl = $service->create([ + 'destination_url' => 'https://example.com', + 'track_visits' => true, +]); +``` + +--- + +## Events + +You can listen to short URL visits dynamically in your application (e.g. for real-time alerts or external webhooks): + +```php +use Bjanczak\FilamentShortUrl\Events\ShortUrlVisited; +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.) +}); +``` + +--- + +## License + +MIT diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..bbf8977 --- /dev/null +++ b/composer.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://getcomposer.org/schema.json", + "name": "bjanczak/filament-short-url", + "description": "A professional Short URL manager plugin for Filament v5 with QR code design, visit tracking, geo-IP detection, and analytics.", + "keywords": ["filament", "short-url", "qr-code", "analytics", "tracking", "laravel"], + "homepage": "https://github.com/bjanczak/filament-short-url", + "license": "MIT", + "authors": [ + { + "name": "Bartek Janczak", + "email": "hello@bjanczak.dev", + "role": "Developer" + } + ], + "require": { + "php": "^8.3", + "filament/filament": "^5.0", + "spatie/laravel-package-tools": "^1.16", + "geoip2/geoip2": "^3.0" + }, + "require-dev": { + "pestphp/pest": "^4.0", + "pestphp/pest-plugin-laravel": "^4.0", + "orchestra/testbench": "^10.0" + }, + "autoload": { + "psr-4": { + "Bjanczak\\FilamentShortUrl\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Bjanczak\\FilamentShortUrl\\Tests\\": "tests/" + } + }, + "extra": { + "laravel": { + "providers": [ + "Bjanczak\\FilamentShortUrl\\FilamentShortUrlServiceProvider" + ] + } + }, + "config": { + "sort-packages": true, + "allow-plugins": { + "pestphp/pest-plugin": true + } + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/config/filament-short-url.php b/config/filament-short-url.php new file mode 100644 index 0000000..f3e6699 --- /dev/null +++ b/config/filament-short-url.php @@ -0,0 +1,135 @@ + env('SHORT_URL_PREFIX', 's'), + + /* + |-------------------------------------------------------------------------- + | Default Redirect Status Code + |-------------------------------------------------------------------------- + | Use 302 for better tracking accuracy. 301 redirects are cached by browsers, + | which means subsequent visits won't trigger the tracking logic. + */ + 'redirect_status_code' => 302, + + /* + |-------------------------------------------------------------------------- + | Key Generation + |-------------------------------------------------------------------------- + | Length of auto-generated URL keys (base62: a-z, A-Z, 0-9). + | 6 chars = 56 billion possible keys. + */ + 'key_length' => 6, + + /* + |-------------------------------------------------------------------------- + | Geo-IP Settings + |-------------------------------------------------------------------------- + | Country detection from visitor IP addresses. + | Uses ip-api.com (free, no key required, 45 req/min). + | Results are cached per IP for cache_ttl seconds. + */ + 'geo_ip' => [ + 'enabled' => env('SHORT_URL_GEO_IP', true), + 'cache_ttl' => 86400, // 24 hours + 'driver' => env('SHORT_URL_GEO_IP_DRIVER', 'headers'), // 'headers', 'maxmind', 'ip-api' + 'timeout' => 3, // seconds to wait for geo-ip response + 'maxmind' => [ + 'database_path' => env('SHORT_URL_MAXMIND_DB', database_path('geoip/GeoLite2-Country.mmdb')), + ], + 'stats_cache_ttl' => env('SHORT_URL_STATS_CACHE_TTL', 300), // 5 minutes caching for stats page calculations + ], + + /* + |-------------------------------------------------------------------------- + | Default Tracking Configuration + |-------------------------------------------------------------------------- + | These are the default tracking settings for newly created short URLs. + | Each can be overridden per-URL in the Filament admin panel. + */ + 'tracking' => [ + 'enabled' => true, + 'fields' => [ + 'ip_address' => true, + 'browser' => true, + 'browser_version' => true, + 'operating_system' => true, + 'operating_system_version' => true, + 'referer_url' => true, + 'device_type' => true, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Google Analytics 4 — Measurement Protocol + |-------------------------------------------------------------------------- + | When a GA Tracking ID is set on a short URL, server-side events are sent + | to GA4 via the Measurement Protocol API. + | Get your API Secret from: GA4 → Admin → Data Streams → Measurement Protocol API secrets + */ + 'ga4' => [ + 'api_secret' => env('GA4_API_SECRET'), + 'firebase_app_id' => env('FIREBASE_APP_ID'), + ], + + /* + |-------------------------------------------------------------------------- + | Queue Connection + |-------------------------------------------------------------------------- + | Visit tracking is dispatched to a queue for ultra-fast redirects. + | Set to null to run synchronously (not recommended in production). + */ + 'queue_connection' => env('SHORT_URL_QUEUE', 'sync'), + + /* + |-------------------------------------------------------------------------- + | Redirect Cache TTL + |-------------------------------------------------------------------------- + | Short URL records are cached so redirects never hit the database on hot + | paths. Cache is automatically invalidated when a URL is saved or deleted. + | Set to 0 to disable caching (useful for testing). Default: 3600 (1 hour). + */ + 'cache_ttl' => (int) env('SHORT_URL_CACHE_TTL', 3600), + + /* + |-------------------------------------------------------------------------- + | Models + |-------------------------------------------------------------------------- + | Override these to use your own custom model classes. + */ + 'models' => [ + 'short_url' => ShortUrl::class, + 'short_url_visit' => ShortUrlVisit::class, + ], + + /* + |-------------------------------------------------------------------------- + | Default QR Code Options + |-------------------------------------------------------------------------- + | Default design settings for newly generated QR codes. + */ + 'qr_defaults' => [ + 'size' => 300, + 'margin' => 1, + 'dot_style' => 'square', + 'foreground_color' => '#000000', + 'background_color' => '#ffffff', + 'gradient_enabled' => false, + 'gradient_from' => '#000000', + 'gradient_to' => '#ffffff', + 'gradient_type' => 'linear', + ], + +]; diff --git a/database/migrations/2024_01_01_000001_create_short_urls_table.php b/database/migrations/2024_01_01_000001_create_short_urls_table.php new file mode 100644 index 0000000..970e0c7 --- /dev/null +++ b/database/migrations/2024_01_01_000001_create_short_urls_table.php @@ -0,0 +1,60 @@ +id(); + + // Core + $table->text('destination_url'); + $table->string('url_key', 32)->unique()->index(); + $table->string('notes')->nullable(); + + // Status & activation + $table->boolean('is_enabled')->default(true)->index(); + $table->smallInteger('redirect_status_code')->default(302); + $table->timestamp('activated_at')->nullable(); + $table->timestamp('deactivated_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + + // Behaviour + $table->boolean('single_use')->default(false); + $table->boolean('forward_query_params')->default(false); + + // Tracking — master switch + $table->boolean('track_visits')->default(true); + + // Tracking — field-level granularity + $table->boolean('track_ip_address')->default(true); + $table->boolean('track_browser')->default(true); + $table->boolean('track_browser_version')->default(true); + $table->boolean('track_operating_system')->default(true); + $table->boolean('track_operating_system_version')->default(true); + $table->boolean('track_device_type')->default(true); + $table->boolean('track_referer_url')->default(true); + + // QR code design (JSON blob) + $table->json('qr_options')->nullable(); + + // Google Analytics 4 integration + $table->string('ga_tracking_id', 50)->nullable(); + + // Denormalized counters for fast reads (updated atomically) + $table->unsignedBigInteger('total_visits')->default(0); + $table->unsignedBigInteger('unique_visits')->default(0); + + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('short_urls'); + } +}; diff --git a/database/migrations/2024_01_01_000002_create_short_url_visits_table.php b/database/migrations/2024_01_01_000002_create_short_url_visits_table.php new file mode 100644 index 0000000..84ef220 --- /dev/null +++ b/database/migrations/2024_01_01_000002_create_short_url_visits_table.php @@ -0,0 +1,52 @@ +id(); + + $table->foreignId('short_url_id') + ->constrained('short_urls') + ->cascadeOnDelete(); + + // Visitor fingerprint + $table->ipAddress('ip_address')->nullable(); + $table->string('ip_hash', 64)->nullable()->index(); // SHA-256 of IP for unique counting + + // Browser detection + $table->string('browser', 100)->nullable(); + $table->string('browser_version', 50)->nullable(); + + // OS detection + $table->string('operating_system', 100)->nullable(); + $table->string('operating_system_version', 50)->nullable(); + + // Device classification + $table->enum('device_type', ['desktop', 'mobile', 'tablet', 'robot'])->nullable()->index(); + + // Traffic source + $table->text('referer_url')->nullable(); + + // Geo-location + $table->string('country', 100)->nullable()->index(); + $table->char('country_code', 2)->nullable()->index(); + + $table->timestamp('visited_at')->useCurrent()->index(); + + // Composite indexes for performance on millions of visits + $table->index(['short_url_id', 'ip_hash']); + $table->index(['short_url_id', 'visited_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('short_url_visits'); + } +}; diff --git a/filament-short-url b/filament-short-url new file mode 160000 index 0000000..4bbad32 --- /dev/null +++ b/filament-short-url @@ -0,0 +1 @@ +Subproject commit 4bbad3298fd7b1b5beb0eac99a26bdca8a11f156 diff --git a/resources/lang/en/default.php b/resources/lang/en/default.php new file mode 100644 index 0000000..8a6200e --- /dev/null +++ b/resources/lang/en/default.php @@ -0,0 +1,183 @@ + 'Short URLs', + 'navigation_group' => 'Tools', + 'resource_title' => 'Short URL', + + // Form Tabs + 'tab_link' => 'Link Details', + 'tab_tracking' => 'Tracking Settings', + 'tab_qr_design' => 'QR Code Design', + + // Link Form Fields + 'destination_url' => 'Destination URL', + 'destination_url_helper' => 'The original URL you want to redirect visitors to.', + 'url_key' => 'Short Key', + 'url_key_helper' => 'Custom key for the short URL (leave empty for auto-generated).', + 'redirect_code' => 'Redirect Code', + 'redirect_code_301' => '301 (Permanent Redirect - better SEO)', + 'redirect_code_302' => '302 (Temporary Redirect - better tracking)', + 'status' => 'Active Status', + 'single_use' => 'Single Use', + 'single_use_helper' => 'Automatically disable this link after it has been visited once.', + 'forward_query_params' => 'Forward Query Parameters', + 'forward_query_params_helper' => 'Append incoming query parameters (like UTM tags) to the destination URL.', + 'expires_at' => 'Expires At', + 'notes' => 'Internal Notes', + 'ga_tracking_id' => 'Google Analytics 4 Measurement ID', + 'ga_tracking_id_helper' => 'Optional G-XXXXXXXXXX ID to track redirects server-side.', + + // UTM Builder + 'utm_builder' => 'UTM Campaign Builder', + 'utm_builder_helper' => 'Dynamically append UTM parameters to the destination URL. Changes are synchronized in real-time.', + 'utm_source' => 'Campaign Source (utm_source)', + 'utm_source_placeholder' => 'google, newsletter, facebook...', + 'utm_medium' => 'Campaign Medium (utm_medium)', + 'utm_medium_placeholder' => 'cpc, email, social...', + 'utm_campaign' => 'Campaign Name (utm_campaign)', + 'utm_campaign_placeholder' => 'spring_sale, promo_2026...', + 'utm_term' => 'Campaign Term (utm_term)', + 'utm_term_placeholder' => 'yacht_charter, buy_yachts...', + 'utm_content' => 'Campaign Content (utm_content)', + 'utm_content_placeholder' => 'logo_link, text_ad...', + + // Tracking Form Fields + 'track_visits' => 'Enable Visit Tracking', + 'track_ip' => 'Track IP Address', + 'track_browser' => 'Track Browser Name', + 'track_browser_version' => 'Track Browser Version', + 'track_os' => 'Track Operating System', + 'track_os_version' => 'Track OS Version', + 'track_device_type' => 'Track Device Type (desktop/mobile/tablet)', + 'track_referer' => 'Track Referer URL', + + // QR Design Fields + 'qr_size' => 'QR Code Size (px)', + 'qr_margin' => 'Quiet Zone Margin', + 'qr_dot_style' => 'Dot Style', + 'qr_fg_color' => 'Foreground Color', + 'qr_bg_color' => 'Background Color', + 'qr_gradient_enabled' => 'Enable Gradient', + 'qr_gradient_from' => 'Gradient Color From', + 'qr_gradient_to' => 'Gradient Color To', + 'qr_gradient_type' => 'Gradient Type', + 'qr_gradient_linear' => 'Linear', + 'qr_gradient_radial' => 'Radial', + 'qr_download' => 'Download QR Code', + 'qr_copied' => 'Short URL copied to clipboard!', + + // QR Designer Panel UI Labels + 'qr_label_size' => 'Size (px)', + 'qr_label_margin' => 'Margin', + 'qr_label_style' => 'Style', + 'qr_label_color' => 'Color', + 'qr_label_from' => 'From', + 'qr_label_to' => 'To', + 'qr_label_foreground_color' => 'Foreground Color', + 'qr_label_single_color' => 'Single Color', + 'qr_label_gradient' => 'Gradient', + 'qr_label_gradient_type' => 'Gradient Type', + 'qr_label_background' => 'Background', + 'qr_label_transparent' => 'Transparent', + 'qr_label_eye_config' => 'Eye Config', + 'qr_label_eye_square_style' => 'Eye Square Style', + 'qr_label_eye_dot_style' => 'Eye Dot Style', + 'qr_label_eye_color' => 'Eye Color', + 'qr_label_preview' => 'Preview', + 'qr_option_square' => 'Square', + 'qr_option_dots' => 'Dots', + 'qr_option_rounded' => 'Rounded', + 'qr_option_classy' => 'Classy', + 'qr_option_classy_rounded' => 'Classy Rounded', + 'qr_option_extra_rounded' => 'Extra Rounded', + 'qr_option_dot' => 'Dot', + 'qr_chart_visits_label' => 'Visits', + + // Table Columns + 'col_short_url' => 'Short URL', + 'col_destination_url' => 'Destination URL', + 'col_total_visits' => 'Total Visits', + 'col_status' => 'Status', + 'col_expires_at' => 'Expires At', + 'col_created_at' => 'Created At', + + // Actions + 'action_stats' => 'Statistics', + 'action_copy' => 'Copy URL', + 'action_qr' => 'QR Code', + + // Stats Page + 'stats_title' => 'Statistics', + 'stats_tab_statistics' => 'Statistics', + 'stats_tab_visit_logs' => 'Visit Logs', + 'stats_card_total' => 'Total Visits', + 'stats_card_unique' => 'Unique Visitors', + 'stats_card_today' => 'Today', + 'stats_card_week' => 'This Week', + 'stats_card_month' => 'This Month', + 'stats_chart_title' => 'Visits — Last 30 Days', + 'stats_no_chart_data' => 'No visits yet in the last 30 days.', + 'stats_breakdown_countries' => 'Top Countries', + 'stats_breakdown_devices' => 'Device Types', + 'stats_breakdown_browsers' => 'Browsers', + 'stats_breakdown_os' => 'Operating Systems', + 'stats_breakdown_referers' => 'Top Referers', + 'stats_no_country_data' => 'No country data.', + 'stats_no_device_data' => 'No device data.', + 'stats_no_browser_data' => 'No browser data.', + 'stats_no_os_data' => 'No OS data.', + 'stats_no_referer_data' => 'No referer data.', + 'stats_table_title' => 'Visit Logs', + 'stats_btn_back' => 'Back to list', + 'stats_btn_copy' => 'Copy Short URL', + 'stats_col_time' => 'Time', + 'stats_col_country' => 'Country', + 'stats_col_device' => 'Device', + 'stats_col_browser' => 'Browser', + 'stats_col_os' => 'OS', + 'stats_col_ip' => 'IP', + 'stats_no_visits' => 'No visits recorded yet.', + // Settings Page + 'settings_nav_label' => 'Settings', + 'settings_save_btn' => 'Save Settings', + 'settings_saved' => 'Settings saved successfully!', + + 'settings_tab_general' => 'General', + 'settings_tab_geoip' => 'Geo-IP', + 'settings_tab_ga4' => 'Google Analytics 4', + + 'settings_section_routing' => 'Routing & Redirects', + 'settings_section_queue' => 'Queue', + 'settings_section_geoip' => 'Geo-IP Detection', + 'settings_section_ga4' => 'GA4 Measurement Protocol', + 'settings_ga4_description' => 'Configure server-side Google Analytics 4 event tracking via the Measurement Protocol. Events are sent in the background without blocking the redirect.', + + 'settings_route_prefix' => 'Route Prefix', + 'settings_route_prefix_helper' => 'URL segment before the short key, e.g. "/s/abc123". Change requires a config:clear.', + 'settings_key_length' => 'Auto-generated Key Length', + 'settings_key_length_helper' => 'Number of characters for auto-generated short keys (base62). 6 chars = ~56 billion unique keys.', + 'settings_cache_ttl' => 'Redirect Cache TTL', + 'settings_cache_ttl_helper' => 'Seconds to cache resolved short URL records. Set to 0 to disable (not recommended in production).', + 'settings_queue_connection' => 'Queue Connection', + 'settings_queue_connection_helper' => 'Laravel queue connection used for async visit tracking. Use "sync" for synchronous (slower redirects), or "redis"/"sqs" for production.', + + 'settings_geoip_enabled' => 'Enable Geo-IP Detection', + 'settings_geoip_enabled_helper' => 'Detect and record the visitor\'s country on each visit.', + 'settings_geoip_driver' => 'Detection Driver', + 'settings_geoip_driver_helper' => 'Method used to resolve the visitor country from their IP address.', + 'settings_geoip_driver_headers' => 'CDN Headers (fastest — Cloudflare, CloudFront)', + 'settings_geoip_driver_maxmind' => 'MaxMind Local DB (offline, zero latency)', + 'settings_geoip_driver_ipapi' => 'ip-api.com (free API, 45 req/min limit)', + 'settings_geoip_cache_ttl' => 'Geo-IP Result Cache TTL', + 'settings_geoip_cache_ttl_helper' => 'Seconds to cache the resolved country per hashed IP. 86400 = 24 hours.', + 'settings_geoip_timeout' => 'API Timeout', + 'settings_geoip_timeout_helper' => 'Maximum seconds to wait for an external Geo-IP API response before giving up.', + 'settings_maxmind_path' => 'MaxMind Database Path', + 'settings_maxmind_path_helper' => 'Absolute path to your GeoLite2-Country.mmdb or GeoIP2-Country.mmdb file. Required when driver is set to MaxMind.', + + 'settings_ga4_api_secret' => 'Measurement Protocol API Secret', + '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.', +]; diff --git a/resources/lang/pl/default.php b/resources/lang/pl/default.php new file mode 100644 index 0000000..4da7f5b --- /dev/null +++ b/resources/lang/pl/default.php @@ -0,0 +1,184 @@ + 'Krótkie linki', + 'navigation_group' => 'Narzędzia', + 'resource_title' => 'Krótki URL', + + // Form Tabs + 'tab_link' => 'Szczegóły linku', + 'tab_tracking' => 'Ustawienia śledzenia', + 'tab_qr_design' => 'Wygląd kodu QR', + + // Link Form Fields + 'destination_url' => 'Docelowy URL', + 'destination_url_helper' => 'Oryginalny adres URL, na który chcesz przekierować odwiedzających.', + 'url_key' => 'Krótki klucz', + 'url_key_helper' => 'Własny klucz dla krótkiego linku (zostaw puste dla automatycznego klucza).', + 'redirect_code' => 'Kod przekierowania', + 'redirect_code_301' => '301 (Przekierowanie stałe - lepsze SEO)', + 'redirect_code_302' => '302 (Przekierowanie tymczasowe - lepsze śledzenie)', + 'status' => 'Status aktywności', + 'single_use' => 'Jednorazowy link', + 'single_use_helper' => 'Automatycznie dezaktywuj ten link po pierwszej wizycie.', + 'forward_query_params' => 'Przekaż parametry URL', + 'forward_query_params_helper' => 'Dołącz parametry wejściowe (np. tagi UTM) do docelowego adresu URL.', + 'expires_at' => 'Wygasa dnia', + 'notes' => 'Wewnętrzne notatki', + 'ga_tracking_id' => 'Identyfikator śledzenia Google Analytics 4', + 'ga_tracking_id_helper' => 'Opcjonalny identyfikator G-XXXXXXXXXX do śledzenia przekierowań po stronie serwera.', + + // UTM Builder + 'utm_builder' => 'Kreator parametrów UTM', + 'utm_builder_helper' => 'Dynamicznie twórz i dołączaj parametry UTM do adresu docelowego. Zmiany są synchronizowane w czasie rzeczywistym.', + 'utm_source' => 'Źródło kampanii (utm_source)', + 'utm_source_placeholder' => 'google, newsletter, facebook...', + 'utm_medium' => 'Medium kampanii (utm_medium)', + 'utm_medium_placeholder' => 'cpc, email, social...', + 'utm_campaign' => 'Nazwa kampanii (utm_campaign)', + 'utm_campaign_placeholder' => 'spring_sale, promo_2026...', + 'utm_term' => 'Słowo kluczowe kampanii (utm_term)', + 'utm_term_placeholder' => 'czarter_jachtow, kupno_jachtu...', + 'utm_content' => 'Treść kampanii (utm_content)', + 'utm_content_placeholder' => 'link_w_logo, reklama_tekstowa...', + + // Tracking Form Fields + 'track_visits' => 'Włącz śledzenie wizyt', + 'track_ip' => 'Śledź adres IP', + 'track_browser' => 'Śledź nazwę przeglądarki', + 'track_browser_version' => 'Śledź wersję przeglądarki', + 'track_os' => 'Śledź system operacyjny', + 'track_os_version' => 'Śledź wersję systemu operacyjnego', + 'track_device_type' => 'Śledź typ urządzenia (desktop/mobile/tablet)', + 'track_referer' => 'Śledź referer URL', + + // QR Design Fields + 'qr_size' => 'Rozmiar kodu QR (px)', + 'qr_margin' => 'Margines (Quiet Zone)', + 'qr_dot_style' => 'Styl punktów', + 'qr_fg_color' => 'Kolor główny (Foreground)', + 'qr_bg_color' => 'Kolor tła', + 'qr_gradient_enabled' => 'Włącz gradient', + 'qr_gradient_from' => 'Kolor gradientu od', + 'qr_gradient_to' => 'Kolor gradientu do', + 'qr_gradient_type' => 'Typ gradientu', + 'qr_gradient_linear' => 'Liniowy', + 'qr_gradient_radial' => 'Radialny', + 'qr_download' => 'Pobierz kod QR', + 'qr_copied' => 'Skopiowano krótki URL do schowka!', + + // QR Designer Panel UI Labels + 'qr_label_size' => 'Rozmiar (px)', + 'qr_label_margin' => 'Margines', + 'qr_label_style' => 'Styl', + 'qr_label_color' => 'Kolor', + 'qr_label_from' => 'Od', + 'qr_label_to' => 'Do', + 'qr_label_foreground_color' => 'Kolor główny', + 'qr_label_single_color' => 'Jednolity kolor', + 'qr_label_gradient' => 'Gradient', + 'qr_label_gradient_type' => 'Typ gradientu', + 'qr_label_background' => 'Tło', + 'qr_label_transparent' => 'Przezroczyste', + 'qr_label_eye_config' => 'Konfiguracja oczu', + 'qr_label_eye_square_style' => 'Styl kwadratu oka', + 'qr_label_eye_dot_style' => 'Styl punktu oka', + 'qr_label_eye_color' => 'Kolor oka', + 'qr_label_preview' => 'Podgląd', + 'qr_option_square' => 'Kwadrat', + 'qr_option_dots' => 'Kropki', + 'qr_option_rounded' => 'Zaokrąglony', + 'qr_option_classy' => 'Elegancki', + 'qr_option_classy_rounded' => 'Zaokrąglony elegancki', + 'qr_option_extra_rounded' => 'Bardzo zaokrąglony', + 'qr_option_dot' => 'Kropka', + 'qr_chart_visits_label' => 'Wizyty', + + // Table Columns + 'col_short_url' => 'Krótki URL', + 'col_destination_url' => 'Docelowy URL', + 'col_total_visits' => 'Wizyty', + 'col_status' => 'Status', + 'col_expires_at' => 'Wygasa', + 'col_created_at' => 'Utworzono', + + // Actions + 'action_stats' => 'Statystyki', + 'action_copy' => 'Kopiuj link', + 'action_qr' => 'Kod QR', + + // Stats Page + 'stats_title' => 'Statystyki', + 'stats_tab_statistics' => 'Statystyki', + 'stats_tab_visit_logs' => 'Logi odwiedzin', + 'stats_card_total' => 'Wszystkie wizyty', + 'stats_card_unique' => 'Unikalni goście', + 'stats_card_today' => 'Dzisiaj', + 'stats_card_week' => 'W tym tygodniu', + 'stats_card_month' => 'W tym miesiącu', + 'stats_chart_title' => 'Wizyty — Ostatnie 30 dni', + 'stats_no_chart_data' => 'Brak wizyt w ostatnich 30 dniach.', + 'stats_breakdown_countries' => 'Najpopularniejsze kraje', + 'stats_breakdown_devices' => 'Typy urządzeń', + 'stats_breakdown_browsers' => 'Przeglądarki', + 'stats_breakdown_os' => 'Systemy operacyjne', + 'stats_breakdown_referers' => 'Źródła (Referer)', + 'stats_no_country_data' => 'Brak danych o krajach.', + 'stats_no_device_data' => 'Brak danych o urządzeniach.', + 'stats_no_browser_data' => 'Brak danych o przeglądarkach.', + 'stats_no_os_data' => 'Brak danych o systemach.', + 'stats_no_referer_data' => 'Brak danych o refererach.', + 'stats_table_title' => 'Logi odwiedzin', + 'stats_btn_back' => 'Wróć do listy', + 'stats_btn_copy' => 'Kopiuj krótki URL', + 'stats_col_time' => 'Czas', + 'stats_col_country' => 'Kraj', + 'stats_col_device' => 'Urządzenie', + 'stats_col_browser' => 'Przeglądarka', + 'stats_col_os' => 'System operacyjny', + 'stats_col_ip' => 'IP', + 'stats_no_visits' => 'Nie zarejestrowano jeszcze żadnych wizyt.', + + // Settings Page + 'settings_nav_label' => 'Ustawienia', + 'settings_save_btn' => 'Zapisz ustawienia', + 'settings_saved' => 'Ustawienia zostały zapisane!', + + 'settings_tab_general' => 'Ogólne', + 'settings_tab_geoip' => 'Geo-IP', + 'settings_tab_ga4' => 'Google Analytics 4', + + 'settings_section_routing' => 'Routing i przekierowania', + 'settings_section_queue' => 'Kolejka', + 'settings_section_geoip' => 'Wykrywanie Geo-IP', + 'settings_section_ga4' => 'GA4 Measurement Protocol', + 'settings_ga4_description' => 'Skonfiguruj serwerowe śledzenie zdarzeń GA4 przez Measurement Protocol. Zdarzenia są wysyłane w tle bez blokowania przekierowania.', + + 'settings_route_prefix' => 'Prefiks trasy', + 'settings_route_prefix_helper' => 'Segment URL przed kluczem, np. "/s/abc123". Zmiana wymaga php artisan config:clear.', + 'settings_key_length' => 'Długość auto-generowanego klucza', + 'settings_key_length_helper' => 'Liczba znaków klucza (base62). 6 znaków = ~56 miliardów unikalnych kluczy.', + 'settings_cache_ttl' => 'TTL cache przekierowania', + 'settings_cache_ttl_helper' => 'Sekundy cachowania rekordów krótkich URL. Ustaw 0 aby wyłączyć (niezalecane na produkcji).', + 'settings_queue_connection' => 'Połączenie kolejki', + 'settings_queue_connection_helper' => 'Kolejka Laravel do asynchronicznego śledzenia wizyt. Użyj "sync" (synchroniczne), lub "redis"/"sqs" na produkcji.', + + 'settings_geoip_enabled' => 'Włącz wykrywanie Geo-IP', + 'settings_geoip_enabled_helper' => 'Wykrywaj i zapisuj kraj odwiedzającego przy każdej wizycie.', + 'settings_geoip_driver' => 'Sterownik wykrywania', + 'settings_geoip_driver_helper' => 'Metoda rozwiązywania kraju odwiedzającego na podstawie adresu IP.', + 'settings_geoip_driver_headers' => 'Nagłówki CDN (najszybsze — Cloudflare, CloudFront)', + 'settings_geoip_driver_maxmind' => 'MaxMind lokalna baza (offline, zerowe opóźnienie)', + 'settings_geoip_driver_ipapi' => 'ip-api.com (darmowe API, limit 45 req/min)', + 'settings_geoip_cache_ttl' => 'TTL cache wyniku Geo-IP', + 'settings_geoip_cache_ttl_helper' => 'Sekundy cachowania kraju dla zahashowanego IP. 86400 = 24 godziny.', + 'settings_geoip_timeout' => 'Timeout API', + 'settings_geoip_timeout_helper' => 'Maksymalna liczba sekund oczekiwania na odpowiedź zewnętrznego API Geo-IP.', + 'settings_maxmind_path' => 'Ścieżka bazy MaxMind', + 'settings_maxmind_path_helper' => 'Bezwzględna ścieżka do pliku GeoLite2-Country.mmdb lub GeoIP2-Country.mmdb. Wymagana gdy sterownik to MaxMind.', + + 'settings_ga4_api_secret' => 'Klucz API Measurement Protocol', + '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.', +]; diff --git a/resources/views/logs.blade.php b/resources/views/logs.blade.php new file mode 100644 index 0000000..6c60a33 --- /dev/null +++ b/resources/views/logs.blade.php @@ -0,0 +1,22 @@ + + + + {{ __('filament-short-url::default.stats_tab_statistics') }} + + + + {{ __('filament-short-url::default.stats_tab_visit_logs') }} + + + +
+ {{ $this->table }} +
+
diff --git a/resources/views/qr-designer.blade.php b/resources/views/qr-designer.blade.php new file mode 100644 index 0000000..b3ba4b0 --- /dev/null +++ b/resources/views/qr-designer.blade.php @@ -0,0 +1,481 @@ +@php + /** @var \Bjanczak\FilamentShortUrl\Models\ShortUrl|null $record */ + $record = $this->record ?? null; + $shortUrl = $record ? $record->getShortUrl() : (config('app.url').'/s/preview'); + $opts = $record ? $record->getQrOptions() : config('filament-short-url.qr_defaults', []); +@endphp + + + +{{-- Preload QR library as soon as the tab renders --}} + + +
+{{-- CSS Grid: left=fixed 280px, right=fills remaining space, both columns same height --}} +
+ + {{-- ══ LEFT: settings panel ══ --}} +
+ + {{-- Size & Margin --}} +
+
+
+ {{ __('filament-short-url::default.qr_label_size') }} + +
+
+ {{ __('filament-short-url::default.qr_label_margin') }} + +
+
+
+ + {{-- Dot Style --}} +
+ {{ __('filament-short-url::default.qr_label_style') }} + +
+ + {{-- Foreground Color --}} +
+ {{ __('filament-short-url::default.qr_label_foreground_color') }} + + {{-- Mode radio --}} +
+ + +
+ + {{-- Single color picker --}} +
+ {{ __('filament-short-url::default.qr_label_color') }} +
+
+ +
+ +
+
+ + {{-- Gradient pickers --}} + +
+ + {{-- Background --}} +
+ {{ __('filament-short-url::default.qr_label_background') }} +
+ {{ __('filament-short-url::default.qr_label_transparent') }} + +
+
+ {{ __('filament-short-url::default.qr_label_color') }} +
+
+ +
+ +
+
+ +
+ + {{-- Eye Config --}} +
+
+ {{ __('filament-short-url::default.qr_label_eye_config') }} + +
+ +
+ +
+ + {{-- ══ RIGHT: Preview ══ --}} +
+ + {{-- Top bar --}} +
+ {{ __('filament-short-url::default.qr_label_preview') }} +
+ + +
+
+ + {{-- + Canvas box — ALL styles in :style (object) so Alpine does NOT overwrite + the static style attribute (Alpine :style REPLACES, not merges, with static style). + --}} +
+ {{-- Spinner --}} +
+
+ {{-- QR canvas --}} +
+
+ +

+ {{ $shortUrl }} +

+
+ +
+
diff --git a/resources/views/settings.blade.php b/resources/views/settings.blade.php new file mode 100644 index 0000000..6391e1c --- /dev/null +++ b/resources/views/settings.blade.php @@ -0,0 +1,13 @@ + +
+ {{ $this->form }} + +
+ + {{ __('filament-short-url::default.settings_save_btn') }} + +
+
+ + +
diff --git a/resources/views/stats.blade.php b/resources/views/stats.blade.php new file mode 100644 index 0000000..ddf4c35 --- /dev/null +++ b/resources/views/stats.blade.php @@ -0,0 +1,33 @@ + + + + {{ __('filament-short-url::default.stats_tab_statistics') }} + + + + {{ __('filament-short-url::default.stats_tab_visit_logs') }} + + + +
+ @livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlStatsOverview::class, ['record' => $record]) + +
+
+ @livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsChart::class, ['record' => $record]) +
+
+ @livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsRightBreakdown::class, ['record' => $record]) +
+
+ + @livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsBottomBreakdown::class, ['record' => $record]) +
+
diff --git a/resources/views/widgets/visits-bottom-breakdown.blade.php b/resources/views/widgets/visits-bottom-breakdown.blade.php new file mode 100644 index 0000000..6b10a0c --- /dev/null +++ b/resources/views/widgets/visits-bottom-breakdown.blade.php @@ -0,0 +1,144 @@ +@php + $deviceIcons = [ + 'desktop' => 'heroicon-m-computer-desktop', + 'mobile' => 'heroicon-m-device-phone-mobile', + 'tablet' => 'heroicon-m-device-tablet', + 'robot' => 'heroicon-m-cpu-chip', + ]; + + $browserIcons = [ + 'Chrome' => 'heroicon-m-globe-alt', + 'Firefox' => 'heroicon-m-globe-americas', + 'Safari' => 'heroicon-m-compass', + 'Edge' => 'heroicon-m-globe-asia-australia', + 'Opera' => 'heroicon-m-bolt', + 'Internet Explorer' => 'heroicon-m-wrench', + 'Samsung Browser' => 'heroicon-m-device-phone-mobile', + ]; + + $osIcons = [ + 'Windows' => 'heroicon-m-computer-desktop', + 'OS X' => 'heroicon-m-computer-desktop', + 'macOS' => 'heroicon-m-computer-desktop', + 'iOS' => 'heroicon-m-device-phone-mobile', + 'Android' => 'heroicon-m-device-phone-mobile', + 'Linux' => 'heroicon-m-cpu-chip', + ]; +@endphp + + +
+ + {{-- Devices --}} +
+
+
+ +
+

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

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

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

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

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

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

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

+ @endforelse +
+
+ + {{-- Operating Systems --}} +
+
+
+ +
+

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

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

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

+ @endforelse +
+
+ + {{-- Referers --}} +
+
+
+ +
+

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

+
+
+ @forelse ($visitsByReferer as $referer => $count) +
+ + + {{ number_format($count) }} + +
+ @empty +

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

+ @endforelse +
+
+ +
+
diff --git a/resources/views/widgets/visits-right-breakdown.blade.php b/resources/views/widgets/visits-right-breakdown.blade.php new file mode 100644 index 0000000..23f0e4c --- /dev/null +++ b/resources/views/widgets/visits-right-breakdown.blade.php @@ -0,0 +1,31 @@ + +
+ + {{-- Countries --}} +
+
+
+ +
+

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

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

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

+ @endforelse +
+
+ +
+
diff --git a/routes/web.php b/routes/web.php new file mode 100644 index 0000000..4f6419c --- /dev/null +++ b/routes/web.php @@ -0,0 +1,12 @@ +name('short-url.redirect') + ->where('key', '[a-zA-Z0-9_-]+') + ->middleware('throttle:120,1'); diff --git a/src/Events/ShortUrlVisited.php b/src/Events/ShortUrlVisited.php new file mode 100644 index 0000000..a440535 --- /dev/null +++ b/src/Events/ShortUrlVisited.php @@ -0,0 +1,29 @@ +shortUrl->url_key.' visited from '.$event->visit->country); + * }); + * ``` + */ +class ShortUrlVisited +{ + use Dispatchable; + use SerializesModels; + + public function __construct( + public readonly ShortUrl $shortUrl, + public readonly ShortUrlVisit $visit, + ) {} +} diff --git a/src/Filament/Resources/ShortUrlResource.php b/src/Filament/Resources/ShortUrlResource.php new file mode 100644 index 0000000..bf663c3 --- /dev/null +++ b/src/Filament/Resources/ShortUrlResource.php @@ -0,0 +1,96 @@ +getNavigationGroup(); + if ($group) { + return $group; + } + } catch (\Throwable) { + // Ignore + } + + return __('filament-short-url::default.navigation_group'); + } + + public static function getNavigationSort(): ?int + { + try { + $plugin = FilamentShortUrlPlugin::get(); + + return $plugin->getNavigationSort() ?? static::$navigationSort; + } catch (\Throwable) { + return static::$navigationSort; + } + } + + // ─── Resource definition ───────────────────────────────────────────────── + + public static function form(Schema $schema): Schema + { + return ShortUrlForm::configure($schema); + } + + public static function table(Table $table): Table + { + return ShortUrlsTable::configure($table); + } + + public static function getRelations(): array + { + return []; + } + + public static function getPages(): array + { + return [ + 'index' => ListShortUrls::route('/'), + 'stats' => ViewShortUrlStats::route('/{record}/stats'), + 'stats.logs' => ViewShortUrlLogs::route('/{record}/stats/logs'), + 'settings' => ShortUrlSettingsPage::route('/settings'), + ]; + } +} diff --git a/src/Filament/Resources/ShortUrlResource/Pages/ListShortUrls.php b/src/Filament/Resources/ShortUrlResource/Pages/ListShortUrls.php new file mode 100644 index 0000000..891fad7 --- /dev/null +++ b/src/Filament/Resources/ShortUrlResource/Pages/ListShortUrls.php @@ -0,0 +1,45 @@ +label(__('filament-short-url::default.settings_nav_label')) + ->icon('heroicon-o-adjustments-horizontal') + ->color('gray') + ->size('sm') + ->url(static::getResource()::getUrl('settings')), + + CreateAction::make() + ->icon('heroicon-o-plus') + ->size('sm') + ->color('primary') + ->modalWidth('4xl') + ->mutateFormDataUsing(function (array $data): array { + // Auto-generate key if not provided + if (empty($data['url_key'])) { + $data['url_key'] = app(ShortUrlService::class)->generateKey(); + } + + return $data; + }), + ]; + } + + protected function getHeaderWidgets(): array + { + return []; + } +} diff --git a/src/Filament/Resources/ShortUrlResource/Pages/ShortUrlSettingsPage.php b/src/Filament/Resources/ShortUrlResource/Pages/ShortUrlSettingsPage.php new file mode 100644 index 0000000..c28438a --- /dev/null +++ b/src/Filament/Resources/ShortUrlResource/Pages/ShortUrlSettingsPage.php @@ -0,0 +1,230 @@ +form->fill([ + 'route_prefix' => $mgr->get('route_prefix', 's'), + 'redirect_status_code' => $mgr->get('redirect_status_code', 302), + 'key_length' => $mgr->get('key_length', 6), + 'cache_ttl' => $mgr->get('cache_ttl', 3600), + 'geo_ip_enabled' => $mgr->get('geo_ip_enabled', true), + 'geo_ip_driver' => $mgr->get('geo_ip_driver', 'headers'), + 'geo_ip_cache_ttl' => $mgr->get('geo_ip_cache_ttl', 86400), + 'geo_ip_timeout' => $mgr->get('geo_ip_timeout', 3), + 'maxmind_database_path' => $mgr->get('maxmind_database_path', database_path('geoip/GeoLite2-Country.mmdb')), + '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'), + ]); + } + + public function form(Schema $schema): Schema + { + return $schema + ->components([ + Tabs::make('ShortUrlSettings') + ->persistTabInQueryString() + ->tabs([ + + // ── General ────────────────────────────────────────── + Tab::make(__('filament-short-url::default.settings_tab_general')) + ->icon('heroicon-o-link') + ->schema([ + Section::make(__('filament-short-url::default.settings_section_routing')) + ->columns(2) + ->schema([ + TextInput::make('route_prefix') + ->label(__('filament-short-url::default.settings_route_prefix')) + ->helperText(__('filament-short-url::default.settings_route_prefix_helper')) + ->required() + ->alphaDash() + ->maxLength(20), + + Select::make('redirect_status_code') + ->label(__('filament-short-url::default.redirect_code')) + ->options([ + 302 => __('filament-short-url::default.redirect_code_302'), + 301 => __('filament-short-url::default.redirect_code_301'), + ]) + ->required(), + + TextInput::make('key_length') + ->label(__('filament-short-url::default.settings_key_length')) + ->helperText(__('filament-short-url::default.settings_key_length_helper')) + ->numeric() + ->minValue(4) + ->maxValue(20) + ->required(), + + TextInput::make('cache_ttl') + ->label(__('filament-short-url::default.settings_cache_ttl')) + ->helperText(__('filament-short-url::default.settings_cache_ttl_helper')) + ->numeric() + ->minValue(0) + ->suffix('s') + ->required(), + ]), + + Section::make(__('filament-short-url::default.settings_section_queue')) + ->schema([ + Select::make('queue_connection') + ->label(__('filament-short-url::default.settings_queue_connection')) + ->helperText(__('filament-short-url::default.settings_queue_connection_helper')) + ->options(function (): array { + $connections = array_keys(config('queue.connections', [])); + + return array_combine($connections, $connections) ?: [ + 'sync' => 'sync', + 'database' => 'database', + 'redis' => 'redis', + ]; + }) + ->required(), + ]), + ]), + + // ── Geo-IP ─────────────────────────────────────────── + Tab::make(__('filament-short-url::default.settings_tab_geoip')) + ->icon('heroicon-o-globe-alt') + ->schema([ + Section::make(__('filament-short-url::default.settings_section_geoip')) + ->columns(2) + ->schema([ + Toggle::make('geo_ip_enabled') + ->label(__('filament-short-url::default.settings_geoip_enabled')) + ->helperText(__('filament-short-url::default.settings_geoip_enabled_helper')) + ->columnSpanFull() + ->inline(false) + ->live(), + + // ── Driver (only when geo-ip is on) ── + Select::make('geo_ip_driver') + ->label(__('filament-short-url::default.settings_geoip_driver')) + ->helperText(__('filament-short-url::default.settings_geoip_driver_helper')) + ->options([ + 'headers' => __('filament-short-url::default.settings_geoip_driver_headers'), + 'maxmind' => __('filament-short-url::default.settings_geoip_driver_maxmind'), + 'ip-api' => __('filament-short-url::default.settings_geoip_driver_ipapi'), + ]) + ->required() + ->live() + ->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled')), + + // ── Cache TTL (only when geo-ip is on) ── + TextInput::make('geo_ip_cache_ttl') + ->label(__('filament-short-url::default.settings_geoip_cache_ttl')) + ->helperText(__('filament-short-url::default.settings_geoip_cache_ttl_helper')) + ->numeric() + ->minValue(0) + ->suffix('s') + ->required() + ->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled')), + + // ── Timeout (only for ip-api driver) ── + TextInput::make('geo_ip_timeout') + ->label(__('filament-short-url::default.settings_geoip_timeout')) + ->helperText(__('filament-short-url::default.settings_geoip_timeout_helper')) + ->numeric() + ->minValue(1) + ->maxValue(30) + ->suffix('s') + ->required() + ->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled') && $get('geo_ip_driver') === 'ip-api'), + + // ── MaxMind path (only for maxmind driver) ── + TextInput::make('maxmind_database_path') + ->label(__('filament-short-url::default.settings_maxmind_path')) + ->helperText(__('filament-short-url::default.settings_maxmind_path_helper')) + ->columnSpanFull() + ->placeholder(database_path('geoip/GeoLite2-Country.mmdb')) + ->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled') && $get('geo_ip_driver') === 'maxmind'), + ]), + ]), + + // ── Google Analytics 4 ─────────────────────────────── + Tab::make(__('filament-short-url::default.settings_tab_ga4')) + ->icon('heroicon-o-chart-bar') + ->schema([ + Section::make(__('filament-short-url::default.settings_section_ga4')) + ->description(__('filament-short-url::default.settings_ga4_description')) + ->schema([ + TextInput::make('ga4_api_secret') + ->label(__('filament-short-url::default.settings_ga4_api_secret')) + ->helperText(__('filament-short-url::default.settings_ga4_api_secret_helper')) + ->password() + ->revealable() + ->live() + ->placeholder('••••••••••••••••••••'), + + // Firebase App ID only when API Secret is set + TextInput::make('ga4_firebase_app_id') + ->label(__('filament-short-url::default.settings_ga4_firebase_app_id')) + ->helperText(__('filament-short-url::default.settings_ga4_firebase_app_id_helper')) + ->placeholder('1:1234567890:android:abcdef123456') + ->visible(fn (Get $get): bool => filled($get('ga4_api_secret'))), + ]), + ]), + ]), + ]) + ->statePath('data'); + } + + public function save(): void + { + $data = $this->form->getState(); + + app(ShortUrlSettingsManager::class)->set($data); + + Notification::make() + ->title(__('filament-short-url::default.settings_saved')) + ->success() + ->send(); + } + + protected function getHeaderActions(): array + { + return [ + Action::make('back') + ->label(__('filament-short-url::default.stats_btn_back')) + ->icon('heroicon-o-arrow-left') + ->color('gray') + ->size('sm') + ->url(static::getResource()::getUrl()), + ]; + } + + public function getTitle(): string + { + return __('filament-short-url::default.settings_nav_label'); + } +} diff --git a/src/Filament/Resources/ShortUrlResource/Pages/ViewShortUrlLogs.php b/src/Filament/Resources/ShortUrlResource/Pages/ViewShortUrlLogs.php new file mode 100644 index 0000000..c4f851a --- /dev/null +++ b/src/Filament/Resources/ShortUrlResource/Pages/ViewShortUrlLogs.php @@ -0,0 +1,184 @@ +record = $record; + } + + public function table(Table $table): Table + { + return $table + ->query(ShortUrlVisit::query()->where('short_url_id', $this->record->id)) + ->columns([ + Tables\Columns\TextColumn::make('visited_at') + ->label(__('filament-short-url::default.stats_col_time')) + ->dateTime() + ->sortable(), + Tables\Columns\TextColumn::make('ip_address') + ->label(__('filament-short-url::default.stats_col_ip')) + ->searchable() + ->sortable() + ->placeholder('—') + ->toggleable(), + Tables\Columns\TextColumn::make('country') + ->label(__('filament-short-url::default.stats_col_country')) + ->searchable() + ->sortable() + ->formatStateUsing(fn ($state, $record) => $record->country_code ? "{$record->country_code} - {$state}" : ($state ?? '—')) + ->placeholder('—') + ->toggleable(), + Tables\Columns\TextColumn::make('device_type') + ->label(__('filament-short-url::default.stats_col_device')) + ->searchable() + ->sortable() + ->formatStateUsing(fn ($state) => ucfirst($state)) + ->placeholder('—') + ->toggleable(), + Tables\Columns\TextColumn::make('browser') + ->label(__('filament-short-url::default.stats_col_browser')) + ->searchable() + ->sortable() + ->formatStateUsing(fn ($state, $record) => $record->browser_version ? "{$state} ({$record->browser_version})" : ($state ?? '—')) + ->placeholder('—') + ->toggleable(), + Tables\Columns\TextColumn::make('operating_system') + ->label(__('filament-short-url::default.stats_col_os')) + ->searchable() + ->sortable() + ->formatStateUsing(fn ($state, $record) => $record->operating_system_version ? "{$state} ({$record->operating_system_version})" : ($state ?? '—')) + ->placeholder('—') + ->toggleable(), + Tables\Columns\TextColumn::make('referer_url') + ->label(__('filament-short-url::default.track_referer')) + ->searchable() + ->sortable() + ->limit(30) + ->placeholder('—') + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->defaultSort('visited_at', 'desc') + ->paginated([10, 25, 50, 100]) + ->filters([ + Tables\Filters\SelectFilter::make('device_type') + ->label(__('filament-short-url::default.stats_col_device')) + ->options([ + 'desktop' => 'Desktop', + 'mobile' => 'Mobile', + 'tablet' => 'Tablet', + 'robot' => 'Robot / Bot', + ]), + + Tables\Filters\SelectFilter::make('country_code') + ->label(__('filament-short-url::default.stats_col_country')) + ->options(function () { + return ShortUrlVisit::query() + ->whereNotNull('country') + ->whereNotNull('country_code') + ->where('short_url_id', $this->record->id) + ->distinct() + ->orderBy('country') + ->pluck('country', 'country_code') + ->toArray(); + }), + + Tables\Filters\Filter::make('visited_at') + ->form([ + DatePicker::make('visited_from') + ->label('Visited From'), + DatePicker::make('visited_until') + ->label('Visited Until'), + ]) + ->query(function ($query, array $data) { + return $query + ->when($data['visited_from'], fn ($q, $date) => $q->whereDate('visited_at', '>=', $date)) + ->when($data['visited_until'], fn ($q, $date) => $q->whereDate('visited_at', '<=', $date)); + }), + ]) + ->headerActions([ + Action::make('export_csv') + ->label('Export CSV') + ->icon('heroicon-o-arrow-down-tray') + ->color('gray') + ->action(function () { + return response()->streamDownload(function () { + $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']); + + ShortUrlVisit::query() + ->where('short_url_id', $this->record->id) + ->orderBy('visited_at', 'desc') + ->chunk(200, function ($visits) use ($handle) { + foreach ($visits as $visit) { + fputcsv($handle, [ + $visit->visited_at->toDateTimeString(), + $visit->ip_address ?? '—', + $visit->country ? "{$visit->country_code} - {$visit->country}" : '—', + ucfirst($visit->device_type ?? '—'), + $visit->browser ? "{$visit->browser} ({$visit->browser_version})" : '—', + $visit->operating_system ? "{$visit->operating_system} ({$visit->operating_system_version})" : '—', + $visit->referer_url ?? '—', + ]); + } + }); + + fclose($handle); + }, "visits-logs-{$this->record->url_key}-".now()->format('Y-m-d').'.csv'); + }), + ]); + } + + protected function getHeaderActions(): array + { + return [ + Action::make('back') + ->label(__('filament-short-url::default.stats_btn_back')) + ->icon('heroicon-o-arrow-left') + ->color('gray') + ->url(ShortUrlResource::getUrl()), + + Action::make('copy_url') + ->label(__('filament-short-url::default.stats_btn_copy')) + ->icon('heroicon-o-clipboard') + ->color('gray') + ->extraAttributes([ + 'x-on:click' => 'navigator.clipboard.writeText("'.$this->record->getShortUrl().'")', + ]), + ]; + } + + public function getTitle(): string + { + return __('filament-short-url::default.stats_table_title').' — '.$this->record->url_key; + } +} diff --git a/src/Filament/Resources/ShortUrlResource/Pages/ViewShortUrlStats.php b/src/Filament/Resources/ShortUrlResource/Pages/ViewShortUrlStats.php new file mode 100644 index 0000000..e20b800 --- /dev/null +++ b/src/Filament/Resources/ShortUrlResource/Pages/ViewShortUrlStats.php @@ -0,0 +1,60 @@ +record = $record; + $this->totalVisits = $record->getCachedStats()['totalVisits'] ?? 0; + } + + protected function getHeaderWidgets(): array + { + return []; + } + + protected function getHeaderActions(): array + { + return [ + Action::make('back') + ->label(__('filament-short-url::default.stats_btn_back')) + ->icon('heroicon-o-arrow-left') + ->color('gray') + ->url(ShortUrlResource::getUrl()), + + Action::make('copy_url') + ->label(__('filament-short-url::default.stats_btn_copy')) + ->icon('heroicon-o-clipboard') + ->color('gray') + ->extraAttributes([ + 'x-on:click' => 'navigator.clipboard.writeText("'.$this->record->getShortUrl().'")', + ]), + ]; + } + + public function getTitle(): string + { + return __('filament-short-url::default.stats_title').' — '.$this->record->url_key; + } +} diff --git a/src/Filament/Resources/ShortUrlResource/Schemas/ShortUrlForm.php b/src/Filament/Resources/ShortUrlResource/Schemas/ShortUrlForm.php new file mode 100644 index 0000000..4a2b677 --- /dev/null +++ b/src/Filament/Resources/ShortUrlResource/Schemas/ShortUrlForm.php @@ -0,0 +1,316 @@ +components([ + Tabs::make()->tabs([ + static::linkTab(), + static::trackingTab(), + static::qrDesignTab(), + ])->columnSpanFull(), + ]); + } + + private static function linkTab(): Tab + { + return Tab::make(__('filament-short-url::default.tab_link')) + ->icon('heroicon-o-link') + ->schema([ + Section::make()->schema([ + TextInput::make('destination_url') + ->label(__('filament-short-url::default.destination_url')) + ->helperText(__('filament-short-url::default.destination_url_helper')) + ->required() + ->url() + ->maxLength(2048) + ->live(onBlur: true) + ->afterStateHydrated(function (TextInput $component, $state, Set $set) { + if (! $state) { + return; + } + $parts = parse_url($state); + if (isset($parts['query'])) { + parse_str($parts['query'], $query); + $set('utm_source', $query['utm_source'] ?? null); + $set('utm_medium', $query['utm_medium'] ?? null); + $set('utm_campaign', $query['utm_campaign'] ?? null); + $set('utm_term', $query['utm_term'] ?? null); + $set('utm_content', $query['utm_content'] ?? null); + } + }) + ->afterStateUpdated(function ($state, Set $set) { + if (! $state) { + return; + } + $parts = parse_url($state); + if (isset($parts['query'])) { + parse_str($parts['query'], $query); + $set('utm_source', $query['utm_source'] ?? null); + $set('utm_medium', $query['utm_medium'] ?? null); + $set('utm_campaign', $query['utm_campaign'] ?? null); + $set('utm_term', $query['utm_term'] ?? null); + $set('utm_content', $query['utm_content'] ?? null); + } else { + $set('utm_source', null); + $set('utm_medium', null); + $set('utm_campaign', null); + $set('utm_term', null); + $set('utm_content', null); + } + }) + ->columnSpanFull(), + + TextInput::make('url_key') + ->label(__('filament-short-url::default.url_key')) + ->helperText(__('filament-short-url::default.url_key_helper')) + ->alphaDash() + ->maxLength(32) + ->unique('short_urls', 'url_key', ignoreRecord: true) + ->suffixAction( + Action::make('regenerate') + ->icon('heroicon-o-arrow-path') + ->tooltip('Generate new key') + ->action(function (Set $set): void { + $set('url_key', app(ShortUrlService::class)->generateKey()); + }) + ) + ->placeholder('auto-generated'), + + Select::make('redirect_status_code') + ->label(__('filament-short-url::default.redirect_code')) + ->options([ + 302 => __('filament-short-url::default.redirect_code_302'), + 301 => __('filament-short-url::default.redirect_code_301'), + ]) + ->default(302) + ->required(), + ])->columns(2), + + Section::make('Options')->schema([ + Toggle::make('is_enabled') + ->label(__('filament-short-url::default.status')) + ->default(true) + ->inline(false), + + Toggle::make('single_use') + ->label(__('filament-short-url::default.single_use')) + ->helperText(__('filament-short-url::default.single_use_helper')) + ->default(false) + ->inline(false), + + Toggle::make('forward_query_params') + ->label(__('filament-short-url::default.forward_query_params')) + ->helperText(__('filament-short-url::default.forward_query_params_helper')) + ->default(false) + ->inline(false), + + DateTimePicker::make('expires_at') + ->label(__('filament-short-url::default.expires_at')) + ->nullable() + ->native(false), + ])->columns(2), + + Section::make('Internal Notes')->schema([ + Textarea::make('notes') + ->label(__('filament-short-url::default.notes')) + ->rows(3) + ->columnSpanFull(), + ]), + ]); + } + + public static function syncUtmToDestination(Get $get, Set $set): void + { + $url = $get('destination_url'); + if (! $url) { + return; + } + + $parts = parse_url($url); + $query = []; + if (isset($parts['query'])) { + parse_str($parts['query'], $query); + } + + // List of UTM parameters we build + $utms = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content']; + foreach ($utms as $utm) { + $val = $get($utm); + if ($val !== null && $val !== '') { + $query[$utm] = $val; + } else { + unset($query[$utm]); + } + } + + $scheme = isset($parts['scheme']) ? $parts['scheme'].'://' : ''; + $host = $parts['host'] ?? ''; + $port = isset($parts['port']) ? ':'.$parts['port'] : ''; + $path = $parts['path'] ?? ''; + $queryString = ! empty($query) ? '?'.http_build_query($query) : ''; + $fragment = isset($parts['fragment']) ? '#'.$parts['fragment'] : ''; + + $set('destination_url', $scheme.$host.$port.$path.$queryString.$fragment); + } + + private static function trackingTab(): Tab + { + return Tab::make(__('filament-short-url::default.tab_tracking')) + ->icon('heroicon-o-chart-bar') + ->schema([ + Section::make('Visit Tracking') + ->schema([ + Toggle::make('track_visits') + ->label(__('filament-short-url::default.track_visits')) + ->default(true) + ->live() + ->inline(false) + ->columnSpanFull(), + ])->columns(1), + + Section::make('Tracked Fields') + ->schema([ + Toggle::make('track_ip_address') + ->label(__('filament-short-url::default.track_ip')) + ->default(true) + ->inline(false) + ->disabled(fn (Get $get): bool => ! $get('track_visits')), + + Toggle::make('track_browser') + ->label(__('filament-short-url::default.track_browser')) + ->default(true) + ->inline(false) + ->disabled(fn (Get $get): bool => ! $get('track_visits')), + + Toggle::make('track_browser_version') + ->label(__('filament-short-url::default.track_browser_version')) + ->default(true) + ->inline(false) + ->disabled(fn (Get $get): bool => ! $get('track_visits')), + + Toggle::make('track_operating_system') + ->label(__('filament-short-url::default.track_os')) + ->default(true) + ->inline(false) + ->disabled(fn (Get $get): bool => ! $get('track_visits')), + + Toggle::make('track_operating_system_version') + ->label(__('filament-short-url::default.track_os_version')) + ->default(true) + ->inline(false) + ->disabled(fn (Get $get): bool => ! $get('track_visits')), + + Toggle::make('track_device_type') + ->label(__('filament-short-url::default.track_device_type')) + ->default(true) + ->inline(false) + ->disabled(fn (Get $get): bool => ! $get('track_visits')), + + Toggle::make('track_referer_url') + ->label(__('filament-short-url::default.track_referer')) + ->default(true) + ->inline(false) + ->disabled(fn (Get $get): bool => ! $get('track_visits')), + ]) + ->columns(4) + ->hidden(fn (Get $get): bool => ! $get('track_visits')), + + Section::make(__('filament-short-url::default.utm_builder')) + ->description(__('filament-short-url::default.utm_builder_helper')) + ->collapsible() + ->collapsed() + ->schema([ + TextInput::make('utm_source') + ->label(__('filament-short-url::default.utm_source')) + ->placeholder(__('filament-short-url::default.utm_source_placeholder')) + ->dehydrated(false) + ->live(onBlur: true) + ->afterStateUpdated(fn (Get $get, Set $set) => static::syncUtmToDestination($get, $set)), + + TextInput::make('utm_medium') + ->label(__('filament-short-url::default.utm_medium')) + ->placeholder(__('filament-short-url::default.utm_medium_placeholder')) + ->dehydrated(false) + ->live(onBlur: true) + ->afterStateUpdated(fn (Get $get, Set $set) => static::syncUtmToDestination($get, $set)), + + TextInput::make('utm_campaign') + ->label(__('filament-short-url::default.utm_campaign')) + ->placeholder(__('filament-short-url::default.utm_campaign_placeholder')) + ->dehydrated(false) + ->live(onBlur: true) + ->afterStateUpdated(fn (Get $get, Set $set) => static::syncUtmToDestination($get, $set)), + + TextInput::make('utm_term') + ->label(__('filament-short-url::default.utm_term')) + ->placeholder(__('filament-short-url::default.utm_term_placeholder')) + ->dehydrated(false) + ->live(onBlur: true) + ->afterStateUpdated(fn (Get $get, Set $set) => static::syncUtmToDestination($get, $set)), + + TextInput::make('utm_content') + ->label(__('filament-short-url::default.utm_content')) + ->placeholder(__('filament-short-url::default.utm_content_placeholder')) + ->dehydrated(false) + ->live(onBlur: true) + ->columnSpanFull() + ->afterStateUpdated(fn (Get $get, Set $set) => static::syncUtmToDestination($get, $set)), + ]) + ->columns(2), + + Section::make('Third-Party Analytics') + ->schema([ + TextInput::make('ga_tracking_id') + ->label(__('filament-short-url::default.ga_tracking_id')) + ->helperText(__('filament-short-url::default.ga_tracking_id_helper')) + ->placeholder('G-XXXXXXXXXX') + ->regex('/^G-[A-Z0-9]+$/') + ->nullable(), + ]), + ]); + } + + private static function qrDesignTab(): Tab + { + return Tab::make(__('filament-short-url::default.tab_qr_design')) + ->icon('heroicon-o-qr-code') + ->schema([ + TextInput::make('qr_options') + ->extraAttributes([ + 'id' => 'qr-options-json-input', + 'style' => 'position:absolute;width:1px;height:1px;opacity:0;pointer-events:none', + 'aria-hidden' => 'true', + ]) + ->dehydrateStateUsing(fn (?string $state): array => json_decode($state ?? '{}', true) ?: []) + ->afterStateHydrated(function (TextInput $component, mixed $state): void { + $component->state(is_array($state) ? json_encode($state) : ($state ?? '{}')); + }) + ->columnSpanFull() + ->label(''), + + ViewField::make('qr_designer') + ->view('filament-short-url::qr-designer') + ->columnSpanFull() + ->dehydrated(false), + ]); + } +} diff --git a/src/Filament/Resources/ShortUrlResource/Tables/ShortUrlsTable.php b/src/Filament/Resources/ShortUrlResource/Tables/ShortUrlsTable.php new file mode 100644 index 0000000..124598c --- /dev/null +++ b/src/Filament/Resources/ShortUrlResource/Tables/ShortUrlsTable.php @@ -0,0 +1,144 @@ +columns([ + TextColumn::make('url_key') + ->label(__('filament-short-url::default.col_short_url')) + ->getStateUsing(fn (ShortUrl $record): string => $record->getShortUrl()) + ->copyable() + ->copyMessage(__('filament-short-url::default.qr_copied')) + ->fontFamily('mono') + ->weight(FontWeight::SemiBold) + ->searchable(query: fn ($query, string $search) => $query->where('url_key', 'like', "%{$search}%")), + + TextColumn::make('destination_url') + ->label(__('filament-short-url::default.col_destination_url')) + ->limit(45) + ->tooltip(fn (ShortUrl $record): string => $record->destination_url) + ->url(fn (ShortUrl $record): string => $record->destination_url, shouldOpenInNewTab: true) + ->searchable(), + + TextColumn::make('total_visits') + ->label(__('filament-short-url::default.col_total_visits')) + ->numeric() + ->badge() + ->color('gray') + ->sortable(), + + TextColumn::make('unique_visits') + ->label(__('filament-short-url::default.stats_card_unique')) + ->numeric() + ->badge() + ->color('info') + ->sortable(), + + ToggleColumn::make('is_enabled') + ->label(__('filament-short-url::default.col_status')) + ->sortable(), + + IconColumn::make('track_visits') + ->label(__('filament-short-url::default.track_visits')) + ->boolean() + ->trueIcon('heroicon-o-signal') + ->falseIcon('heroicon-o-signal-slash') + ->trueColor('success') + ->falseColor('gray'), + + TextColumn::make('expires_at') + ->label(__('filament-short-url::default.col_expires_at')) + ->dateTime('d M Y') + ->placeholder('Never') + ->color(fn (ShortUrl $record): string => $record->isExpired() ? 'danger' : 'gray') + ->sortable(), + + TextColumn::make('created_at') + ->label(__('filament-short-url::default.col_created_at')) + ->since() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + TernaryFilter::make('is_enabled') + ->label(__('filament-short-url::default.col_status')) + ->trueLabel('Active only') + ->falseLabel('Inactive only'), + + TernaryFilter::make('track_visits') + ->label(__('filament-short-url::default.track_visits')) + ->trueLabel('Tracking enabled') + ->falseLabel('Tracking disabled'), + + SelectFilter::make('single_use') + ->label(__('filament-short-url::default.single_use')) + ->options([ + '0' => 'Multi-use', + '1' => 'Single-use', + ]), + ]) + ->recordActions([ + Action::make('stats') + ->label(__('filament-short-url::default.action_stats')) + ->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', + ]), + + EditAction::make() + ->icon('heroicon-o-pencil-square') + ->size('sm') + ->color('gray'), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + BulkAction::make('enable') + ->label('Enable selected') + ->icon('heroicon-o-check-circle') + ->action(fn ($records) => $records->each->update(['is_enabled' => true])) + ->deselectRecordsAfterCompletion(), + + BulkAction::make('disable') + ->label('Disable selected') + ->icon('heroicon-o-x-circle') + ->action(fn ($records) => $records->each->update(['is_enabled' => false])) + ->deselectRecordsAfterCompletion(), + + DeleteBulkAction::make(), + ]), + ]) + ->defaultSort('created_at', 'desc'); + } +} diff --git a/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlStatsOverview.php b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlStatsOverview.php new file mode 100644 index 0000000..bfbdc04 --- /dev/null +++ b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlStatsOverview.php @@ -0,0 +1,43 @@ +record) { + return []; + } + + $stats = $this->record->getCachedStats(); + + return [ + Stat::make(__('filament-short-url::default.stats_card_total'), number_format($stats['totalVisits'] ?? 0)) + ->icon('heroicon-o-cursor-arrow-rays') + ->color('primary'), + + Stat::make(__('filament-short-url::default.stats_card_unique'), number_format($stats['uniqueVisits'] ?? 0)) + ->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') + ->color('warning'), + + Stat::make(__('filament-short-url::default.stats_card_week'), number_format($stats['visitsThisWeek'] ?? 0)) + ->icon('heroicon-o-calendar-days') + ->color('success'), + + Stat::make(__('filament-short-url::default.stats_card_month'), number_format($stats['visitsThisMonth'] ?? 0)) + ->icon('heroicon-o-calendar') + ->color('gray'), + ]; + } +} diff --git a/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsBottomBreakdown.php b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsBottomBreakdown.php new file mode 100644 index 0000000..57a38b7 --- /dev/null +++ b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsBottomBreakdown.php @@ -0,0 +1,46 @@ +record = $record; + } + + /** + * @return array + */ + protected function getViewData(): array + { + if (! $this->record) { + return [ + 'visitsByDevice' => [], + 'visitsByBrowser' => [], + 'visitsByOs' => [], + 'visitsByReferer' => [], + 'totalVisits' => 0, + ]; + } + + $stats = $this->record->getCachedStats(); + + return [ + 'visitsByDevice' => $stats['visitsByDevice'] ?? [], + 'visitsByBrowser' => $stats['visitsByBrowser'] ?? [], + 'visitsByOs' => $stats['visitsByOs'] ?? [], + 'visitsByReferer' => $stats['visitsByReferer'] ?? [], + 'totalVisits' => $stats['totalVisits'] ?? 0, + ]; + } +} diff --git a/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsChart.php b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsChart.php new file mode 100644 index 0000000..5d47b12 --- /dev/null +++ b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsChart.php @@ -0,0 +1,57 @@ + 2, + ]; + + public function getHeading(): string + { + return __('filament-short-url::default.stats_chart_title'); + } + + protected function getData(): array + { + if (! $this->record) { + return [ + 'datasets' => [], + 'labels' => [], + ]; + } + + $stats = $this->record->getCachedStats(); + $visitsByDay = $stats['visitsByDay'] ?? []; + + return [ + 'datasets' => [ + [ + 'label' => __('filament-short-url::default.qr_chart_visits_label'), + 'data' => array_values($visitsByDay), + 'borderColor' => 'rgb(99, 102, 241)', + 'backgroundColor' => 'rgba(99, 102, 241, 0.08)', + 'borderWidth' => 2, + 'pointBackgroundColor' => 'rgb(99, 102, 241)', + 'pointRadius' => 3, + 'tension' => 0.4, + 'fill' => true, + ], + ], + 'labels' => array_keys($visitsByDay), + ]; + } + + protected function getType(): string + { + return 'line'; + } +} diff --git a/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsRightBreakdown.php b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsRightBreakdown.php new file mode 100644 index 0000000..7d56657 --- /dev/null +++ b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsRightBreakdown.php @@ -0,0 +1,42 @@ + 1, + ]; + + public function mount(?ShortUrl $record = null): void + { + $this->record = $record; + } + + /** + * @return array + */ + protected function getViewData(): array + { + if (! $this->record) { + return [ + 'visitsByCountry' => [], + 'totalVisits' => 0, + ]; + } + + $stats = $this->record->getCachedStats(); + + return [ + 'visitsByCountry' => $stats['visitsByCountry'] ?? [], + 'totalVisits' => $stats['totalVisits'] ?? 0, + ]; + } +} diff --git a/src/FilamentShortUrlPlugin.php b/src/FilamentShortUrlPlugin.php new file mode 100644 index 0000000..e4ef9dc --- /dev/null +++ b/src/FilamentShortUrlPlugin.php @@ -0,0 +1,82 @@ +getId()); + } + + // ─── Plugin interface ──────────────────────────────────────────────────── + + public function getId(): string + { + return 'filament-short-url'; + } + + public function register(Panel $panel): void + { + $panel + ->resources([ + ShortUrlResource::class, + ]); + } + + public function boot(Panel $panel): void + { + // Configuration is consumed by ShortUrlResource via FilamentShortUrlPlugin::get() + } + + // ─── Fluent configuration API ──────────────────────────────────────────── + + /** + * Set the navigation group for the Short URLs resource. + * + * @example FilamentShortUrlPlugin::make()->navigationGroup('Marketing') + */ + public function navigationGroup(string $group): static + { + $this->navigationGroup = $group; + + return $this; + } + + public function getNavigationGroup(): ?string + { + return $this->navigationGroup; + } + + /** + * Set the navigation sort order. + */ + public function navigationSort(int $sort): static + { + $this->navigationSort = $sort; + + return $this; + } + + public function getNavigationSort(): ?int + { + return $this->navigationSort; + } +} diff --git a/src/FilamentShortUrlServiceProvider.php b/src/FilamentShortUrlServiceProvider.php new file mode 100644 index 0000000..f0f2de0 --- /dev/null +++ b/src/FilamentShortUrlServiceProvider.php @@ -0,0 +1,42 @@ +name(static::$name) + ->hasConfigFile('filament-short-url') + ->hasViews('filament-short-url') + ->hasTranslations() + ->hasMigrations([ + '2024_01_01_000001_create_short_urls_table', + '2024_01_01_000002_create_short_url_visits_table', + ]) + ->hasRoutes(['web']); + } + + public function packageRegistered(): void + { + $this->app->singleton(ShortUrlSettingsManager::class); + $this->app->make(ShortUrlSettingsManager::class)->applyConfigOverrides(); + + // Bind services as singletons for efficient reuse + $this->app->singleton(UserAgentParser::class); + $this->app->singleton(GeoIpService::class); + $this->app->singleton(ShortUrlService::class); + $this->app->singleton(ShortUrlTracker::class); + } +} diff --git a/src/Http/Controllers/ShortUrlRedirectController.php b/src/Http/Controllers/ShortUrlRedirectController.php new file mode 100644 index 0000000..6a7dbee --- /dev/null +++ b/src/Http/Controllers/ShortUrlRedirectController.php @@ -0,0 +1,69 @@ +isActive()) { + abort(410); + } + + if ($shortUrl->track_visits) { + $connection = config('filament-short-url.queue_connection'); + $ipAddress = ClientIpExtractor::getIp($request); + $countryCode = ClientIpExtractor::getCountryCode($request); + + $job = new TrackShortUrlVisitJob( + shortUrl: $shortUrl, + ipAddress: $ipAddress, + userAgent: $request->userAgent() ?? '', + refererUrl: $request->header('Referer'), + countryCode: $countryCode, + ); + + if ($connection && $connection !== 'default') { + dispatch($job->onConnection($connection)); + } else { + dispatch($job); + } + } + + // Atomically disable single-use URLs — prevents race condition under concurrent load + if ($shortUrl->single_use) { + $affected = ShortUrl::where('id', $shortUrl->id) + ->where('is_enabled', true) + ->update(['is_enabled' => false]); + + // Another request beat us to it — this visit should 410 + if ($affected === 0) { + abort(410); + } + } + + $redirectUrl = $this->service->resolveRedirectUrl($shortUrl, $request); + + return redirect()->away($redirectUrl, $shortUrl->redirect_status_code); + } +} diff --git a/src/Jobs/TrackShortUrlVisitJob.php b/src/Jobs/TrackShortUrlVisitJob.php new file mode 100644 index 0000000..9c5df88 --- /dev/null +++ b/src/Jobs/TrackShortUrlVisitJob.php @@ -0,0 +1,125 @@ +onQueue(config('filament-short-url.queue_name', 'default')); + } + + public function handle(ShortUrlTracker $tracker): void + { + // Re-fetch fresh model to avoid acting on stale state + $shortUrl = ShortUrl::find($this->shortUrl->id); + + if (! $shortUrl || ! $shortUrl->track_visits) { + return; + } + + // Reconstruct a minimal Request-like object for the tracker + $request = Request::create('/', 'GET', [], [], [], [ + 'REMOTE_ADDR' => $this->ipAddress, + 'HTTP_USER_AGENT' => $this->userAgent, + 'HTTP_REFERER' => $this->refererUrl, + ]); + + $countryCode = isset($this->countryCode) ? $this->countryCode : null; + $visit = $tracker->record($shortUrl, $request, $countryCode); + + // Null means bot/crawler — nothing to dispatch or report + if ($visit === null) { + return; + } + + // Fire event for user listeners + ShortUrlVisited::dispatch($shortUrl, $visit); + + // Optional GA4 Measurement Protocol integration + if ($shortUrl->ga_tracking_id && config('filament-short-url.ga4.api_secret')) { + $this->sendGa4Hit($shortUrl, $visit); + } + } + + private function sendGa4Hit(ShortUrl $shortUrl, ShortUrlVisit $visit): void + { + $apiSecret = config('filament-short-url.ga4.api_secret'); + $firebaseAppId = config('filament-short-url.ga4.firebase_app_id'); + + // GA4 Measurement Protocol requires a client_id + $clientId = Str::uuid()->toString(); + + $payload = [ + 'client_id' => $clientId, + 'events' => [ + [ + 'name' => 'short_url_visit', + 'params' => [ + 'url_key' => $shortUrl->url_key, + 'destination_url' => $shortUrl->destination_url, + 'device_type' => $visit->device_type ?? 'unknown', + 'country' => $visit->country ?? 'unknown', + 'browser' => $visit->browser ?? 'unknown', + ], + ], + ], + ]; + + $queryParams = ['api_secret' => $apiSecret]; + if ($firebaseAppId) { + $queryParams['firebase_app_id'] = $firebaseAppId; + } else { + $queryParams['measurement_id'] = $shortUrl->ga_tracking_id; + } + + try { + Http::timeout(5) + ->post( + 'https://www.google-analytics.com/mp/collect?'.http_build_query($queryParams), + $payload + ); + } catch (\Throwable $e) { + Log::warning('[FilamentShortUrl] GA4 Measurement Protocol hit failed', [ + 'url_key' => $shortUrl->url_key, + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/src/Models/ShortUrl.php b/src/Models/ShortUrl.php new file mode 100644 index 0000000..1b3a56f --- /dev/null +++ b/src/Models/ShortUrl.php @@ -0,0 +1,349 @@ + */ + protected $casts = [ + 'is_enabled' => 'boolean', + 'single_use' => 'boolean', + 'forward_query_params' => 'boolean', + 'track_visits' => 'boolean', + 'track_ip_address' => 'boolean', + 'track_browser' => 'boolean', + 'track_browser_version' => 'boolean', + 'track_operating_system' => 'boolean', + 'track_operating_system_version' => 'boolean', + 'track_device_type' => 'boolean', + 'track_referer_url' => 'boolean', + 'qr_options' => 'array', + 'activated_at' => 'datetime', + 'deactivated_at' => 'datetime', + 'expires_at' => 'datetime', + ]; + + // ─── Relations ─────────────────────────────────────────────────────────── + + public function visits(): HasMany + { + return $this->hasMany(ShortUrlVisit::class, 'short_url_id'); + } + + // ─── Scopes ────────────────────────────────────────────────────────────── + + public function scopeEnabled(Builder $query): Builder + { + return $query->where('is_enabled', true); + } + + public function scopeActive(Builder $query): Builder + { + return $query + ->enabled() + ->where(fn (Builder $q) => $q + ->whereNull('expires_at') + ->orWhere('expires_at', '>', now()) + ); + } + + public function scopeExpired(Builder $query): Builder + { + return $query->whereNotNull('expires_at')->where('expires_at', '<=', now()); + } + + // ─── Static Finders ────────────────────────────────────────────────────── + + /** + * Find by URL key — cached for ultra-fast redirects. + * Cache is invalidated automatically via model events on save/delete. + */ + public static function findByKey(string $key): ?static + { + $ttl = config('filament-short-url.cache_ttl', 3600); + + if ($ttl <= 0) { + return static::where('url_key', $key)->first(); + } + + return cache()->remember( + "filament-short-url:{$key}", + $ttl, + fn () => static::where('url_key', $key)->first() + ); + } + + /** + * Bust the cache when the model is saved or deleted. + */ + protected static function booted(): void + { + static::saved(fn (self $m) => cache()->forget("filament-short-url:{$m->url_key}")); + static::deleted(fn (self $m) => cache()->forget("filament-short-url:{$m->url_key}")); + } + + /** @return Collection */ + public static function findByDestinationUrl(string $url): Collection + { + return static::where('destination_url', $url)->get(); + } + + /** + * Start building a ShortUrl programmatically. + * + * @example ShortUrl::destination('https://example.com')->withTracing(['utm_source' => 'linkedin'])->create(); + */ + public static function destination(string $url): ShortUrlBuilder + { + return app(ShortUrlService::class)->destination($url); + } + + // ─── Helpers ───────────────────────────────────────────────────────────── + + public function isActive(): bool + { + if (! $this->is_enabled) { + return false; + } + + // Not yet active + if ($this->activated_at && $this->activated_at->isFuture()) { + return false; + } + + // Explicitly deactivated + if ($this->deactivated_at && $this->deactivated_at->isPast()) { + return false; + } + + // TTL expiry + if ($this->expires_at && $this->expires_at->isPast()) { + return false; + } + + return true; + } + + public function isExpired(): bool + { + return $this->expires_at !== null && $this->expires_at->isPast(); + } + + public function trackingEnabled(): bool + { + return $this->track_visits; + } + + /** + * @return array + */ + public function trackingFields(): array + { + if (! $this->track_visits) { + return []; + } + + return array_keys(array_filter([ + 'ip_address' => $this->track_ip_address, + 'browser' => $this->track_browser, + 'browser_version' => $this->track_browser_version, + 'operating_system' => $this->track_operating_system, + 'operating_system_version' => $this->track_operating_system_version, + 'device_type' => $this->track_device_type, + 'referer_url' => $this->track_referer_url, + ])); + } + + /** @return array */ + public function getQrOptions(): array + { + return array_merge( + config('filament-short-url.qr_defaults', []), + $this->qr_options ?? [] + ); + } + + public function getShortUrl(): string + { + $prefix = config('filament-short-url.route_prefix', 's'); + + return rtrim(config('app.url'), '/')."/{$prefix}/{$this->url_key}"; + } + + /** + * Atomically increment visit counters — single query when unique, + * to avoid race conditions and two round-trips. + */ + public function incrementVisits(bool $isUnique = false): void + { + $this->newQuery() + ->where('id', $this->id) + ->increment('total_visits', 1, $isUnique ? ['unique_visits' => DB::raw('unique_visits + 1')] : []); + } + + /** + * Get cached statistics for this short URL. + * + * @return array + */ + public function getCachedStats(): array + { + $cacheTtl = (int) config('filament-short-url.geo_ip.stats_cache_ttl', 300); + $cacheKey = "short_url_stats_{$this->id}"; + + return cache()->remember($cacheKey, $cacheTtl, function () { + $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(); + + // 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(); + + $visitsByDay = []; + for ($i = 29; $i >= 0; $i--) { + $date = now()->subDays($i)->format('Y-m-d'); + $visitsByDay[$date] = $visitsByDayRaw[$date] ?? 0; + } + + // Top countries + $visitsByCountry = (clone $visits) + ->whereNotNull('country') + ->selectRaw('country, country_code, COUNT(*) as count') + ->groupBy('country', 'country_code') + ->orderByDesc('count') + ->limit(10) + ->get() + ->mapWithKeys(fn ($row) => [$row->country => $row->count]) + ->toArray(); + + // Device types + $visitsByDevice = (clone $visits) + ->whereNotNull('device_type') + ->selectRaw('device_type, COUNT(*) as count') + ->groupBy('device_type') + ->orderByDesc('count') + ->pluck('count', 'device_type') + ->toArray(); + + // Browsers + $visitsByBrowser = (clone $visits) + ->whereNotNull('browser') + ->selectRaw('browser, COUNT(*) as count') + ->groupBy('browser') + ->orderByDesc('count') + ->limit(8) + ->pluck('count', 'browser') + ->toArray(); + + // Operating systems + $visitsByOs = (clone $visits) + ->whereNotNull('operating_system') + ->selectRaw('operating_system, COUNT(*) as count') + ->groupBy('operating_system') + ->orderByDesc('count') + ->limit(8) + ->pluck('count', 'operating_system') + ->toArray(); + + // Top referers + $visitsByReferer = (clone $visits) + ->whereNotNull('referer_url') + ->selectRaw('referer_url, COUNT(*) as count') + ->groupBy('referer_url') + ->orderByDesc('count') + ->limit(10) + ->pluck('count', 'referer_url') + ->toArray(); + + return [ + 'totalVisits' => $totalVisits, + 'uniqueVisits' => $uniqueVisits, + 'visitsToday' => $visitsToday, + 'visitsThisWeek' => $visitsThisWeek, + 'visitsThisMonth' => $visitsThisMonth, + 'visitsByDay' => $visitsByDay, + 'visitsByCountry' => $visitsByCountry, + 'visitsByDevice' => $visitsByDevice, + 'visitsByBrowser' => $visitsByBrowser, + 'visitsByOs' => $visitsByOs, + 'visitsByReferer' => $visitsByReferer, + ]; + }); + } +} diff --git a/src/Models/ShortUrlVisit.php b/src/Models/ShortUrlVisit.php new file mode 100644 index 0000000..d7980cd --- /dev/null +++ b/src/Models/ShortUrlVisit.php @@ -0,0 +1,54 @@ + */ + protected $casts = [ + 'visited_at' => 'datetime', + ]; + + public function shortUrl(): BelongsTo + { + return $this->belongsTo(ShortUrl::class, 'short_url_id'); + } +} diff --git a/src/Services/ClientIpExtractor.php b/src/Services/ClientIpExtractor.php new file mode 100644 index 0000000..756264c --- /dev/null +++ b/src/Services/ClientIpExtractor.php @@ -0,0 +1,62 @@ +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]); + } + + // 5. Fallback to standard Laravel/Symfony IP resolver + return $request->ip() ?? '0.0.0.0'; + } + + /** + * Extract the edge-provided 2-letter country code from CDN/proxy headers. + */ + public static function getCountryCode(Request $request): ?string + { + // 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)); + } + + return null; + } +} diff --git a/src/Services/GeoIpService.php b/src/Services/GeoIpService.php new file mode 100644 index 0000000..8e88188 --- /dev/null +++ b/src/Services/GeoIpService.php @@ -0,0 +1,222 @@ + */ + private static array $runtimeCache = []; + + /** + * Resolve country data for the given IP address. + * + * @return array{country: string|null, country_code: string|null} + */ + public function resolve(string $ip, ?string $preResolvedCountryCode = null): array + { + $empty = ['country' => null, 'country_code' => null]; + + if (! config('filament-short-url.geo_ip.enabled', true)) { + return $empty; + } + + // 1. Prioritise edge-provided CDN country code (extremely fast, zero latency) + if ($preResolvedCountryCode) { + $code = strtoupper(trim($preResolvedCountryCode)); + + return [ + 'country' => $this->getCountryName($code), + 'country_code' => $code, + ]; + } + + if ($this->isPrivateIp($ip)) { + return $empty; + } + + $cacheKey = 'short_url_geo_'.hash('sha256', $ip); + + // Check runtime cache first (avoids repeated DB/Redis reads in same request) + if (isset(self::$runtimeCache[$cacheKey])) { + return self::$runtimeCache[$cacheKey]; + } + + $ttl = (int) config('filament-short-url.geo_ip.cache_ttl', 86400); + + $result = Cache::remember($cacheKey, $ttl, function () use ($ip, $empty): array { + $driver = config('filament-short-url.geo_ip.driver', 'headers'); + + if ($driver === 'maxmind') { + return $this->lookupMaxMind($ip) ?? $empty; + } + + if ($driver === 'ip-api') { + return $this->fetchFromApi($ip) ?? $empty; + } + + return $empty; + }); + + self::$runtimeCache[$cacheKey] = $result; + + return $result; + } + + /** + * Resolve country name from a 2-letter ISO country code. + */ + private function getCountryName(?string $code): ?string + { + if (! $code) { + return null; + } + + $code = strtoupper(trim($code)); + + if (class_exists(\Locale::class)) { + try { + $name = \Locale::getDisplayRegion('-'.$code, 'en'); + if ($name && $name !== $code) { + return $name; + } + } catch (\Throwable $e) { + // Ignore locale failures + } + } + + // Common country code fallback dictionary + $countries = [ + 'PL' => 'Poland', + 'US' => 'United States', + 'GB' => 'United Kingdom', + 'DE' => 'Germany', + 'FR' => 'France', + 'ES' => 'Spain', + 'IT' => 'Italy', + 'NL' => 'Netherlands', + 'CA' => 'Canada', + 'AU' => 'Australia', + 'BR' => 'Brazil', + 'CN' => 'China', + 'IN' => 'India', + 'JP' => 'Japan', + 'RU' => 'Russia', + 'UA' => 'Ukraine', + 'AE' => 'United Arab Emirates', + 'CH' => 'Switzerland', + 'SE' => 'Sweden', + 'NO' => 'Norway', + 'FI' => 'Finland', + 'DK' => 'Denmark', + ]; + + return $countries[$code] ?? $code; + } + + /** + * Resolve country using local MaxMind GeoIP2 DB. + * + * @return array{country: string, country_code: string}|null + */ + private function lookupMaxMind(string $ip): ?array + { + if (! class_exists(Reader::class)) { + Log::warning('[FilamentShortUrl] MaxMind driver selected, but geoip2/geoip2 library is not installed.'); + + return null; + } + + $dbPath = config('filament-short-url.geo_ip.maxmind.database_path'); + + if (! $dbPath || ! file_exists($dbPath)) { + Log::warning('[FilamentShortUrl] MaxMind database file not found at: '.$dbPath); + + return null; + } + + try { + $reader = new Reader($dbPath); + $record = $reader->country($ip); + + return [ + 'country' => $record->country->name ?? null, + 'country_code' => $record->country->isoCode ?? null, + ]; + } catch (\Throwable $e) { + Log::warning('[FilamentShortUrl] MaxMind GeoIP lookup failed', [ + 'ip' => substr($ip, 0, 8).'***', + 'error' => $e->getMessage(), + ]); + + return null; + } + } + + /** + * Resolve country using free ip-api.com. + * + * @return array{country: string, country_code: string}|null + */ + private function fetchFromApi(string $ip): ?array + { + $timeout = (int) config('filament-short-url.geo_ip.timeout', 3); + + try { + $response = Http::timeout($timeout) + ->get(sprintf(self::API_URL, urlencode($ip))); + + if (! $response->successful()) { + return null; + } + + $data = $response->json(); + + if (($data['status'] ?? '') !== 'success') { + return null; + } + + return [ + 'country' => $data['country'] ?? null, + 'country_code' => $data['countryCode'] ?? null, + ]; + } catch (\Throwable $e) { + Log::warning('[FilamentShortUrl] GeoIP lookup failed', [ + 'ip' => substr($ip, 0, 8).'***', + 'error' => $e->getMessage(), + ]); + + return null; + } + } + + /** + * RFC 1918 + loopback + link-local ranges — skip API call for these. + */ + private function isPrivateIp(string $ip): bool + { + if ($ip === '127.0.0.1' || $ip === '::1') { + return true; + } + + return filter_var( + $ip, + FILTER_VALIDATE_IP, + FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE + ) === false; + } +} diff --git a/src/Services/ShortUrlBuilder.php b/src/Services/ShortUrlBuilder.php new file mode 100644 index 0000000..8b71b6b --- /dev/null +++ b/src/Services/ShortUrlBuilder.php @@ -0,0 +1,92 @@ +data['destination_url'] = $destinationUrl; + } + + public function urlKey(string $key): static + { + $this->data['url_key'] = $key; + + return $this; + } + + public function notes(string $notes): static + { + $this->data['notes'] = $notes; + + return $this; + } + + public function singleUse(bool $singleUse = true): static + { + $this->data['single_use'] = $singleUse; + + return $this; + } + + public function forwardQueryParams(bool $forward = true): static + { + $this->data['forward_query_params'] = $forward; + + return $this; + } + + public function expiresAt(\DateTimeInterface|Carbon|null $date): static + { + $this->data['expires_at'] = $date ? Carbon::instance($date) : null; + + return $this; + } + + public function trackVisits(bool $track = true): static + { + $this->data['track_visits'] = $track; + + return $this; + } + + /** + * Append tracing/UTM parameters directly to the destination URL. + * + * @param array $tracing + */ + public function withTracing(array $tracing): static + { + if (empty($tracing)) { + return $this; + } + + $destination = $this->data['destination_url']; + $separator = str_contains($destination, '?') ? '&' : '?'; + + // Filter out empty or null values to keep the URL clean + $filtered = array_filter($tracing, fn ($value) => $value !== null && $value !== ''); + + if (! empty($filtered)) { + $this->data['destination_url'] = $destination.$separator.http_build_query($filtered); + } + + return $this; + } + + /** + * Build and persist the ShortUrl record. + */ + public function create(): ShortUrl + { + return $this->service->create($this->data); + } +} diff --git a/src/Services/ShortUrlService.php b/src/Services/ShortUrlService.php new file mode 100644 index 0000000..42fb7c2 --- /dev/null +++ b/src/Services/ShortUrlService.php @@ -0,0 +1,126 @@ +keyExists($key)); + + return $key; + } + + public function keyExists(string $key): bool + { + return ShortUrl::where('url_key', $key)->exists(); + } + + /** + * Create and persist a new ShortUrl. + * + * @param array{ + * destination_url: string, + * url_key?: string, + * notes?: string, + * is_enabled?: bool, + * redirect_status_code?: int, + * single_use?: bool, + * forward_query_params?: bool, + * expires_at?: Carbon|null, + * track_visits?: bool, + * track_ip_address?: bool, + * track_browser?: bool, + * track_browser_version?: bool, + * track_operating_system?: bool, + * track_operating_system_version?: bool, + * track_device_type?: bool, + * track_referer_url?: bool, + * qr_options?: array, + * ga_tracking_id?: string|null, + * } $data + */ + public function create(array $data): ShortUrl + { + $tracking = config('filament-short-url.tracking', []); + $fields = $tracking['fields'] ?? []; + + $data = array_merge([ + 'is_enabled' => true, + 'redirect_status_code' => config('filament-short-url.redirect_status_code', 302), + 'single_use' => false, + 'forward_query_params' => false, + 'track_visits' => $tracking['enabled'] ?? true, + 'track_ip_address' => $fields['ip_address'] ?? true, + 'track_browser' => $fields['browser'] ?? true, + 'track_browser_version' => $fields['browser_version'] ?? true, + 'track_operating_system' => $fields['operating_system'] ?? true, + 'track_operating_system_version' => $fields['operating_system_version'] ?? true, + 'track_device_type' => $fields['device_type'] ?? true, + 'track_referer_url' => $fields['referer_url'] ?? true, + ], $data); + + if (empty($data['url_key'])) { + $data['url_key'] = $this->generateKey(); + } + + return ShortUrl::create($data); + } + + /** + * Build the full publicly accessible short URL string. + */ + public function buildShortUrl(ShortUrl $shortUrl): string + { + return $shortUrl->getShortUrl(); + } + + /** + * Resolve the final redirect target URL, optionally forwarding query params. + */ + public function resolveRedirectUrl(ShortUrl $shortUrl, Request $request): string + { + $destination = $shortUrl->destination_url; + + if (! $shortUrl->forward_query_params) { + return $destination; + } + + $queryParams = $request->query(); + + if (empty($queryParams)) { + return $destination; + } + + $separator = str_contains($destination, '?') ? '&' : '?'; + + return $destination.$separator.http_build_query($queryParams); + } +} diff --git a/src/Services/ShortUrlSettingsManager.php b/src/Services/ShortUrlSettingsManager.php new file mode 100644 index 0000000..8f4e410 --- /dev/null +++ b/src/Services/ShortUrlSettingsManager.php @@ -0,0 +1,143 @@ + + */ + public function all(): array + { + if ($this->cache !== null) { + return $this->cache; + } + + $path = $this->getSettingsPath(); + $stored = []; + + if (File::exists($path)) { + try { + $stored = json_decode(File::get($path), true) ?: []; + } catch (\Throwable) { + // Fallback to empty if json is corrupt + } + } + + // Merge stored settings with default values from config() + $this->cache = array_merge([ + 'route_prefix' => config('filament-short-url.route_prefix', 's'), + 'redirect_status_code' => config('filament-short-url.redirect_status_code', 302), + 'key_length' => config('filament-short-url.key_length', 6), + 'geo_ip_enabled' => config('filament-short-url.geo_ip.enabled', true), + 'geo_ip_driver' => config('filament-short-url.geo_ip.driver', 'headers'), + 'geo_ip_cache_ttl' => config('filament-short-url.geo_ip.cache_ttl', 86400), + 'geo_ip_timeout' => config('filament-short-url.geo_ip.timeout', 3), + 'maxmind_database_path' => config('filament-short-url.geo_ip.maxmind.database_path', database_path('geoip/GeoLite2-Country.mmdb')), + 'ga4_api_secret' => config('filament-short-url.ga4.api_secret'), + '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), + ], $stored); + + return $this->cache; + } + + public function get(string $key, mixed $default = null): mixed + { + return $this->all()[$key] ?? $default; + } + + /** + * Persist settings to JSON file. + * + * @param array $data + */ + public function set(array $data): void + { + $path = $this->getSettingsPath(); + $dir = dirname($path); + + if (! File::isDirectory($dir)) { + File::makeDirectory($dir, 0755, true); + } + + // Keep only supported settings keys to prevent bloat + $keys = [ + 'route_prefix', + 'redirect_status_code', + 'key_length', + 'geo_ip_enabled', + 'geo_ip_driver', + 'geo_ip_cache_ttl', + 'geo_ip_timeout', + 'maxmind_database_path', + 'ga4_api_secret', + 'ga4_firebase_app_id', + 'queue_connection', + 'cache_ttl', + ]; + + $filtered = array_intersect_key($data, array_flip($keys)); + + // Format datatypes properly + if (isset($filtered['redirect_status_code'])) { + $filtered['redirect_status_code'] = (int) $filtered['redirect_status_code']; + } + if (isset($filtered['key_length'])) { + $filtered['key_length'] = (int) $filtered['key_length']; + } + if (isset($filtered['geo_ip_enabled'])) { + $filtered['geo_ip_enabled'] = (bool) $filtered['geo_ip_enabled']; + } + if (isset($filtered['geo_ip_cache_ttl'])) { + $filtered['geo_ip_cache_ttl'] = (int) $filtered['geo_ip_cache_ttl']; + } + if (isset($filtered['geo_ip_timeout'])) { + $filtered['geo_ip_timeout'] = (int) $filtered['geo_ip_timeout']; + } + if (isset($filtered['cache_ttl'])) { + $filtered['cache_ttl'] = (int) $filtered['cache_ttl']; + } + + File::put($path, json_encode($filtered, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + $this->cache = null; + + // Apply immediately to current request config + $this->applyConfigOverrides(); + } + + /** + * Override standard Laravel config with user-configured settings. + */ + public function applyConfigOverrides(): void + { + $settings = $this->all(); + + config([ + 'filament-short-url.route_prefix' => $settings['route_prefix'], + 'filament-short-url.redirect_status_code' => $settings['redirect_status_code'], + 'filament-short-url.key_length' => $settings['key_length'], + 'filament-short-url.geo_ip.enabled' => $settings['geo_ip_enabled'], + 'filament-short-url.geo_ip.driver' => $settings['geo_ip_driver'], + 'filament-short-url.geo_ip.cache_ttl' => $settings['geo_ip_cache_ttl'], + 'filament-short-url.geo_ip.timeout' => $settings['geo_ip_timeout'], + 'filament-short-url.geo_ip.maxmind.database_path' => $settings['maxmind_database_path'], + 'filament-short-url.ga4.api_secret' => $settings['ga4_api_secret'], + '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'], + ]); + } +} diff --git a/src/Services/ShortUrlTracker.php b/src/Services/ShortUrlTracker.php new file mode 100644 index 0000000..d5b04a5 --- /dev/null +++ b/src/Services/ShortUrlTracker.php @@ -0,0 +1,69 @@ +userAgent() ?? ''; + $parsed = $this->uaParser->parse($ua); + + // Don't track bots — they inflate stats and waste API calls + if ($parsed['device_type'] === 'robot') { + return null; + } + + $geo = config('filament-short-url.geo_ip.enabled', true) + ? $this->geoIp->resolve($ip, $preResolvedCountryCode) + : ['country' => null, 'country_code' => null]; + + // Determine uniqueness: first time this IP hash visits this URL. + // We check BEFORE insert to avoid a self-referential race. + $isUnique = ! ShortUrlVisit::where('short_url_id', $shortUrl->id) + ->where('ip_hash', $ipHash) + ->exists(); + + $visit = new ShortUrlVisit; + $visit->short_url_id = $shortUrl->id; + $visit->visited_at = now(); + $visit->ip_hash = $ipHash; + $visit->ip_address = $shortUrl->track_ip_address ? $ip : null; + $visit->browser = $shortUrl->track_browser ? $parsed['browser'] : null; + $visit->browser_version = $shortUrl->track_browser_version ? $parsed['browser_version'] : null; + $visit->operating_system = $shortUrl->track_operating_system ? $parsed['operating_system'] : null; + $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->save(); + + $shortUrl->incrementVisits($isUnique); + + return $visit; + } +} diff --git a/src/Services/UserAgentParser.php b/src/Services/UserAgentParser.php new file mode 100644 index 0000000..d046d03 --- /dev/null +++ b/src/Services/UserAgentParser.php @@ -0,0 +1,178 @@ + $this->parseBrowser($userAgent), + 'browser_version' => $this->parseBrowserVersion($userAgent), + 'operating_system' => $this->parseOs($userAgent), + 'operating_system_version' => $this->parseOsVersion($userAgent), + 'device_type' => $this->parseDeviceType($userAgent), + ]; + } + + private function parseBrowser(string $ua): ?string + { + // Order matters — check specific first, generic last + $patterns = [ + 'Edg/' => 'Edge', + 'OPR/' => 'Opera', + 'Opera' => 'Opera', + 'SamsungBrowser' => 'Samsung Browser', + 'Chrome' => 'Chrome', + 'CriOS' => 'Chrome', // Chrome iOS + 'Firefox' => 'Firefox', + 'FxiOS' => 'Firefox', // Firefox iOS + 'Safari' => 'Safari', + 'MSIE' => 'Internet Explorer', + 'Trident' => 'Internet Explorer', + 'Googlebot' => 'Googlebot', + 'bingbot' => 'Bingbot', + 'DuckDuckBot' => 'DuckDuckBot', + 'Slurp' => 'Yahoo! Slurp', + 'curl' => 'cURL', + ]; + + foreach ($patterns as $token => $name) { + if (stripos($ua, $token) !== false) { + return $name; + } + } + + return null; + } + + private function parseBrowserVersion(string $ua): ?string + { + $patterns = [ + '/Edg\/([0-9.]+)/', + '/OPR\/([0-9.]+)/', + '/SamsungBrowser\/([0-9.]+)/', + '/CriOS\/([0-9.]+)/', + '/Chrome\/([0-9.]+)/', + '/FxiOS\/([0-9.]+)/', + '/Firefox\/([0-9.]+)/', + '/Version\/([0-9.]+).*Safari/', + '/MSIE ([0-9.]+)/', + '/rv:([0-9.]+).*Trident/', + ]; + + foreach ($patterns as $pattern) { + if (preg_match($pattern, $ua, $matches)) { + // Return major.minor only + $parts = explode('.', $matches[1]); + + return implode('.', array_slice($parts, 0, 2)); + } + } + + return null; + } + + private function parseOs(string $ua): ?string + { + return match (true) { + stripos($ua, 'Windows') !== false => 'Windows', + stripos($ua, 'iPad') !== false => 'iPadOS', + stripos($ua, 'iPhone') !== false => 'iOS', + stripos($ua, 'Mac OS X') !== false => 'macOS', + stripos($ua, 'Android') !== false => 'Android', + stripos($ua, 'Linux') !== false => 'Linux', + stripos($ua, 'CrOS') !== false => 'Chrome OS', + default => null, + }; + } + + private function parseOsVersion(string $ua): ?string + { + // Windows: Windows NT 10.0 + if (preg_match('/Windows NT ([0-9.]+)/', $ua, $m)) { + return $this->windowsVersion($m[1]); + } + + // Android: Android 13 + if (preg_match('/Android ([0-9.]+)/', $ua, $m)) { + return $m[1]; + } + + // iOS / iPadOS: OS 17_0 + if (preg_match('/OS ([0-9_]+) like Mac/', $ua, $m)) { + return str_replace('_', '.', $m[1]); + } + + // macOS: Mac OS X 10_15_7 or 14.0 + if (preg_match('/Mac OS X ([0-9_.]+)/', $ua, $m)) { + return str_replace('_', '.', $m[1]); + } + + // Linux — usually no version + if (preg_match('/Linux ([0-9.]+)/', $ua, $m)) { + return $m[1]; + } + + return null; + } + + private function parseDeviceType(string $ua): string + { + // Robots first + $robots = ['Googlebot', 'bingbot', 'Slurp', 'DuckDuckBot', 'Baiduspider', 'YandexBot', 'facebookexternalhit', 'Twitterbot', 'Applebot', 'curl', 'python-requests', 'Go-http-client']; + foreach ($robots as $robot) { + if (stripos($ua, $robot) !== false) { + return 'robot'; + } + } + + // Tablets before phones — iPad + Android tablets + if ( + stripos($ua, 'iPad') !== false || + (stripos($ua, 'Android') !== false && stripos($ua, 'Mobile') === false) + ) { + return 'tablet'; + } + + // Mobile phones + $mobileKeywords = ['Mobile', 'iPhone', 'iPod', 'BlackBerry', 'IEMobile', 'Opera Mini', 'SamsungBrowser', 'Kindle']; + foreach ($mobileKeywords as $keyword) { + if (stripos($ua, $keyword) !== false) { + return 'mobile'; + } + } + + return 'desktop'; + } + + private function windowsVersion(string $ntVersion): string + { + return match ($ntVersion) { + '10.0' => '10/11', + '6.3' => '8.1', + '6.2' => '8', + '6.1' => '7', + '6.0' => 'Vista', + '5.1' => 'XP', + default => $ntVersion, + }; + } +}