feat: initial release v1.0.0

This commit is contained in:
Bartłomiej Janczak
2026-06-01 10:10:26 +02:00
commit 20a75bc72a
41 changed files with 4472 additions and 0 deletions

11
.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
/vendor/
/node_modules/
/.env
/.env.*
!/.env.example
/coverage/
/.phpunit.cache/
/.pest/
*.log
.DS_Store
Thumbs.db

154
README.md Normal file
View File

@@ -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

51
composer.json Normal file
View File

@@ -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
}

View File

@@ -0,0 +1,135 @@
<?php
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit;
return [
/*
|--------------------------------------------------------------------------
| Route Prefix
|--------------------------------------------------------------------------
| The URL prefix for short URL redirects. Short URLs will be accessible at:
| /{prefix}/{key} e.g. /s/abc123
*/
'route_prefix' => 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',
],
];

View File

@@ -0,0 +1,60 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('short_urls', function (Blueprint $table): void {
$table->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');
}
};

View File

@@ -0,0 +1,52 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('short_url_visits', function (Blueprint $table): void {
$table->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');
}
};

1
filament-short-url Submodule

Submodule filament-short-url added at 4bbad3298f

View File

@@ -0,0 +1,183 @@
<?php
return [
'navigation_label' => '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.',
];

View File

@@ -0,0 +1,184 @@
<?php
return [
'navigation_label' => '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.',
];

View File

@@ -0,0 +1,22 @@
<x-filament-panels::page>
<x-filament::tabs class="mb-6">
<x-filament::tabs.item
tag="a"
href="{{ \Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource::getUrl('stats', ['record' => $record]) }}"
icon="heroicon-m-presentation-chart-line"
>
{{ __('filament-short-url::default.stats_tab_statistics') }}
</x-filament::tabs.item>
<x-filament::tabs.item
:active="true"
icon="heroicon-m-list-bullet"
>
{{ __('filament-short-url::default.stats_tab_visit_logs') }}
</x-filament::tabs.item>
</x-filament::tabs>
<div class="mt-2">
{{ $this->table }}
</div>
</x-filament-panels::page>

View File

