feat: initial release v1.0.0
This commit is contained in:
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal 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
154
README.md
Normal 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
51
composer.json
Normal 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
|
||||
}
|
||||
135
config/filament-short-url.php
Normal file
135
config/filament-short-url.php
Normal 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',
|
||||
],
|
||||
|
||||
];
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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
1
filament-short-url
Submodule
Submodule filament-short-url added at 4bbad3298f
183
resources/lang/en/default.php
Normal file
183
resources/lang/en/default.php
Normal 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.',
|
||||
];
|
||||
184
resources/lang/pl/default.php
Normal file
184
resources/lang/pl/default.php
Normal 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.',
|
||||
];
|
||||
22
resources/views/logs.blade.php
Normal file
22
resources/views/logs.blade.php
Normal 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>
|
||||
481
resources/views/qr-designer.blade.php
Normal file
481
resources/views/qr-designer.blade.php
Normal 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>
|
||||
13
resources/views/settings.blade.php
Normal file
13
resources/views/settings.blade.php
Normal 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>
|
||||
33
resources/views/stats.blade.php
Normal file
33
resources/views/stats.blade.php
Normal 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>
|
||||
144
resources/views/widgets/visits-bottom-breakdown.blade.php
Normal file
144
resources/views/widgets/visits-bottom-breakdown.blade.php
Normal 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>
|
||||
31
resources/views/widgets/visits-right-breakdown.blade.php
Normal file
31
resources/views/widgets/visits-right-breakdown.blade.php
Normal 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
12
routes/web.php
Normal 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');
|
||||
29
src/Events/ShortUrlVisited.php
Normal file
29
src/Events/ShortUrlVisited.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
96
src/Filament/Resources/ShortUrlResource.php
Normal file
96
src/Filament/Resources/ShortUrlResource.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
316
src/Filament/Resources/ShortUrlResource/Schemas/ShortUrlForm.php
Normal file
316
src/Filament/Resources/ShortUrlResource/Schemas/ShortUrlForm.php
Normal 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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
82
src/FilamentShortUrlPlugin.php
Normal file
82
src/FilamentShortUrlPlugin.php
Normal 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;
|
||||
}
|
||||
}
|
||||
42
src/FilamentShortUrlServiceProvider.php
Normal file
42
src/FilamentShortUrlServiceProvider.php
Normal 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);
|
||||
}
|
||||
}
|
||||
69
src/Http/Controllers/ShortUrlRedirectController.php
Normal file
69
src/Http/Controllers/ShortUrlRedirectController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
125
src/Jobs/TrackShortUrlVisitJob.php
Normal file
125
src/Jobs/TrackShortUrlVisitJob.php
Normal 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
349
src/Models/ShortUrl.php
Normal 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,
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
54
src/Models/ShortUrlVisit.php
Normal file
54
src/Models/ShortUrlVisit.php
Normal 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');
|
||||
}
|
||||
}
|
||||
62
src/Services/ClientIpExtractor.php
Normal file
62
src/Services/ClientIpExtractor.php
Normal 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;
|
||||
}
|
||||
}
|
||||
222
src/Services/GeoIpService.php
Normal file
222
src/Services/GeoIpService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
92
src/Services/ShortUrlBuilder.php
Normal file
92
src/Services/ShortUrlBuilder.php
Normal 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);
|
||||
}
|
||||
}
|
||||
126
src/Services/ShortUrlService.php
Normal file
126
src/Services/ShortUrlService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
143
src/Services/ShortUrlSettingsManager.php
Normal file
143
src/Services/ShortUrlSettingsManager.php
Normal 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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
69
src/Services/ShortUrlTracker.php
Normal file
69
src/Services/ShortUrlTracker.php
Normal 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;
|
||||
}
|
||||
}
|
||||
178
src/Services/UserAgentParser.php
Normal file
178
src/Services/UserAgentParser.php
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user