@@ -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
<style>
/* Color picker */
.qr-color-picker input[type=color] {
position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;
}
/* Radio buttons */
.qr-radio-option {
display:flex;align-items:center;gap:8px;cursor:pointer;
padding:8px 12px;border-radius:8px;border:1.5px solid #e5e7eb;
transition:all .15s;font-size:13px;font-weight:500;color:#374151;
background:white;
}
.dark .qr-radio-option { background:#1f2937;border-color:#374151;color:#d1d5db; }
.qr-radio-option.active {
border-color:#6366f1;background:#eef2ff;color:#4338ca;
}
.dark .qr-radio-option.active { background:#1e1b4b;border-color:#818cf8;color:#a5b4fc; }
.qr-radio-dot {
width:15px;height:15px;border-radius:50%;border:2px solid currentColor;
display:flex;align-items:center;justify-content:center;flex-shrink:0;
}
.qr-radio-option.active .qr-radio-dot::after {
content:'';width:6px;height:6px;border-radius:50%;background:currentColor;
}
/* Toggle */
.qr-toggle {
position:relative;display:inline-flex;align-items:center;
height:22px;width:42px;cursor:pointer;border-radius:9999px;
transition:background-color .2s ease;flex-shrink:0;
}
.qr-toggle.on { background:#6366f1; }
.qr-toggle.off { background:#d1d5db; }
.dark .qr-toggle.off { background:#4b5563; }
.qr-toggle-thumb {
pointer-events:none;height:18px;width:18px;border-radius:9999px;
background:white;box-shadow:0 1px 3px rgba(0,0,0,.25);
transition:transform .2s ease;position:absolute;left:2px;
}
.qr-toggle.on .qr-toggle-thumb { transform:translateX(20px); }
.qr-toggle.off .qr-toggle-thumb { transform:translateX(0); }
/* Labels */
.qr-label {
font-size:11px;font-weight:700;color:#9ca3af;
text-transform:uppercase;letter-spacing:.06em;
margin-bottom:6px;display:block;
}
/* Selects */
.qr-select {
width:100%;padding:7px 30px 7px 10px;border-radius:8px;font-size:13px;font-weight:500;
border:1.5px solid #e5e7eb;background:#fff;color:#111827;
appearance:none;
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3E%3C/svg%3E");
background-repeat:no-repeat;background-position:right 8px center;background-size:16px;
cursor:pointer;transition:border-color .15s;
}
.qr-select:focus { outline:none;border-color:#6366f1; }
.dark .qr-select { background-color:#374151;border-color:#4b5563;color:#f9fafb; }
/* Number input */
.qr-input-num {
width:100%;padding:7px 10px;border-radius:8px;font-size:13px;font-weight:500;
border:1.5px solid #e5e7eb;background:#fff;color:#111827;transition:border-color .15s;
}
.qr-input-num:focus { outline:none;border-color:#6366f1; }
.dark .qr-input-num { background-color:#374151;border-color:#4b5563;color:#f9fafb; }
/* Color swatch */
.qr-color-swatch {
position:relative;width:36px;height:36px;border-radius:8px;
border:1.5px solid #e5e7eb;cursor:pointer;overflow:hidden;flex-shrink:0;
}
/* Hex input */
.qr-hex-input {
flex:1;min-width:0;padding:7px 10px;border-radius:8px;
font-size:12px;font-family:monospace;font-weight:600;
border:1.5px solid #e5e7eb;background:#fff;color:#374151;
letter-spacing:.03em;transition:border-color .15s;
}
.qr-hex-input:focus { outline:none;border-color:#6366f1; }
.dark .qr-hex-input { background:#374151;border-color:#4b5563;color:#f9fafb; }
/* Sections */
.qr-section { padding:14px 0;border-bottom:1px solid #f3f4f6; }
.dark .qr-section { border-color:#374151; }
.qr-section:last-child { border-bottom:none; }
/* Download buttons */
.qr-dl-btn {
display:inline-flex;align-items:center;gap:5px;padding:6px 14px;
border-radius:8px;font-size:12px;font-weight:600;
border:1.5px solid #e5e7eb;background:#fff;color:#374151;
cursor:pointer;transition:all .15s;
}
.qr-dl-btn:hover { background:#f9fafb;border-color:#d1d5db; }
.dark .qr-dl-btn { background:#374151;border-color:#4b5563;color:#d1d5db; }
.dark .qr-dl-btn:hover { background:#4b5563; }
/* Transparent checker */
.qr-checker {
background-image:linear-gradient(45deg,#d1d5db 25%,transparent 25%),
linear-gradient(-45deg,#d1d5db 25%,transparent 25%),
linear-gradient(45deg,transparent 75%,#d1d5db 75%),
linear-gradient(-45deg,transparent 75%,#d1d5db 75%);
background-size:10px 10px;
background-position:0 0,0 5px,5px -5px,-5px 0;
background-color:#f3f4f6;
}
@keyframes qr-spin { to { transform: rotate(360deg); } }
</style>
{{-- Preload QR library as soon as the tab renders --}}
<script>
(function(){
if (window.QRCodeStyling) return;
var s = document.createElement('script');
s.src = 'https://unpkg.com/qr-code-styling@1.6.0-rc.1/lib/qr-code-styling.js';
s.onerror = function(){
var s2 = document.createElement('script');
s2.src = 'https://cdn.jsdelivr.net/npm/qr-code-styling@1.6.0-rc.1/lib/qr-code-styling.js';
document.head.appendChild(s2);
};
document.head.appendChild(s);
})();
</script>
<div
x-data="{
url: @js($shortUrl),
size: {{ $opts['size'] ?? 300 }},
margin: {{ $opts['margin'] ?? 1 }},
dotStyle: @js($opts['dot_style'] ?? 'square'),
colorMode: @js($opts['color_mode'] ?? 'solid'),
fgColor: @js($opts['foreground_color'] ?? '#000000'),
gradientFrom: @js($opts['gradient_from'] ?? '#4f46e5'),
gradientTo: @js($opts['gradient_to'] ?? '#06b6d4'),
gradientType: @js($opts['gradient_type'] ?? 'linear'),
bgTransparent: {{ ($opts['bg_transparent'] ?? false) ? 'true' : 'false' }},
bgColor: @js($opts['background_color'] ?? '#ffffff'),
eyeConfigEnabled: {{ ($opts['eye_config_enabled'] ?? false) ? 'true' : 'false' }},
eyeSquareStyle: @js($opts['eye_square_style'] ?? 'square'),
eyeDotStyle: @js($opts['eye_dot_style'] ?? 'square'),
eyeColor: @js($opts['eye_color'] ?? '#000000'),
qrInstance: null,
init() {
this.$nextTick(() => {
this.loadScript().then(() => {
this.render();
['size','margin','dotStyle','colorMode','fgColor','gradientFrom',
'gradientTo','gradientType','bgTransparent','bgColor',
'eyeConfigEnabled','eyeSquareStyle','eyeDotStyle','eyeColor'
].forEach(k => this.$watch(k, () => { this.syncDom(); this.render(); }));
// Re-render when the QR tab becomes visible (Filament uses x-show on tab panels)
const canvas = this.$refs.qrCanvas;
if (canvas) {
const obs = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) { this.render(); }
});
obs.observe(canvas);
}
});
});
},
loadScript() {
if (window.QRCodeStyling) return Promise.resolve();
return new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = 'https://unpkg.com/qr-code-styling@1.6.0-rc.1/lib/qr-code-styling.js';
s.onload = resolve;
s.onerror = () => {
// Fallback CDN
const s2 = document.createElement('script');
s2.src = 'https://cdn.jsdelivr.net/npm/qr-code-styling@1.6.0-rc.1/lib/qr-code-styling.js';
s2.onload = resolve;
s2.onerror = reject;
document.head.appendChild(s2);
};
document.head.appendChild(s);
});
},
buildOptions() {
const isGrad = this.colorMode === 'gradient';
const dotsOptions = isGrad
? { type: this.dotStyle, gradient: { type: this.gradientType,
colorStops: [{ offset: 0, color: this.gradientFrom }, { offset: 1, color: this.gradientTo }] } }
: { type: this.dotStyle, color: this.fgColor };
const mainColor = isGrad ? this.gradientFrom : this.fgColor;
const eyeSq = this.eyeConfigEnabled
? { type: this.eyeSquareStyle, color: this.eyeColor }
: { type: this.dotStyle === 'dots' ? 'dot' : 'square', color: mainColor };
const eyeDt = this.eyeConfigEnabled
? { type: this.eyeDotStyle, color: this.eyeColor }
: { type: this.dotStyle === 'dots' ? 'dot' : 'square', color: mainColor };
return {
width: +this.size || 300, height: +this.size || 300,
data: this.url, margin: +this.margin || 1,
dotsOptions,
backgroundOptions: this.bgTransparent ? { color: 'rgba(0,0,0,0)' } : { color: this.bgColor },
cornersSquareOptions: eyeSq,
cornersDotOptions: eyeDt,
qrOptions: { errorCorrectionLevel: 'M' },
};
},
render() {
const el = this.$refs.qrCanvas;
if (!el || !window.QRCodeStyling) return;
el.innerHTML = '';
this.qrInstance = new window.QRCodeStyling(this.buildOptions());
this.qrInstance.append(el);
},
syncDom() {
const el = document.getElementById('qr-options-json-input');
if (!el) return;
el.value = JSON.stringify({
size: +this.size, margin: +this.margin, dot_style: this.dotStyle,
color_mode: this.colorMode, foreground_color: this.fgColor,
gradient_from: this.gradientFrom, gradient_to: this.gradientTo,
gradient_type: this.gradientType, bg_transparent: this.bgTransparent,
background_color: this.bgColor, eye_config_enabled: this.eyeConfigEnabled,
eye_square_style: this.eyeSquareStyle, eye_dot_style: this.eyeDotStyle,
eye_color: this.eyeColor,
});
el.dispatchEvent(new Event('input', { bubbles: true }));
},
download(ext) { this.qrInstance?.download({ name: 'qr-code', extension: ext }); },
setHex(field, val) { if (/^#[0-9A-Fa-f]{6}$/.test(val)) this[field] = val; },
}"
class="w-full"
>
{{-- CSS Grid: left=fixed 280px, right=fills remaining space, both columns same height --}}
<div style="display:grid;grid-template-columns:280px 1fr;gap:2rem;width:100%;min-width:0;align-items:stretch">
{{-- ══ LEFT: settings panel ══ --}}
<div style="min-width:0;overflow:hidden">
{{-- Size & Margin --}}
<div class="qr-section">
<div class="grid grid-cols-2 gap-3">
<div>
<span class="qr-label">{{ __('filament-short-url::default.qr_label_size') }}</span>
<input type="number" x-model.number="size" min="100" max="1000" step="10" class="qr-input-num" />
</div>
<div>
<span class="qr-label">{{ __('filament-short-url::default.qr_label_margin') }}</span>
<select x-model.number="margin" class="qr-select">
@foreach (range(0, 10) as $m)
<option value="{{ $m }}">{{ $m }}</option>
@endforeach
</select>
</div>
</div>
</div>
{{-- Dot Style --}}
<div class="qr-section">
<span class="qr-label">{{ __('filament-short-url::default.qr_label_style') }}</span>
<select x-model="dotStyle" class="qr-select">
<option value="square">{{ __('filament-short-url::default.qr_option_square') }}</option>
<option value="dots">{{ __('filament-short-url::default.qr_option_dots') }}</option>
<option value="rounded">{{ __('filament-short-url::default.qr_option_rounded') }}</option>
<option value="classy">{{ __('filament-short-url::default.qr_option_classy') }}</option>
<option value="classy-rounded">{{ __('filament-short-url::default.qr_option_classy_rounded') }}</option>
<option value="extra-rounded">{{ __('filament-short-url::default.qr_option_extra_rounded') }}</option>
</select>
</div>
{{-- Foreground Color --}}
<div class="qr-section">
<span class="qr-label">{{ __('filament-short-url::default.qr_label_foreground_color') }}</span>
{{-- Mode radio --}}
<div class="flex gap-2">
<button type="button"
:class="colorMode === 'solid' ? 'active' : ''"
class="qr-radio-option flex-1 justify-center text-center"
x-on:click="colorMode = 'solid'">
<span class="qr-radio-dot"></span>
{{ __('filament-short-url::default.qr_label_single_color') }}
</button>
<button type="button"
:class="colorMode === 'gradient' ? 'active' : ''"
class="qr-radio-option flex-1 justify-center text-center"
x-on:click="colorMode = 'gradient'">
<span class="qr-radio-dot"></span>
{{ __('filament-short-url::default.qr_label_gradient') }}
</button>
</div>
{{-- Single color picker --}}
<div x-show="colorMode === 'solid'" x-transition style="display:block" class="mt-3">
<span class="qr-label">{{ __('filament-short-url::default.qr_label_color') }}</span>
<div class="flex items-center gap-2">
<div class="qr-color-swatch qr-color-picker" :style="'background:'+fgColor">
<input type="color" x-model="fgColor" />
</div>
<input type="text" class="qr-hex-input"
:value="fgColor"
x-on:change="setHex('fgColor', $event.target.value)"
maxlength="7" placeholder="#000000" />
</div>
</div>
{{-- Gradient pickers --}}
<div x-show="colorMode === 'gradient'" x-transition style="display:none" class="mt-3 space-y-3">
<div class="grid grid-cols-2 gap-2">
<div>
<span class="qr-label">{{ __('filament-short-url::default.qr_label_from') }}</span>
<div class="flex items-center gap-1.5">
<div class="qr-color-swatch qr-color-picker" :style="'background:'+gradientFrom">
<input type="color" x-model="gradientFrom" />
</div>
<input type="text" class="qr-hex-input"
:value="gradientFrom"
x-on:change="setHex('gradientFrom', $event.target.value)"
maxlength="7" />
</div>
</div>
<div>
<span class="qr-label">{{ __('filament-short-url::default.qr_label_to') }}</span>
<div class="flex items-center gap-1.5">
<div class="qr-color-swatch qr-color-picker" :style="'background:'+gradientTo">
<input type="color" x-model="gradientTo" />
</div>
<input type="text" class="qr-hex-input"
:value="gradientTo"
x-on:change="setHex('gradientTo', $event.target.value)"
maxlength="7" />
</div>
</div>
</div>
<div>
<span class="qr-label">{{ __('filament-short-url::default.qr_label_gradient_type') }}</span>
<select x-model="gradientType" class="qr-select">
<option value="linear">{{ __('filament-short-url::default.qr_gradient_linear') }}</option>
<option value="radial">{{ __('filament-short-url::default.qr_gradient_radial') }}</option>
</select>
</div>
</div>
</div>
{{-- Background --}}
<div class="qr-section">
<span class="qr-label">{{ __('filament-short-url::default.qr_label_background') }}</span>
<div class="flex items-center justify-between">
<span style="font-size:13px;font-weight:500;color:#374151" class="dark:text-gray-300">{{ __('filament-short-url::default.qr_label_transparent') }}</span>
<button type="button"
:class="bgTransparent ? 'on' : 'off'"
class="qr-toggle"
x-on:click="bgTransparent = !bgTransparent">
<span class="qr-toggle-thumb"></span>
</button>
</div>
<div x-show="!bgTransparent" x-transition style="display:block" class="mt-3">
<span class="qr-label">{{ __('filament-short-url::default.qr_label_color') }}</span>
<div class="flex items-center gap-2">
<div class="qr-color-swatch qr-color-picker" :style="'background:'+bgColor">
<input type="color" x-model="bgColor" />
</div>
<input type="text" class="qr-hex-input"
:value="bgColor"
x-on:change="setHex('bgColor', $event.target.value)"
maxlength="7" placeholder="#ffffff" />
</div>
</div>
<div x-show="bgTransparent" x-transition style="display:none" class="mt-3">
<div class="qr-checker flex h-9 w-full items-center justify-center rounded-lg border border-dashed border-gray-300" style="font-size:11px;color:#9ca3af;font-weight:600">
TRANSPARENT
</div>
</div>
</div>
{{-- Eye Config --}}
<div class="qr-section">
<div class="flex items-center justify-between">
<span class="qr-label" style="margin-bottom:0">{{ __('filament-short-url::default.qr_label_eye_config') }}</span>
<button type="button"
:class="eyeConfigEnabled ? 'on' : 'off'"
class="qr-toggle"
x-on:click="eyeConfigEnabled = !eyeConfigEnabled">
<span class="qr-toggle-thumb"></span>
</button>
</div>
<div x-show="eyeConfigEnabled" x-transition style="display:none" class="mt-3 space-y-3">
<div>
<span class="qr-label">{{ __('filament-short-url::default.qr_label_eye_square_style') }}</span>
<select x-model="eyeSquareStyle" class="qr-select">
<option value="square">{{ __('filament-short-url::default.qr_option_square') }}</option>
<option value="dot">{{ __('filament-short-url::default.qr_option_dot') }}</option>
<option value="extra-rounded">{{ __('filament-short-url::default.qr_option_extra_rounded') }}</option>
</select>
</div>
<div>
<span class="qr-label">{{ __('filament-short-url::default.qr_label_eye_dot_style') }}</span>
<select x-model="eyeDotStyle" class="qr-select">
<option value="square">{{ __('filament-short-url::default.qr_option_square') }}</option>
<option value="dot">{{ __('filament-short-url::default.qr_option_dot') }}</option>
</select>
</div>
<div>
<span class="qr-label">{{ __('filament-short-url::default.qr_label_eye_color') }}</span>
<div class="flex items-center gap-2">
<div class="qr-color-swatch qr-color-picker" :style="'background:'+eyeColor">
<input type="color" x-model="eyeColor" />
</div>
<input type="text" class="qr-hex-input"
:value="eyeColor"
x-on:change="setHex('eyeColor', $event.target.value)"
maxlength="7" />
</div>
</div>
</div>
</div>
</div>
{{-- ══ RIGHT: Preview ══ --}}
<div style="display:flex;flex-direction:column;min-width:0;height:100%">
{{-- Top bar --}}
<div class="mb-4 flex items-center justify-between">
<span style="font-size:13px;font-weight:600;color:#9ca3af">{{ __('filament-short-url::default.qr_label_preview') }}</span>
<div class="flex items-center gap-2">
<button type="button" x-on:click="download('png')" class="qr-dl-btn">
<svg style="width:13px;height:13px" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"/>
</svg>
PNG
</button>
<button type="button" x-on:click="download('svg')" class="qr-dl-btn">
<svg style="width:13px;height:13px" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"/>
</svg>
SVG
</button>
</div>
</div>
{{--
Canvas box ALL styles in :style (object) so Alpine does NOT overwrite
the static style attribute (Alpine :style REPLACES, not merges, with static style).
--}}
<div :class="bgTransparent ? 'qr-checker' : ''"
:style="{
flex: '1',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '15px',
border: '1.5px solid #e5e7eb',
minHeight: '280px',
padding: '32px',
position: 'relative',
background: bgTransparent ? '' : bgColor,
}">
{{-- Spinner --}}
<div x-show="!qrInstance"
style="width:36px;height:36px;border:3px solid #e5e7eb;
border-top-color:#6366f1;border-radius:50%;
animation:qr-spin 0.8s linear infinite">
</div>
{{-- QR canvas --}}
<div x-ref="qrCanvas" style="line-height:0"></div>
</div>
<p class="mt-2 text-center font-mono" style="font-size:11px;color:#9ca3af">
{{ $shortUrl }}
</p>
</div>
</div>
</div>

View File

@@ -0,0 +1,13 @@
<x-filament-panels::page>
<form wire:submit="save">
{{ $this->form }}
<div class="mt-6 flex justify-end">
<x-filament::button type="submit" icon="heroicon-o-check">
{{ __('filament-short-url::default.settings_save_btn') }}
</x-filament::button>
</div>
</form>
<x-filament-actions::modals />
</x-filament-panels::page>

View File

@@ -0,0 +1,33 @@
<x-filament-panels::page>
<x-filament::tabs class="mb-6">
<x-filament::tabs.item
:active="true"
icon="heroicon-m-presentation-chart-line"
>
{{ __('filament-short-url::default.stats_tab_statistics') }}
</x-filament::tabs.item>
<x-filament::tabs.item
tag="a"
href="{{ \Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource::getUrl('stats.logs', ['record' => $record]) }}"
icon="heroicon-m-list-bullet"
>
{{ __('filament-short-url::default.stats_tab_visit_logs') }}
</x-filament::tabs.item>
</x-filament::tabs>
<div class="space-y-6">
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlStatsOverview::class, ['record' => $record])
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div class="lg:col-span-2">
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsChart::class, ['record' => $record])
</div>
<div>
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsRightBreakdown::class, ['record' => $record])
</div>
</div>
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsBottomBreakdown::class, ['record' => $record])
</div>
</x-filament-panels::page>

View File

@@ -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
<x-filament-widgets::widget>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
{{-- Devices --}}
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-50 text-emerald-500 dark:bg-emerald-950/50 dark:text-emerald-400">
<x-filament::icon icon="heroicon-o-device-phone-mobile" class="h-5 w-5" />
</div>
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_devices') }}</h3>
</div>
<div class="space-y-4">
@forelse ($visitsByDevice as $device => $count)
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
<div class="flex items-center gap-3">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gray-50 text-gray-500 dark:bg-gray-800 dark:text-gray-400">
<x-filament::icon :icon="$deviceIcons[$device] ?? 'heroicon-m-question-mark-circle'" class="h-4 w-4" />
</div>
<div class="flex-1 min-w-0">
<div class="flex justify-between text-sm">
<span class="capitalize font-medium text-gray-700 dark:text-gray-300 truncate">{{ $device }}</span>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">{{ $pct }}%</span>
</div>
<div class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div class="h-full rounded-full bg-emerald-500 transition-all duration-500" style="width: {{ $pct }}%"></div>
</div>
</div>
</div>
@empty
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_device_data') }}</p>
@endforelse
</div>
</div>
{{-- Browsers --}}
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-amber-50 text-amber-500 dark:bg-amber-950/50 dark:text-amber-400">
<x-filament::icon icon="heroicon-o-globe-asia-australia" class="h-5 w-5" />
</div>
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_browsers') }}</h3>
</div>
<div class="space-y-3.5">
@forelse ($visitsByBrowser as $browser => $count)
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2 min-w-0">
<x-filament::icon :icon="$browserIcons[$browser] ?? 'heroicon-m-globe-alt'" class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" />
<span class="font-medium text-gray-700 dark:text-gray-300 truncate">{{ $browser }}</span>
</div>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0 ml-2">
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
</span>
</div>
@empty
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_browser_data') }}</p>
@endforelse
</div>
</div>
{{-- Operating Systems --}}
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-blue-50 text-blue-500 dark:bg-blue-950/50 dark:text-blue-400">
<x-filament::icon icon="heroicon-o-computer-desktop" class="h-5 w-5" />
</div>
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_os') }}</h3>
</div>
<div class="space-y-3.5">
@forelse ($visitsByOs as $os => $count)
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2 min-w-0">
<x-filament::icon :icon="$osIcons[$os] ?? 'heroicon-m-computer-desktop'" class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" />
<span class="font-medium text-gray-700 dark:text-gray-300 truncate">{{ $os }}</span>
</div>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0 ml-2">
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
</span>
</div>
@empty
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_os_data') }}</p>
@endforelse
</div>
</div>
{{-- Referers --}}
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-purple-50 text-purple-500 dark:bg-purple-950/50 dark:text-purple-400">
<x-filament::icon icon="heroicon-o-link" class="h-5 w-5" />
</div>
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_referers') }}</h3>
</div>
<div class="space-y-3">
@forelse ($visitsByReferer as $referer => $count)
<div class="flex items-center justify-between text-sm gap-4">
<div class="flex items-center gap-2 min-w-0 flex-1">
<x-filament::icon icon="heroicon-m-link" class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" />
<a href="{{ $referer }}" target="_blank" rel="noopener"
class="truncate text-indigo-600 hover:underline dark:text-indigo-400 font-medium">
{{ $referer }}
</a>
</div>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0">
{{ number_format($count) }}
</span>
</div>
@empty
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_referer_data') }}</p>
@endforelse
</div>
</div>
</div>
</x-filament-widgets::widget>

View File

@@ -0,0 +1,31 @@
<x-filament-widgets::widget>
<div class="space-y-6">
{{-- Countries --}}
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-indigo-50 text-indigo-500 dark:bg-indigo-950/50 dark:text-indigo-400">
<x-filament::icon icon="heroicon-o-globe-alt" class="h-5 w-5" />
</div>
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_countries') }}</h3>
</div>
<div class="space-y-3">
@forelse ($visitsByCountry as $country => $count)
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
<div>
<div class="flex items-center justify-between text-sm">
<span class="font-medium text-gray-700 dark:text-gray-300">{{ $country }}</span>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span></span>
</div>
<div class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div class="h-full rounded-full bg-indigo-500 transition-all duration-500" style="width: {{ $pct }}%"></div>
</div>
</div>
@empty
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_country_data') }}</p>
@endforelse
</div>
</div>
</div>
</x-filament-widgets::widget>

12
routes/web.php Normal file
View File

@@ -0,0 +1,12 @@
<?php
use Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlRedirectController;
use Illuminate\Support\Facades\Route;
Route::get(
config('filament-short-url.route_prefix', 's').'/{key}',
ShortUrlRedirectController::class
)
->name('short-url.redirect')
->where('key', '[a-zA-Z0-9_-]+')
->middleware('throttle:120,1');

View File

@@ -0,0 +1,29 @@
<?php
namespace Bjanczak\FilamentShortUrl\Events;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Fired after a short URL is visited and the visit has been recorded.
*
* @example
* ```php
* Event::listen(ShortUrlVisited::class, function (ShortUrlVisited $event) {
* logger($event->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,
) {}
}

View File

@@ -0,0 +1,96 @@
<?php
namespace Bjanczak\FilamentShortUrl\Filament\Resources;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\ListShortUrls;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\ShortUrlSettingsPage;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\ViewShortUrlLogs;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\ViewShortUrlStats;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\ShortUrlForm;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Tables\ShortUrlsTable;
use Bjanczak\FilamentShortUrl\FilamentShortUrlPlugin;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables\Table;
class ShortUrlResource extends Resource
{
protected static ?string $model = ShortUrl::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-link';
protected static ?int $navigationSort = 50;
protected static ?string $recordTitleAttribute = 'url_key';
public static function getNavigationLabel(): string
{
return __('filament-short-url::default.navigation_label');
}
public static function getModelLabel(): string
{
return __('filament-short-url::default.resource_title');
}
public static function getPluralModelLabel(): string
{
return __('filament-short-url::default.navigation_label');
}
// ─── Plugin-aware navigation overrides ───────────────────────────────────
public static function getNavigationGroup(): string|\UnitEnum|null
{
try {
$plugin = FilamentShortUrlPlugin::get();
$group = $plugin->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'),
];
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
use Filament\Actions\Action;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ManageRecords;
class ListShortUrls extends ManageRecords
{
protected static string $resource = ShortUrlResource::class;
protected function getHeaderActions(): array
{
return [
Action::make('settings')
->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 [];
}
}

View File

@@ -0,0 +1,230 @@
<?php
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
use Bjanczak\FilamentShortUrl\Services\ShortUrlSettingsManager;
use Filament\Actions\Action;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\Page;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
class ShortUrlSettingsPage extends Page implements HasForms
{
use InteractsWithForms;
protected static string $resource = ShortUrlResource::class;
protected string $view = 'filament-short-url::settings';
public ?array $data = [];
public function mount(): void
{
$mgr = app(ShortUrlSettingsManager::class);
$this->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');
}
}

View File

@@ -0,0 +1,184 @@
<?php
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit;
use Filament\Actions\Action;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Resources\Pages\Page;
use Filament\Tables;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
class ViewShortUrlLogs extends Page implements HasForms, HasTable
{
use InteractsWithForms;
use InteractsWithTable;
protected static string $resource = ShortUrlResource::class;
protected string $view = 'filament-short-url::logs';
protected static ?string $title = 'Visit Logs';
public ShortUrl $record;
public function mount(ShortUrl $record): void
{
$this->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;
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Filament\Actions\Action;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Resources\Pages\Page;
class ViewShortUrlStats extends Page implements HasForms
{
use InteractsWithForms;
protected static string $resource = ShortUrlResource::class;
protected string $view = 'filament-short-url::stats';
protected static ?string $title = 'Statistics';
public ShortUrl $record;
public int $totalVisits = 0;
public function mount(ShortUrl $record): void
{
$this->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;
}
}

View File

@@ -0,0 +1,316 @@
<?php
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas;
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
use Filament\Actions\Action;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Components\ViewField;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Schema;
class ShortUrlForm
{
public static function configure(Schema $schema): Schema
{
return $schema->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),
]);
}
}

View File

@@ -0,0 +1,144 @@
<?php
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Tables;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Filament\Actions\Action;
use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Support\Enums\FontWeight;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\ToggleColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
class ShortUrlsTable
{
public static function configure(Table $table): Table
{
return $table
->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');
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
class ShortUrlStatsOverview extends BaseWidget
{
public ?ShortUrl $record = null;
protected function getStats(): array
{
if (! $this->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'),
];
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Filament\Widgets\Widget;
class ShortUrlVisitsBottomBreakdown extends Widget
{
public ?ShortUrl $record = null;
protected string $view = 'filament-short-url::widgets.visits-bottom-breakdown';
protected int|string|array $columnSpan = 'full';
public function mount(?ShortUrl $record = null): void
{
$this->record = $record;
}
/**
* @return array<string, mixed>
*/
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,
];
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Filament\Widgets\ChartWidget;
class ShortUrlVisitsChart extends ChartWidget
{
public ?ShortUrl $record = null;
protected ?string $maxHeight = '200px';
protected int|string|array $columnSpan = [
'lg' => 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';
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Filament\Widgets\Widget;
class ShortUrlVisitsRightBreakdown extends Widget
{
public ?ShortUrl $record = null;
protected string $view = 'filament-short-url::widgets.visits-right-breakdown';
protected int|string|array $columnSpan = [
'lg' => 1,
];
public function mount(?ShortUrl $record = null): void
{
$this->record = $record;
}
/**
* @return array<string, mixed>
*/
protected function getViewData(): array
{
if (! $this->record) {
return [
'visitsByCountry' => [],
'totalVisits' => 0,
];
}
$stats = $this->record->getCachedStats();
return [
'visitsByCountry' => $stats['visitsByCountry'] ?? [],
'totalVisits' => $stats['totalVisits'] ?? 0,
];
}
}

View File

@@ -0,0 +1,82 @@
<?php
namespace Bjanczak\FilamentShortUrl;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
use Filament\Contracts\Plugin;
use Filament\Panel;
class FilamentShortUrlPlugin implements Plugin
{
protected ?string $navigationGroup = null;
protected ?int $navigationSort = null;
protected ?string $routePrefix = null;
// ─── Factory ─────────────────────────────────────────────────────────────
public static function make(): static
{
return app(static::class);
}
public static function get(): static
{
return filament(app(static::class)->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;
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace Bjanczak\FilamentShortUrl;
use Bjanczak\FilamentShortUrl\Services\GeoIpService;
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
use Bjanczak\FilamentShortUrl\Services\ShortUrlSettingsManager;
use Bjanczak\FilamentShortUrl\Services\ShortUrlTracker;
use Bjanczak\FilamentShortUrl\Services\UserAgentParser;
use Spatie\LaravelPackageTools\Package;
use Spatie\LaravelPackageTools\PackageServiceProvider;
class FilamentShortUrlServiceProvider extends PackageServiceProvider
{
public static string $name = 'filament-short-url';
public function configurePackage(Package $package): void
{
$package
->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);
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace Bjanczak\FilamentShortUrl\Http\Controllers;
use Bjanczak\FilamentShortUrl\Jobs\TrackShortUrlVisitJob;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Bjanczak\FilamentShortUrl\Services\ClientIpExtractor;
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
class ShortUrlRedirectController extends Controller
{
public function __construct(
private readonly ShortUrlService $service,
) {}
public function __invoke(Request $request, string $key): RedirectResponse
{
$shortUrl = ShortUrl::findByKey($key);
// 404 if not found
if (! $shortUrl) {
abort(404);
}
// 410 Gone if disabled or expired
if (! $shortUrl->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);
}
}

View File

@@ -0,0 +1,125 @@
<?php
namespace Bjanczak\FilamentShortUrl\Jobs;
use Bjanczak\FilamentShortUrl\Events\ShortUrlVisited;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit;
use Bjanczak\FilamentShortUrl\Services\ShortUrlTracker;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Http\Request;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
/**
* Queued job for recording short URL visits.
*
* By dispatching this to a queue, the redirect response is sent to the visitor
* immediately — tracking happens asynchronously without adding latency.
*/
class TrackShortUrlVisitJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
/** @var int Max retry attempts if the job fails */
public int $tries = 3;
/** @var int Delay between retries (seconds) */
public int $backoff = 5;
public function __construct(
public readonly ShortUrl $shortUrl,
public readonly string $ipAddress,
public readonly string $userAgent,
public readonly ?string $refererUrl,
public readonly ?string $countryCode = null,
) {
$this->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(),
]);
}
}
}

349
src/Models/ShortUrl.php Normal file
View File

@@ -0,0 +1,349 @@
<?php
namespace Bjanczak\FilamentShortUrl\Models;
use Bjanczak\FilamentShortUrl\Services\ShortUrlBuilder;
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
* @property int $id
* @property string $destination_url
* @property string $url_key
* @property string|null $notes
* @property bool $is_enabled
* @property int $redirect_status_code
* @property Carbon|null $activated_at
* @property Carbon|null $deactivated_at
* @property Carbon|null $expires_at
* @property bool $single_use
* @property bool $forward_query_params
* @property bool $track_visits
* @property bool $track_ip_address
* @property bool $track_browser
* @property bool $track_browser_version
* @property bool $track_operating_system
* @property bool $track_operating_system_version
* @property bool $track_device_type
* @property bool $track_referer_url
* @property array|null $qr_options
* @property string|null $ga_tracking_id
* @property int $total_visits
* @property int $unique_visits
* @property Carbon $created_at
* @property Carbon $updated_at
*/
class ShortUrl extends Model
{
use HasFactory;
protected $table = 'short_urls';
protected $fillable = [
'destination_url',
'url_key',
'notes',
'is_enabled',
'redirect_status_code',
'activated_at',
'deactivated_at',
'expires_at',
'single_use',
'forward_query_params',
'track_visits',
'track_ip_address',
'track_browser',
'track_browser_version',
'track_operating_system',
'track_operating_system_version',
'track_device_type',
'track_referer_url',
'qr_options',
'ga_tracking_id',
'total_visits',
'unique_visits',
];
/** @var array<string, string> */
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<int, static> */
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<string>
*/
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<string, mixed> */
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<string, mixed>
*/
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,
];
});
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace Bjanczak\FilamentShortUrl\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
/**
* @property int $id
* @property int $short_url_id
* @property string|null $ip_address
* @property string|null $ip_hash
* @property string|null $browser
* @property string|null $browser_version
* @property string|null $operating_system
* @property string|null $operating_system_version
* @property string|null $device_type
* @property string|null $referer_url
* @property string|null $country
* @property string|null $country_code
* @property Carbon $visited_at
*/
class ShortUrlVisit extends Model
{
public $timestamps = false;
protected $table = 'short_url_visits';
protected $fillable = [
'short_url_id',
'ip_address',
'ip_hash',
'browser',
'browser_version',
'operating_system',
'operating_system_version',
'device_type',
'referer_url',
'country',
'country_code',
'visited_at',
];
/** @var array<string, string> */
protected $casts = [
'visited_at' => 'datetime',
];
public function shortUrl(): BelongsTo
{
return $this->belongsTo(ShortUrl::class, 'short_url_id');
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace Bjanczak\FilamentShortUrl\Services;
use Illuminate\Http\Request;
class ClientIpExtractor
{
/**
* Get a proxy-resistant client IP address.
*/
public static function getIp(Request $request): string
{
// 1. Cloudflare connecting IP header
if ($cfIp = $request->header('CF-Connecting-IP')) {
return trim($cfIp);
}
// 2. Akamai or other CDNs True-Client-IP header
if ($trueIp = $request->header('True-Client-IP')) {
return trim($trueIp);
}
// 3. General reverse proxy / Nginx X-Real-IP header
if ($realIp = $request->header('X-Real-IP')) {
return trim($realIp);
}
// 4. Standard X-Forwarded-For header chain (first IP is the client)
if ($forwardedFor = $request->header('X-Forwarded-For')) {
$ips = explode(',', $forwardedFor);
return trim($ips[0]);
}
// 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;
}
}

View File

@@ -0,0 +1,222 @@
<?php
namespace Bjanczak\FilamentShortUrl\Services;
use GeoIp2\Database\Reader;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Resolves country information from an IP address or pre-resolved headers.
*
* Supports local CDN/proxy headers, offline MaxMind databases, and online fallbacks.
* Results are cached per IP hash to minimise overhead.
*
* @internal
*/
class GeoIpService
{
private const API_URL = 'http://ip-api.com/json/%s?fields=status,country,countryCode';
/** @var array<string, array{country: string|null, country_code: string|null}> */
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;
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace Bjanczak\FilamentShortUrl\Services;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Illuminate\Support\Carbon;
class ShortUrlBuilder
{
private array $data = [];
public function __construct(
private ShortUrlService $service,
string $destinationUrl
) {
$this->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<string, string> $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);
}
}

View File

@@ -0,0 +1,126 @@
<?php
namespace Bjanczak\FilamentShortUrl\Services;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
class ShortUrlService
{
/**
* Start building a ShortUrl programmatically using a fluent interface.
*/
public function destination(string $url): ShortUrlBuilder
{
return new ShortUrlBuilder($this, $url);
}
/**
* Generate a unique, collision-free URL key.
*
* Uses base62 (a-z A-Z 0-9) for maximum URL friendliness.
* Retries automatically on the rare collision chance.
*/
public function generateKey(?int $length = null): string
{
$length ??= (int) config('filament-short-url.key_length', 6);
$characters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
$maxIndex = strlen($characters) - 1;
do {
$key = '';
for ($i = 0; $i < $length; $i++) {
$key .= $characters[random_int(0, $maxIndex)];
}
} while ($this->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);
}
}

View File

@@ -0,0 +1,143 @@
<?php
namespace Bjanczak\FilamentShortUrl\Services;
use Illuminate\Support\Facades\File;
class ShortUrlSettingsManager
{
private ?array $cache = null;
public function getSettingsPath(): string
{
return storage_path('app/filament-short-url-settings.json');
}
/**
* Get all settings merged with configuration defaults.
*
* @return array<string, mixed>
*/
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<string, mixed> $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'],
]);
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace Bjanczak\FilamentShortUrl\Services;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit;
use Illuminate\Http\Request;
/**
* Records a visit to a ShortUrl, collecting all enabled tracking fields.
*
* @internal
*/
class ShortUrlTracker
{
public function __construct(
private readonly UserAgentParser $uaParser,
private readonly GeoIpService $geoIp,
) {}
/**
* Record a visit and return the created ShortUrlVisit model.
*
* Returns null if the visit was from a bot/crawler (we don't track those).
*/
public function record(ShortUrl $shortUrl, Request $request, ?string $preResolvedCountryCode = null): ?ShortUrlVisit
{
$ip = ClientIpExtractor::getIp($request);
$ipHash = hash('sha256', $ip);
$ua = $request->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;
}
}

View File

@@ -0,0 +1,178 @@
<?php
namespace Bjanczak\FilamentShortUrl\Services;
/**
* Pure-PHP User Agent parser.
*
* No external dependencies. Uses ordered regex matching for accuracy.
* Detects: browsers, browser versions, operating systems, OS versions, device types.
*
* @internal
*/
class UserAgentParser
{
/**
* @return array{
* browser: string|null,
* browser_version: string|null,
* operating_system: string|null,
* operating_system_version: string|null,
* device_type: string,
* }
*/
public function parse(string $userAgent): array
{
return [
'browser' => $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,
};
}
}