Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
137a81dcab | ||
|
|
276b75ad9f | ||
|
|
d8d6b1b22f | ||
|
|
0d4eab2016 | ||
|
|
0349825890 | ||
|
|
e1e6ded015 | ||
|
|
c5147ac170 | ||
|
|
0cdc4ad0db | ||
|
|
5e2b266793 | ||
|
|
26247fd94a | ||
|
|
f3623df340 | ||
|
|
48ecb8ed28 | ||
|
|
d67bd56f68 | ||
|
|
d075ee83b0 | ||
|
|
67ce293685 | ||
|
|
ce8062036a | ||
|
|
e8e9f26c9b | ||
|
|
da810db511 | ||
|
|
8de83e3973 | ||
|
|
a5421a2d30 | ||
|
|
d1eb322cb6 | ||
|
|
3d62dbcbb7 | ||
|
|
c650c1f205 | ||
|
|
34e4339c7c | ||
|
|
e847328b9b | ||
|
|
911e98a2e3 | ||
|
|
bdef0b6687 | ||
|
|
e0b7e3f28c | ||
|
|
e29c3ef24e | ||
|
|
b1416fbe86 | ||
|
|
59e4fc0e6b | ||
|
|
0e371ad139 | ||
|
|
e5437dd0a6 | ||
|
|
65a1b126df | ||
|
|
f0d76ff03b | ||
|
|
eda4794cac | ||
|
|
69f6cef31b | ||
|
|
2ab85dd264 | ||
|
|
f518d4dae9 | ||
|
|
bdbe9b2ffd | ||
|
|
c842096a22 | ||
|
|
7035e5b84b | ||
|
|
3b97ad3d00 | ||
|
|
a5f5afa198 | ||
|
|
429896d8e3 | ||
|
|
985aa43155 | ||
|
|
cd9a4a1620 |
4
.gitattributes
vendored
Normal file
4
.gitattributes
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
/art export-ignore
|
||||
/tests export-ignore
|
||||
/.gitattributes export-ignore
|
||||
/.gitignore export-ignore
|
||||
30
CONTRIBUTING.md
Normal file
30
CONTRIBUTING.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Contributing to Filament Short URL
|
||||
|
||||
Thank you for considering contributing to the Filament Short URL plugin!
|
||||
|
||||
## Development Setup
|
||||
|
||||
If you are modifying the plugin source code and need to recompile its stylesheet after changing Blade, PHP, or configuration files, follow these steps:
|
||||
|
||||
### 1. Recompile Plugin CSS (Tailwind CSS v4)
|
||||
Run the Tailwind CSS CLI from the root folder of your host application to scan the plugin's views and compile the final stylesheet:
|
||||
|
||||
```bash
|
||||
npx @tailwindcss/cli -i ./packages/filament-short-url/resources/css/plugin.css \
|
||||
-o ./packages/filament-short-url/resources/dist/filament-short-url.css --minify
|
||||
```
|
||||
|
||||
### 2. Re-publish compiled assets
|
||||
After compiling, publish the updated CSS stylesheet to the public folder of your host application so the browser can load it:
|
||||
|
||||
```bash
|
||||
php artisan filament:assets
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
We enforce test coverage for all features. To run the tests from the root of your host application:
|
||||
|
||||
```bash
|
||||
php artisan test --filter=ShortUrl
|
||||
```
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Bartek Janczak
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
BIN
art/v2_1.png
Normal file
BIN
art/v2_1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 345 KiB |
BIN
art/v2_2.png
Normal file
BIN
art/v2_2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 292 KiB |
BIN
art/v2_3.png
Normal file
BIN
art/v2_3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 451 KiB |
BIN
art/v2_4.png
Normal file
BIN
art/v2_4.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 247 KiB |
BIN
art/v2_5.png
Normal file
BIN
art/v2_5.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 484 KiB |
@@ -14,6 +14,16 @@ return [
|
||||
*/
|
||||
'route_prefix' => env('SHORT_URL_PREFIX', 's'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Site Name Override
|
||||
|--------------------------------------------------------------------------
|
||||
| The brand or site name displayed on the password prompt, redirect warnings,
|
||||
| and pixel loading screens. Falls back to config('app.name') if empty.
|
||||
|
|
||||
*/
|
||||
'site_name' => env('SHORT_URL_SITE_NAME'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Redirect Status Code
|
||||
@@ -46,7 +56,7 @@ return [
|
||||
'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')),
|
||||
'database_path' => env('SHORT_URL_MAXMIND_DB', storage_path('geoip/GeoLite2-Country.mmdb')),
|
||||
],
|
||||
'stats_cache_ttl' => env('SHORT_URL_STATS_CACHE_TTL', 300), // 5 minutes caching for stats page calculations
|
||||
],
|
||||
@@ -68,6 +78,7 @@ return [
|
||||
'operating_system_version' => true,
|
||||
'referer_url' => true,
|
||||
'device_type' => true,
|
||||
'browser_language' => true,
|
||||
],
|
||||
],
|
||||
|
||||
@@ -93,6 +104,15 @@ return [
|
||||
*/
|
||||
'queue_connection' => env('SHORT_URL_QUEUE', 'sync'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Queue Name
|
||||
|--------------------------------------------------------------------------
|
||||
| The queue name to which visit tracking and counter jobs are dispatched.
|
||||
|
|
||||
*/
|
||||
'queue_name' => env('SHORT_URL_QUEUE_NAME', 'default'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Redirect Cache TTL
|
||||
@@ -127,9 +147,117 @@ return [
|
||||
'foreground_color' => '#000000',
|
||||
'background_color' => '#ffffff',
|
||||
'gradient_enabled' => false,
|
||||
'gradient_from' => '#000000',
|
||||
'gradient_to' => '#ffffff',
|
||||
'gradient_from' => '#4f46e5',
|
||||
'gradient_to' => '#06b6d4',
|
||||
'gradient_type' => 'linear',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Counter Buffering (Write-back Caching)
|
||||
|--------------------------------------------------------------------------
|
||||
| For high-traffic applications, direct database writes on every visit
|
||||
| can cause locks. Enable this to buffer total/unique visit counts in
|
||||
| cache, then sync them to the database periodically via a scheduled task.
|
||||
*/
|
||||
'counter_buffering' => [
|
||||
'enabled' => (bool) env('SHORT_URL_COUNTER_BUFFERING', false),
|
||||
'cache_key_prefix' => 'filament-short-url:buffer:',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Trust CDN/Proxy Headers
|
||||
|--------------------------------------------------------------------------
|
||||
| If your application sits behind a CDN (like Cloudflare, AWS CloudFront)
|
||||
| or a reverse proxy, set this to true to parse real visitor IP addresses
|
||||
| and country codes from proxy headers. Only enable this if you are
|
||||
| actually behind a proxy to prevent client IP spoofing!
|
||||
|
|
||||
*/
|
||||
'trust_cdn_headers' => (bool) env('SHORT_URL_TRUST_CDN_HEADERS', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Data Pruning & Aggregation
|
||||
|--------------------------------------------------------------------------
|
||||
| To keep the database clean and fast, raw visit logs can be aggregated
|
||||
| into daily statistics and pruned after a retention period.
|
||||
|
|
||||
*/
|
||||
'pruning' => [
|
||||
'enabled' => env('SHORT_URL_PRUNING_ENABLED', true),
|
||||
'retention_days' => env('SHORT_URL_PRUNING_DAYS', 90),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Rate Limiting
|
||||
|--------------------------------------------------------------------------
|
||||
| Protect redirect routes from bot abuse and brute force by enabling
|
||||
| rate limiting.
|
||||
|
|
||||
*/
|
||||
'rate_limiting' => [
|
||||
'enabled' => env('SHORT_URL_RATE_LIMITING', false),
|
||||
'max_attempts' => env('SHORT_URL_RATE_LIMIT_MAX', 60),
|
||||
'decay_seconds' => env('SHORT_URL_RATE_LIMIT_DECAY', 60),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Redirect Route Middleware
|
||||
|--------------------------------------------------------------------------
|
||||
| The middleware list applied to the short URL redirect route.
|
||||
| By default, standard web middleware and rate limiting are applied.
|
||||
|
|
||||
*/
|
||||
'middleware' => [
|
||||
'web',
|
||||
'throttle:120,1',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Deep Linking v2.1
|
||||
|--------------------------------------------------------------------------
|
||||
| Universal Links (iOS) and App Links (Android) configuration.
|
||||
|
|
||||
*/
|
||||
'deep_linking' => [
|
||||
'enabled' => env('SHORT_URL_DEEP_LINKING_ENABLED', false),
|
||||
'aasa_json' => env('SHORT_URL_AASA_JSON', <<<'JSON'
|
||||
{
|
||||
"applinks": {
|
||||
"apps": [],
|
||||
"details": [
|
||||
{
|
||||
"appID": "YOUR_TEAM_ID.com.yourcompany.app",
|
||||
"paths": [
|
||||
"/s/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
JSON),
|
||||
'assetlinks_json' => env('SHORT_URL_ASSETLINKS_JSON', <<<'JSON'
|
||||
[
|
||||
{
|
||||
"relation": [
|
||||
"delegate_permission/common.handle_all_urls"
|
||||
],
|
||||
"target": {
|
||||
"namespace": "android_app",
|
||||
"package_name": "com.yourcompany.app",
|
||||
"sha256_cert_fingerprints": [
|
||||
"14:6D:E9:..."
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
JSON),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
|
||||
@@ -27,8 +27,8 @@ return new class extends Migration
|
||||
$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();
|
||||
// Device classification ('desktop', 'mobile', 'tablet', 'robot') — validated at PHP level
|
||||
$table->string('device_type', 20)->nullable()->index();
|
||||
|
||||
// Traffic source
|
||||
$table->text('referer_url')->nullable();
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('short_url_visits', function (Blueprint $table): void {
|
||||
$table->string('city', 100)->nullable();
|
||||
$table->string('referer_host', 255)->nullable()->index();
|
||||
$table->string('utm_source', 100)->nullable()->index();
|
||||
$table->string('utm_medium', 100)->nullable()->index();
|
||||
$table->string('utm_campaign', 100)->nullable()->index();
|
||||
$table->string('utm_term', 100)->nullable();
|
||||
$table->string('utm_content', 100)->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('short_url_visits', function (Blueprint $table): void {
|
||||
$table->dropColumn([
|
||||
'city',
|
||||
'referer_host',
|
||||
'utm_source',
|
||||
'utm_medium',
|
||||
'utm_campaign',
|
||||
'utm_term',
|
||||
'utm_content',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('short_urls', function (Blueprint $table): void {
|
||||
$table->string('password', 255)->nullable();
|
||||
$table->boolean('show_warning_page')->default(false);
|
||||
$table->json('targeting_rules')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('short_urls', function (Blueprint $table): void {
|
||||
$table->dropColumn([
|
||||
'password',
|
||||
'show_warning_page',
|
||||
'targeting_rules',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
<?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_daily_stats', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('short_url_id')
|
||||
->constrained('short_urls')
|
||||
->cascadeOnDelete();
|
||||
$table->date('date');
|
||||
$table->integer('visits_count')->default(0);
|
||||
$table->integer('unique_visits_count')->default(0);
|
||||
$table->json('device_stats')->nullable();
|
||||
$table->json('browser_stats')->nullable();
|
||||
$table->json('os_stats')->nullable();
|
||||
$table->json('country_stats')->nullable();
|
||||
$table->json('city_stats')->nullable();
|
||||
$table->json('referer_stats')->nullable();
|
||||
$table->json('utm_source_stats')->nullable();
|
||||
$table->json('utm_medium_stats')->nullable();
|
||||
$table->json('utm_campaign_stats')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['short_url_id', 'date']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('short_url_daily_stats');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('short_urls', function (Blueprint $table): void {
|
||||
$table->unsignedInteger('max_visits')->nullable();
|
||||
$table->string('expiration_redirect_url', 2048)->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('short_urls', function (Blueprint $table): void {
|
||||
$table->dropColumn(['max_visits', 'expiration_redirect_url']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('short_urls', function (Blueprint $table) {
|
||||
$table->string('pixel_meta_id', 100)->nullable();
|
||||
$table->string('pixel_google_id', 100)->nullable();
|
||||
$table->string('pixel_linkedin_id', 100)->nullable();
|
||||
$table->string('webhook_url', 2048)->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('short_urls', function (Blueprint $table) {
|
||||
$table->dropColumn([
|
||||
'pixel_meta_id',
|
||||
'pixel_google_id',
|
||||
'pixel_linkedin_id',
|
||||
'webhook_url',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('short_url_visits', function (Blueprint $table): void {
|
||||
$table->boolean('is_bot')->default(false)->index();
|
||||
$table->boolean('is_proxy')->default(false)->index();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('short_url_visits', function (Blueprint $table): void {
|
||||
$table->dropColumn(['is_bot', 'is_proxy']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('short_urls', function (Blueprint $table) {
|
||||
$table->string('qr_logo')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('short_urls', function (Blueprint $table) {
|
||||
$table->dropColumn('qr_logo');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('short_urls', function (Blueprint $table): void {
|
||||
$table->integer('qr_scans')->default(0);
|
||||
});
|
||||
|
||||
Schema::table('short_url_visits', function (Blueprint $table): void {
|
||||
$table->boolean('is_qr_scan')->default(false)->index();
|
||||
$table->string('browser_language', 10)->nullable()->index();
|
||||
});
|
||||
|
||||
Schema::table('short_url_daily_stats', function (Blueprint $table): void {
|
||||
$table->integer('qr_visits_count')->default(0);
|
||||
$table->json('language_stats')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('short_urls', function (Blueprint $table): void {
|
||||
$table->dropColumn('qr_scans');
|
||||
});
|
||||
|
||||
Schema::table('short_url_visits', function (Blueprint $table): void {
|
||||
$table->dropIndex(['is_qr_scan']);
|
||||
$table->dropColumn('is_qr_scan');
|
||||
$table->dropIndex(['browser_language']);
|
||||
$table->dropColumn('browser_language');
|
||||
});
|
||||
|
||||
Schema::table('short_url_daily_stats', function (Blueprint $table): void {
|
||||
$table->dropColumn(['qr_visits_count', 'language_stats']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('short_urls', function (Blueprint $table): void {
|
||||
$table->boolean('track_browser_language')->default(true);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('short_urls', function (Blueprint $table): void {
|
||||
$table->dropColumn('track_browser_language');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// 1. Create short_url_pixels table
|
||||
Schema::create('short_url_pixels', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name', 150);
|
||||
$table->string('type', 50); // meta, google, linkedin, tiktok, pinterest
|
||||
$table->string('pixel_id', 100);
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['type', 'pixel_id']);
|
||||
});
|
||||
|
||||
// 2. Create short_url_pixel pivot table
|
||||
Schema::create('short_url_pixel', function (Blueprint $table) {
|
||||
$table->foreignId('short_url_id')->constrained('short_urls')->cascadeOnDelete();
|
||||
$table->foreignId('pixel_id')->constrained('short_url_pixels')->cascadeOnDelete();
|
||||
$table->primary(['short_url_id', 'pixel_id']);
|
||||
});
|
||||
|
||||
// 3. Data migration: Migrate existing pixel IDs from short_urls to short_url_pixels and pivot
|
||||
if (Schema::hasColumn('short_urls', 'pixel_meta_id')) {
|
||||
$oldUrls = DB::table('short_urls')
|
||||
->whereNotNull('pixel_meta_id')
|
||||
->orWhereNotNull('pixel_google_id')
|
||||
->orWhereNotNull('pixel_linkedin_id')
|
||||
->get();
|
||||
|
||||
foreach ($oldUrls as $url) {
|
||||
// Migrate Meta Pixel
|
||||
if (! empty($url->pixel_meta_id)) {
|
||||
$pixelId = DB::table('short_url_pixels')
|
||||
->where('type', 'meta')
|
||||
->where('pixel_id', $url->pixel_meta_id)
|
||||
->value('id');
|
||||
|
||||
if (! $pixelId) {
|
||||
$pixelId = DB::table('short_url_pixels')->insertGetId([
|
||||
'name' => 'Meta Pixel ('.$url->pixel_meta_id.')',
|
||||
'type' => 'meta',
|
||||
'pixel_id' => $url->pixel_meta_id,
|
||||
'is_active' => true,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
DB::table('short_url_pixel')->insertOrIgnore([
|
||||
'short_url_id' => $url->id,
|
||||
'pixel_id' => $pixelId,
|
||||
]);
|
||||
}
|
||||
|
||||
// Migrate Google Tag / GA4
|
||||
if (! empty($url->pixel_google_id)) {
|
||||
$pixelId = DB::table('short_url_pixels')
|
||||
->where('type', 'google')
|
||||
->where('pixel_id', $url->pixel_google_id)
|
||||
->value('id');
|
||||
|
||||
if (! $pixelId) {
|
||||
$pixelId = DB::table('short_url_pixels')->insertGetId([
|
||||
'name' => 'Google Tag ('.$url->pixel_google_id.')',
|
||||
'type' => 'google',
|
||||
'pixel_id' => $url->pixel_google_id,
|
||||
'is_active' => true,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
DB::table('short_url_pixel')->insertOrIgnore([
|
||||
'short_url_id' => $url->id,
|
||||
'pixel_id' => $pixelId,
|
||||
]);
|
||||
}
|
||||
|
||||
// Migrate LinkedIn Insight
|
||||
if (! empty($url->pixel_linkedin_id)) {
|
||||
$pixelId = DB::table('short_url_pixels')
|
||||
->where('type', 'linkedin')
|
||||
->where('pixel_id', $url->pixel_linkedin_id)
|
||||
->value('id');
|
||||
|
||||
if (! $pixelId) {
|
||||
$pixelId = DB::table('short_url_pixels')->insertGetId([
|
||||
'name' => 'LinkedIn Insight ('.$url->pixel_linkedin_id.')',
|
||||
'type' => 'linkedin',
|
||||
'pixel_id' => $url->pixel_linkedin_id,
|
||||
'is_active' => true,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
DB::table('short_url_pixel')->insertOrIgnore([
|
||||
'short_url_id' => $url->id,
|
||||
'pixel_id' => $pixelId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Drop the old columns
|
||||
Schema::table('short_urls', function (Blueprint $table) {
|
||||
$table->dropColumn(['pixel_meta_id', 'pixel_google_id', 'pixel_linkedin_id']);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
// 1. Re-add the columns
|
||||
Schema::table('short_urls', function (Blueprint $table) {
|
||||
$table->string('pixel_meta_id', 100)->nullable();
|
||||
$table->string('pixel_google_id', 100)->nullable();
|
||||
$table->string('pixel_linkedin_id', 100)->nullable();
|
||||
});
|
||||
|
||||
// 2. Re-populate the old columns from pivot data
|
||||
$associations = DB::table('short_url_pixel')
|
||||
->join('short_url_pixels', 'short_url_pixel.pixel_id', '=', 'short_url_pixels.id')
|
||||
->select('short_url_pixel.short_url_id', 'short_url_pixels.type', 'short_url_pixels.pixel_id')
|
||||
->get();
|
||||
|
||||
foreach ($associations as $assoc) {
|
||||
if ($assoc->type === 'meta') {
|
||||
DB::table('short_urls')->where('id', $assoc->short_url_id)->update(['pixel_meta_id' => $assoc->pixel_id]);
|
||||
} elseif ($assoc->type === 'google') {
|
||||
DB::table('short_urls')->where('id', $assoc->short_url_id)->update(['pixel_google_id' => $assoc->pixel_id]);
|
||||
} elseif ($assoc->type === 'linkedin') {
|
||||
DB::table('short_urls')->where('id', $assoc->short_url_id)->update(['pixel_linkedin_id' => $assoc->pixel_id]);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Drop tables
|
||||
Schema::dropIfExists('short_url_pixel');
|
||||
Schema::dropIfExists('short_url_pixels');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('short_urls', function (Blueprint $table) {
|
||||
$table->boolean('auto_open_app_mobile')->default(false)->after('forward_query_params');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('short_urls', function (Blueprint $table) {
|
||||
$table->dropColumn('auto_open_app_mobile');
|
||||
});
|
||||
}
|
||||
};
|
||||
17
resources/css/plugin.css
Normal file
17
resources/css/plugin.css
Normal file
@@ -0,0 +1,17 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@source "../../src/**/*.php";
|
||||
@source "../../resources/views/**/*.blade.php";
|
||||
|
||||
.short-url-card {
|
||||
position: relative !important;
|
||||
}
|
||||
|
||||
.short-url-card .fi-ta-actions {
|
||||
position: absolute !important;
|
||||
top: 1.25rem !important;
|
||||
right: 1.25rem !important;
|
||||
z-index: 10 !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
2
resources/dist/filament-short-url.css
vendored
Normal file
2
resources/dist/filament-short-url.css
vendored
Normal file
File diff suppressed because one or more lines are too long
253
resources/lang/en/countries.php
Normal file
253
resources/lang/en/countries.php
Normal file
@@ -0,0 +1,253 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'AF' => 'Afghanistan',
|
||||
'AX' => 'Åland Islands',
|
||||
'AL' => 'Albania',
|
||||
'DZ' => 'Algeria',
|
||||
'AS' => 'American Samoa',
|
||||
'AD' => 'Andorra',
|
||||
'AO' => 'Angola',
|
||||
'AI' => 'Anguilla',
|
||||
'AQ' => 'Antarctica',
|
||||
'AG' => 'Antigua and Barbuda',
|
||||
'AR' => 'Argentina',
|
||||
'AM' => 'Armenia',
|
||||
'AW' => 'Aruba',
|
||||
'AU' => 'Australia',
|
||||
'AT' => 'Austria',
|
||||
'AZ' => 'Azerbaijan',
|
||||
'BS' => 'Bahamas',
|
||||
'BH' => 'Bahrain',
|
||||
'BD' => 'Bangladesh',
|
||||
'BB' => 'Barbados',
|
||||
'BY' => 'Belarus',
|
||||
'BE' => 'Belgium',
|
||||
'BZ' => 'Belize',
|
||||
'BJ' => 'Benin',
|
||||
'BM' => 'Bermuda',
|
||||
'BT' => 'Bhutan',
|
||||
'BO' => 'Bolivia',
|
||||
'BQ' => 'Bonaire, Sint Eustatius and Saba',
|
||||
'BA' => 'Bosnia and Herzegovina',
|
||||
'BW' => 'Botswana',
|
||||
'BV' => 'Bouvet Island',
|
||||
'BR' => 'Brazil',
|
||||
'IO' => 'British Indian Ocean Territory',
|
||||
'BN' => 'Brunei Darussalam',
|
||||
'BG' => 'Bulgaria',
|
||||
'BF' => 'Burkina Faso',
|
||||
'BI' => 'Burundi',
|
||||
'CV' => 'Cabo Verde',
|
||||
'KH' => 'Cambodia',
|
||||
'CM' => 'Cameroon',
|
||||
'CA' => 'Canada',
|
||||
'KY' => 'Cayman Islands',
|
||||
'CF' => 'Central African Republic',
|
||||
'TD' => 'Chad',
|
||||
'CL' => 'Chile',
|
||||
'CN' => 'China',
|
||||
'CX' => 'Christmas Island',
|
||||
'CC' => 'Cocos (Keeling) Islands',
|
||||
'CO' => 'Colombia',
|
||||
'KM' => 'Comoros',
|
||||
'CD' => 'Congo (Democratic Republic)',
|
||||
'CG' => 'Congo (Republic)',
|
||||
'CK' => 'Cook Islands',
|
||||
'CR' => 'Costa Rica',
|
||||
'CI' => 'Côte d\'Ivoire',
|
||||
'HR' => 'Croatia',
|
||||
'CU' => 'Cuba',
|
||||
'CW' => 'Curaçao',
|
||||
'CY' => 'Cyprus',
|
||||
'CZ' => 'Czech Republic',
|
||||
'DK' => 'Denmark',
|
||||
'DJ' => 'Djibouti',
|
||||
'DM' => 'Dominica',
|
||||
'DO' => 'Dominican Republic',
|
||||
'EC' => 'Ecuador',
|
||||
'EG' => 'Egypt',
|
||||
'SV' => 'El Salvador',
|
||||
'GQ' => 'Equatorial Guinea',
|
||||
'ER' => 'Eritrea',
|
||||
'EE' => 'Estonia',
|
||||
'SZ' => 'Eswatini',
|
||||
'ET' => 'Ethiopia',
|
||||
'FK' => 'Falkland Islands',
|
||||
'FO' => 'Faroe Islands',
|
||||
'FJ' => 'Fiji',
|
||||
'FI' => 'Finland',
|
||||
'FR' => 'France',
|
||||
'GF' => 'French Guiana',
|
||||
'PF' => 'French Polynesia',
|
||||
'TF' => 'French Southern Territories',
|
||||
'GA' => 'Gabon',
|
||||
'GM' => 'Gambia',
|
||||
'GE' => 'Georgia',
|
||||
'DE' => 'Germany',
|
||||
'GH' => 'Ghana',
|
||||
'GI' => 'Gibraltar',
|
||||
'GR' => 'Greece',
|
||||
'GL' => 'Greenland',
|
||||
'GD' => 'Grenada',
|
||||
'GP' => 'Guadeloupe',
|
||||
'GU' => 'Guam',
|
||||
'GT' => 'Guatemala',
|
||||
'GG' => 'Guernsey',
|
||||
'GN' => 'Guinea',
|
||||
'GW' => 'Guinea-Bissau',
|
||||
'GY' => 'Guyana',
|
||||
'HT' => 'Haiti',
|
||||
'HM' => 'Heard Island and McDonald Islands',
|
||||
'VA' => 'Holy See',
|
||||
'HN' => 'Honduras',
|
||||
'HK' => 'Hong Kong',
|
||||
'HU' => 'Hungary',
|
||||
'IS' => 'Iceland',
|
||||
'IN' => 'India',
|
||||
'ID' => 'Indonesia',
|
||||
'IR' => 'Iran',
|
||||
'IQ' => 'Iraq',
|
||||
'IE' => 'Ireland',
|
||||
'IM' => 'Isle of Man',
|
||||
'IL' => 'Israel',
|
||||
'IT' => 'Italy',
|
||||
'JM' => 'Jamaica',
|
||||
'JP' => 'Japan',
|
||||
'JE' => 'Jersey',
|
||||
'JO' => 'Jordan',
|
||||
'KZ' => 'Kazakhstan',
|
||||
'KE' => 'Kenya',
|
||||
'KI' => 'Kiribati',
|
||||
'KP' => 'North Korea',
|
||||
'KR' => 'South Korea',
|
||||
'KW' => 'Kuwait',
|
||||
'KG' => 'Kyrgyzstan',
|
||||
'LA' => 'Lao People\'s Democratic Republic',
|
||||
'LV' => 'Latvia',
|
||||
'LB' => 'Lebanon',
|
||||
'LS' => 'Lesotho',
|
||||
'LR' => 'Liberia',
|
||||
'LY' => 'Libya',
|
||||
'LI' => 'Liechtenstein',
|
||||
'LT' => 'Lithuania',
|
||||
'LU' => 'Luxembourg',
|
||||
'MO' => 'Macao',
|
||||
'MG' => 'Madagascar',
|
||||
'MW' => 'Malawi',
|
||||
'MY' => 'Malaysia',
|
||||
'MV' => 'Maldives',
|
||||
'ML' => 'Mali',
|
||||
'MT' => 'Malta',
|
||||
'MH' => 'Marshall Islands',
|
||||
'MQ' => 'Martinique',
|
||||
'MR' => 'Mauritania',
|
||||
'MU' => 'Mauritius',
|
||||
'YT' => 'Mayotte',
|
||||
'MX' => 'Mexico',
|
||||
'FM' => 'Micronesia',
|
||||
'MD' => 'Moldova',
|
||||
'MC' => 'Monaco',
|
||||
'MN' => 'Mongolia',
|
||||
'ME' => 'Montenegro',
|
||||
'MS' => 'Montserrat',
|
||||
'MA' => 'Morocco',
|
||||
'MZ' => 'Mozambique',
|
||||
'MM' => 'Myanmar',
|
||||
'NA' => 'Namibia',
|
||||
'NR' => 'Nauru',
|
||||
'NP' => 'Nepal',
|
||||
'NL' => 'Netherlands',
|
||||
'NC' => 'New Caledonia',
|
||||
'NZ' => 'New Zealand',
|
||||
'NI' => 'Nicaragua',
|
||||
'NE' => 'Niger',
|
||||
'NG' => 'Nigeria',
|
||||
'NU' => 'Niue',
|
||||
'NF' => 'Norfolk Island',
|
||||
'MK' => 'North Macedonia',
|
||||
'MP' => 'Northern Mariana Islands',
|
||||
'NO' => 'Norway',
|
||||
'OM' => 'Oman',
|
||||
'PK' => 'Pakistan',
|
||||
'PW' => 'Palau',
|
||||
'PS' => 'Palestine',
|
||||
'PA' => 'Panama',
|
||||
'PG' => 'Papua New Guinea',
|
||||
'PY' => 'Paraguay',
|
||||
'PE' => 'Peru',
|
||||
'PH' => 'Philippines',
|
||||
'PN' => 'Pitcairn',
|
||||
'PL' => 'Poland',
|
||||
'PT' => 'Portugal',
|
||||
'PR' => 'Puerto Rico',
|
||||
'QA' => 'Qatar',
|
||||
'RE' => 'Réunion',
|
||||
'RO' => 'Romania',
|
||||
'RU' => 'Russian Federation',
|
||||
'RW' => 'Rwanda',
|
||||
'BL' => 'Saint Barthélemy',
|
||||
'SH' => 'Saint Helena, Ascension and Tristan da Cunha',
|
||||
'KN' => 'Saint Kitts and Nevis',
|
||||
'LC' => 'Saint Lucia',
|
||||
'MF' => 'Saint Martin (French part)',
|
||||
'PM' => 'Saint Pierre and Miquelon',
|
||||
'VC' => 'Saint Vincent and the Grenadines',
|
||||
'WS' => 'Samoa',
|
||||
'SM' => 'San Marino',
|
||||
'ST' => 'Sao Tome and Principe',
|
||||
'SA' => 'Saudi Arabia',
|
||||
'SN' => 'Senegal',
|
||||
'RS' => 'Serbia',
|
||||
'SC' => 'Seychelles',
|
||||
'SL' => 'Sierra Leone',
|
||||
'SG' => 'Singapore',
|
||||
'SX' => 'Sint Maarten (Dutch part)',
|
||||
'SK' => 'Slovakia',
|
||||
'SI' => 'Slovenia',
|
||||
'SB' => 'Solomon Islands',
|
||||
'SO' => 'Somalia',
|
||||
'ZA' => 'South Africa',
|
||||
'GS' => 'South Georgia and the South Sandwich Islands',
|
||||
'SS' => 'South Sudan',
|
||||
'ES' => 'Spain',
|
||||
'LK' => 'Sri Lanka',
|
||||
'SD' => 'Sudan',
|
||||
'SR' => 'Suriname',
|
||||
'SJ' => 'Svalbard and Jan Mayen',
|
||||
'SE' => 'Sweden',
|
||||
'CH' => 'Switzerland',
|
||||
'SY' => 'Syrian Arab Republic',
|
||||
'TW' => 'Taiwan',
|
||||
'TJ' => 'Tajikistan',
|
||||
'TZ' => 'Tanzania',
|
||||
'TH' => 'Thailand',
|
||||
'TL' => 'Timor-Leste',
|
||||
'TG' => 'Togo',
|
||||
'TK' => 'Tokelau',
|
||||
'TO' => 'Tonga',
|
||||
'TT' => 'Trinidad and Tobago',
|
||||
'TN' => 'Tunisia',
|
||||
'TR' => 'Turkey',
|
||||
'TM' => 'Turkmenistan',
|
||||
'TC' => 'Turks and Caicos Islands',
|
||||
'TV' => 'Tuvalu',
|
||||
'UG' => 'Uganda',
|
||||
'UA' => 'Ukraine',
|
||||
'AE' => 'United Arab Emirates',
|
||||
'GB' => 'United Kingdom',
|
||||
'UM' => 'United States Minor Outlying Islands',
|
||||
'US' => 'United States',
|
||||
'UY' => 'Uruguay',
|
||||
'UZ' => 'Uzbekistan',
|
||||
'VU' => 'Vanuatu',
|
||||
'VE' => 'Venezuela',
|
||||
'VN' => 'Viet Nam',
|
||||
'VG' => 'Virgin Islands (British)',
|
||||
'VI' => 'Virgin Islands (U.S.)',
|
||||
'WF' => 'Wallis and Futuna',
|
||||
'EH' => 'Western Sahara',
|
||||
'YE' => 'Yemen',
|
||||
'ZM' => 'Zambia',
|
||||
'ZW' => 'Zimbabwe',
|
||||
];
|
||||
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'navigation_label' => 'Short URLs',
|
||||
'navigation_label' => 'Short Links',
|
||||
'navigation_group' => 'Tools',
|
||||
'resource_title' => 'Short URL',
|
||||
|
||||
@@ -9,6 +9,16 @@ return [
|
||||
'tab_link' => 'Link Details',
|
||||
'tab_tracking' => 'Tracking Settings',
|
||||
'tab_qr_design' => 'QR Code Design',
|
||||
'tab_app_linking' => 'App Linking',
|
||||
|
||||
// App Linking
|
||||
'form_section_app_linking' => 'App Linking / Deep Links',
|
||||
'auto_open_app_mobile' => 'Auto open app on mobile',
|
||||
'auto_open_app_mobile_helper' => 'Enable this if you want your link to automatically open as an app when accessed on mobile.',
|
||||
'supported_operating_systems' => 'Supported Operating Systems',
|
||||
'supported_apps' => 'Supported Apps',
|
||||
'supported_apps_helper' => 'If you do not find your app here, you can use the Targeting feature to redirect the user to your app with your custom deep link.',
|
||||
'matched' => 'Matched',
|
||||
|
||||
// Link Form Fields
|
||||
'destination_url' => 'Destination URL',
|
||||
@@ -24,6 +34,13 @@ return [
|
||||
'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',
|
||||
'activated_at' => 'Active From',
|
||||
'max_visits' => 'Max Visits Limit',
|
||||
'max_visits_helper' => 'Optional limit of total visits allowed before this link is deactivated.',
|
||||
'expiration_redirect_url' => 'Expiration Redirect URL',
|
||||
'expiration_redirect_url_helper' => 'Fallback URL to redirect to when the link is expired or inactive (leaves blank for 410 Gone error page).',
|
||||
'form_section_validity' => 'Validity & Limits',
|
||||
'use_date_validity' => 'Set Date Range Limits',
|
||||
'notes' => 'Internal Notes',
|
||||
'ga_tracking_id' => 'Google Analytics 4 Measurement ID',
|
||||
'ga_tracking_id_helper' => 'Optional G-XXXXXXXXXX ID to track redirects server-side.',
|
||||
@@ -51,6 +68,7 @@ return [
|
||||
'track_os_version' => 'Track OS Version',
|
||||
'track_device_type' => 'Track Device Type (desktop/mobile/tablet)',
|
||||
'track_referer' => 'Track Referer URL',
|
||||
'track_browser_language' => 'Track Browser Language',
|
||||
|
||||
// QR Design Fields
|
||||
'qr_size' => 'QR Code Size (px)',
|
||||
@@ -91,8 +109,26 @@ return [
|
||||
'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',
|
||||
'qr_label_dots_background' => 'Dots & Background',
|
||||
'qr_label_custom_eye_config' => 'Custom Eye Config',
|
||||
'qr_label_logo_overlay' => 'Logo & Icon Overlay',
|
||||
'qr_label_drag_drop_upload' => 'Drag & drop or click to upload logo',
|
||||
'qr_label_upload_supports' => 'Supports PNG, JPG, SVG up to 10MB',
|
||||
'qr_label_drag_drop_replace' => 'Drag & drop or click to replace',
|
||||
'qr_label_uploading_logo' => 'Uploading logo...',
|
||||
'qr_logo_upload_parse_error' => 'Error parsing server response!',
|
||||
'qr_logo_upload_error' => 'Error during logo file upload!',
|
||||
'qr_logo_upload_connection_error' => 'Connection error during logo file upload!',
|
||||
'qr_label_remove_logo' => 'Remove logo',
|
||||
'qr_label_logo_shape' => 'Logo Shape',
|
||||
'qr_label_logo_size' => 'Logo Size',
|
||||
'qr_label_logo_margin' => 'Logo Margin',
|
||||
'qr_label_clear_dots' => 'Clear Dots Behind Logo',
|
||||
'qr_label_live_preview' => 'Live Preview',
|
||||
'qr_option_circle' => 'Circle',
|
||||
'qr_label_png' => 'PNG',
|
||||
'qr_label_svg' => 'SVG',
|
||||
|
||||
// Table Columns
|
||||
'col_short_url' => 'Short URL',
|
||||
@@ -106,6 +142,22 @@ return [
|
||||
'action_stats' => 'Statistics',
|
||||
'action_copy' => 'Copy URL',
|
||||
'action_qr' => 'QR Code',
|
||||
'action_edit' => 'Edit URL',
|
||||
'action_delete' => 'Delete URL',
|
||||
'action_share' => 'Share Link',
|
||||
'share_title' => 'Share Link',
|
||||
'share_description' => 'Share this short link via:',
|
||||
'share_copy' => 'Copy',
|
||||
'share_copied' => 'Short link copied to clipboard!',
|
||||
'qr_modal_helper' => 'Scan, copy, or download your custom QR code.',
|
||||
'qr_download_svg' => 'Download SVG',
|
||||
'qr_download_png' => 'Download PNG',
|
||||
'success_modal_title' => 'Your link & QR code are ready!',
|
||||
'success_modal_subtitle' => 'Time to get some clicks 🎉',
|
||||
'success_modal_helper' => 'Copy and share manually or choose a platform.',
|
||||
'open_link' => 'Open link',
|
||||
'close_button' => 'Close',
|
||||
'dont_show_again' => "Don't show sharing options after creating a link",
|
||||
|
||||
// Stats Page
|
||||
'stats_title' => 'Statistics',
|
||||
@@ -114,6 +166,7 @@ return [
|
||||
'stats_card_total' => 'Total Visits',
|
||||
'stats_card_unique' => 'Unique Visitors',
|
||||
'stats_card_today' => 'Today',
|
||||
'stats_card_today_clicks' => 'Clicks Today',
|
||||
'stats_card_week' => 'This Week',
|
||||
'stats_card_month' => 'This Month',
|
||||
'stats_chart_title' => 'Visits — Last 30 Days',
|
||||
@@ -153,8 +206,11 @@ return [
|
||||
'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_site_name' => 'Site Name Override',
|
||||
'settings_site_name_helper' => 'Custom brand/site name displayed on redirect prompt screens. If empty, falls back to config("app.name").',
|
||||
'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_route_prefix_helper' => 'URL segment before the short key, e.g. "/s/abc123". Leave empty to serve links directly from the root domain (e.g. "domain.com/abc123"). Change requires a config:clear.',
|
||||
'settings_redirect_code_helper' => 'Temporary redirect (302) forces browsers to request the short URL every time, ensuring accurate tracking of all visits. Permanent redirect (301) is cached by browsers and search engines (better for SEO), but subsequent visits from the same device might not be logged in statistics.',
|
||||
'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',
|
||||
@@ -170,14 +226,289 @@ return [
|
||||
'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_cache_ttl_helper' => 'Time (in seconds) to cache the resolved location per visitor IP. Prevents querying APIs/databases repeatedly on subsequent clicks from the same user. Default: 86400 (24 hours). Max: 31536000 (1 year).',
|
||||
'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_maxmind_info_callout' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M8 0C3.58125 0 0 3.58125 0 8C0 12.4187 3.58125 16 8 16C12.4187 16 16 12.4187 16 8C16 3.58125 12.4187 0 8 0ZM8 14.5C4.41563 14.5 1.5 11.5841 1.5 8C1.5 4.41594 4.41563 1.5 8 1.5C11.5844 1.5 14.5 4.41594 14.5 8C14.5 11.5841 11.5844 14.5 8 14.5ZM9.25 10.5H8.75V7.75C8.75 7.3375 8.41563 7 8 7H7C6.5875 7 6.25 7.3375 6.25 7.75C6.25 8.1625 6.5875 8.5 7 8.5H7.25V10.5H6.75C6.3375 10.5 6 10.8375 6 11.25C6 11.6625 6.3375 12 6.75 12H9.25C9.66406 12 10 11.6641 10 11.25C10 10.8359 9.66563 10.5 9.25 10.5ZM8 6C8.55219 6 9 5.55219 9 5C9 4.44781 8.55219 4 8 4C7.44781 4 7 4.44687 7 5C7 5.55313 7.44687 6 8 6Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>How to configure MaxMind (Offline Geo-IP):</strong><br>1. Register for a free account at <a href="https://www.maxmind.com" target="_blank" class="underline text-primary-600 dark:text-primary-400">maxmind.com</a>.<br>2. Download the free <strong>GeoLite2 Country</strong> or <strong>GeoLite2 City</strong> database in binary format (file extension <code class="px-1 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">.mmdb</code>).<br>3. Upload the file to your server, e.g. under the path <code class="px-1 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">storage/geoip/GeoLite2-Country.mmdb</code>.<br>4. Paste the absolute path below and click the <strong>Verify file</strong> button on the right side of the field.</span></div></div>',
|
||||
'settings_maxmind_verify' => 'Verify file',
|
||||
'settings_maxmind_verify_ok' => '✅ File found & readable',
|
||||
'settings_maxmind_verify_fail' => '❌ File not found or not readable',
|
||||
'settings_maxmind_verify_empty' => 'Please enter a path first.',
|
||||
|
||||
'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.',
|
||||
'settings_ga4_verify' => 'Test connection',
|
||||
'settings_ga4_verify_ok' => '✅ API Secret is valid — GA4 connection successful',
|
||||
'settings_ga4_verify_fail' => '❌ Invalid API Secret — GA4 rejected the request',
|
||||
'settings_ga4_verify_empty' => 'Please enter an API Secret first.',
|
||||
'settings_ga4_verify_error' => '⚠️ Connection error — could not reach GA4',
|
||||
|
||||
'settings_section_buffering' => 'Visit Counters Buffering',
|
||||
'settings_buffering_enabled' => 'Buffer Visit Counts in Cache',
|
||||
'settings_buffering_helper' => 'When enabled, visit count increments are temporarily buffered in the application cache and must be flushed periodically to the database via "php artisan short-url:sync-counters". This prevents row-locking issues and performance degradation under high-traffic spikes.',
|
||||
'settings_buffering_worker_info' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M8 0C3.58125 0 0 3.58125 0 8C0 12.4187 3.58125 16 8 16C12.4187 16 16 12.4187 16 8C16 3.58125 12.4187 0 8 0ZM8 14.5C4.41563 14.5 1.5 11.5841 1.5 8C1.5 4.41594 4.41563 1.5 8 1.5C11.5844 1.5 14.5 4.41594 14.5 8C14.5 11.5841 11.5844 14.5 8 14.5ZM9.25 10.5H8.75V7.75C8.75 7.3375 8.41563 7 8 7H7C6.5875 7 6.25 7.3375 6.25 7.75C6.25 8.1625 6.5875 8.5 7 8.5H7.25V10.5H6.75C6.3375 10.5 6 10.8375 6 11.25C6 11.6625 6.3375 12 6.75 12H9.25C9.66406 12 10 11.6641 10 11.25C10 10.8359 9.66563 10.5 9.25 10.5ZM8 6C8.55219 6 9 5.55219 9 5C9 4.44781 8.55219 4 8 4C7.44781 4 7 4.44687 7 5C7 5.55313 7.44687 6 8 6Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full [&_kbd]:bg-background-light dark:[&_kbd]:bg-background-dark [_code]:!text-current [_kbd]:!text-current [_a]:!text-current [_a]:border-current [_strong]:!text-current text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>Required Cron Task (Scheduler):</strong> You have enabled click counter buffering. You must add the following task to your server\'s crontab:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1</code><br>Command run automatically in the background by the scheduler:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">php artisan short-url:sync-counters</code></span></div></div>',
|
||||
|
||||
// CDN Trust Settings
|
||||
'settings_trust_cdn_headers' => 'Trust CDN & Proxy Headers',
|
||||
'settings_trust_cdn_headers_helper' => 'Enable this if your app sits behind a CDN (like Cloudflare, AWS CloudFront) or a reverse proxy. This allows extracting the real client IP and country code from CDN headers. Warning: only enable this if you are actually behind a proxy, otherwise client IP addresses can be spoofed!',
|
||||
'settings_trust_cdn_headers_info_callout' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-amber-200 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/20" data-callout-type="warning"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-amber-800 dark:text-amber-300" aria-label="Warning"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" /></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full text-amber-900 dark:text-amber-200" data-component-part="callout-content"><span><strong>Warning (IP Address Security):</strong> You have enabled trust for proxy headers. Enable this option <u>only</u> if your website is behind:<br>• <strong>Cloudflare</strong> (reads from CF-Connecting-IP)<br>• <strong>AWS CloudFront</strong> or another CDN system<br>• <strong>Nginx/Apache reverse proxy</strong> forwarding X-Forwarded-For.<br><br><strong>Turn this option OFF</strong> if your server connects directly to users. If left ON without a proxy, malicious users can easily spoof their IP address by sending a custom header.</span></div></div>',
|
||||
'settings_geoip_headers_warning' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-amber-200 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/20" data-callout-type="warning"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-amber-800 dark:text-amber-300" aria-label="Warning"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" /></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full text-amber-900 dark:text-amber-200" data-component-part="callout-content"><span><strong>Proxy Header Trust Disabled:</strong> You selected <em>CDN Headers</em> as the Geo-IP driver, but the option <strong>"Trust CDN & Proxy Headers"</strong> in the <em>General</em> tab is disabled. Location detection will not function until you enable it.</span></div></div>',
|
||||
|
||||
// Stats View & Export Localization
|
||||
'stats_filter_visited_from' => 'Visited From',
|
||||
'stats_filter_visited_until' => 'Visited Until',
|
||||
'stats_action_export' => 'Export CSV',
|
||||
'stats_csv_time' => 'Time',
|
||||
'stats_csv_ip' => 'IP Address',
|
||||
'stats_csv_country' => 'Country',
|
||||
'stats_csv_device' => 'Device',
|
||||
'stats_csv_browser' => 'Browser',
|
||||
'stats_csv_os' => 'OS',
|
||||
'stats_csv_referer' => 'Referer',
|
||||
|
||||
// Bulk Actions Localization
|
||||
'action_enable_selected' => 'Enable selected',
|
||||
'action_disable_selected' => 'Disable selected',
|
||||
|
||||
// Form Sections Localization
|
||||
'form_section_options' => 'Options',
|
||||
'form_section_notes' => 'Internal Notes',
|
||||
'form_section_tracking' => 'Visit Tracking',
|
||||
'form_section_tracked_fields' => 'Tracked Fields',
|
||||
'form_section_analytics' => 'Third-Party Analytics',
|
||||
|
||||
// New Dashboard Analytics Keys
|
||||
'stats_card_top_source' => 'Top UTM Source',
|
||||
'stats_card_top_country' => 'Top Country',
|
||||
'stats_filter_date_range' => 'Date Range',
|
||||
'stats_preset_24_hours' => 'Last 24 Hours',
|
||||
'stats_preset_7_days' => 'Last 7 Days',
|
||||
'stats_preset_30_days' => 'Last 30 Days',
|
||||
'stats_preset_90_days' => 'Last 90 Days',
|
||||
'stats_preset_custom' => 'Custom Range',
|
||||
'stats_breakdown_cities' => 'Top Cities',
|
||||
'stats_no_city_data' => 'No city data.',
|
||||
'stats_breakdown_utm_source' => 'Campaign Sources',
|
||||
'stats_breakdown_utm_medium' => 'Campaign Mediums',
|
||||
'stats_breakdown_utm_campaign' => 'Campaign Names',
|
||||
'stats_no_utm_data' => 'No UTM data recorded.',
|
||||
|
||||
// New Targeting & Security Tab and fields
|
||||
'tab_targeting' => 'Targeting & Security',
|
||||
'security_section_title' => 'Security Controls',
|
||||
'password' => 'Access Password',
|
||||
'password_helper' => 'Require visitors to enter a password before being redirected.',
|
||||
'show_warning_page' => 'Show Redirect Warning Page',
|
||||
'show_warning_page_helper' => 'Show an intermediate screen warning visitors about external redirection (NSFW/phishing protection).',
|
||||
'targeting_type' => 'Targeting Strategy',
|
||||
'targeting_type_none' => 'None (Direct Redirect)',
|
||||
'targeting_type_device' => 'Device-Based Redirects',
|
||||
'targeting_type_country' => 'Country-Based (Geo-IP) Redirects',
|
||||
'targeting_type_rotation' => 'A/B Split Rotation',
|
||||
'targeting_type_language' => 'Browser Language-Based Redirects',
|
||||
'device_targeting_rules' => 'Device Rules',
|
||||
'country_targeting_rules' => 'Country Rules',
|
||||
'language_targeting_rules' => 'Language Rules',
|
||||
'rotation_targeting_rules' => 'Rotation Targets',
|
||||
'device_mobile' => 'Mobile Destination URL',
|
||||
'device_tablet' => 'Tablet Destination URL',
|
||||
'device_desktop' => 'Desktop Destination URL',
|
||||
'country_code' => 'Country Code',
|
||||
'language_code' => 'Language Code',
|
||||
'rotation_url' => 'Destination URL',
|
||||
'rotation_weight' => 'Traffic Weight',
|
||||
'rotation_weight_helper' => 'Percentage of traffic to route to this URL (e.g. 50 for 50%). Weights are balanced proportionally.',
|
||||
|
||||
// New Settings Page Fields
|
||||
'settings_tab_advanced' => 'Performance & Security',
|
||||
'settings_section_aggregation' => 'High-Traffic Log Management',
|
||||
'settings_retention_days' => 'Raw Log Retention Period',
|
||||
'settings_retention_days_helper' => 'Select the duration for which you want to keep detailed raw click logs before they are deleted.',
|
||||
'settings_aggregation_enabled' => 'Enable Automatic Daily Pruning & Aggregation',
|
||||
'settings_aggregation_enabled_helper' => 'When enabled, the plugin automatically registers a daily task in the scheduler (at 02:00) to aggregate visits into daily statistics and delete raw logs older than the chosen retention period.',
|
||||
'retention_30_days' => '30 Days',
|
||||
'retention_60_days' => '60 Days',
|
||||
'retention_90_days' => '90 Days',
|
||||
'retention_180_days' => '180 Days (Half Year)',
|
||||
'retention_365_days' => '365 Days (1 Year)',
|
||||
'retention_730_days' => '2 Years',
|
||||
'settings_section_rate_limiting' => 'Rate Limiting / Bot Protection',
|
||||
'settings_rate_limiting_enabled' => 'Enable Limit Protection',
|
||||
'settings_rate_limiting_enabled_helper' => 'Limit the rate of redirects per client IP address.',
|
||||
'settings_rate_limiting_max_attempts' => 'Max Redirects Allowed',
|
||||
'settings_rate_limiting_max_attempts_helper' => 'Maximum allowed redirection requests within the decay window.',
|
||||
'settings_rate_limiting_decay_seconds' => 'Decay Window (Seconds)',
|
||||
'settings_rate_limiting_decay_seconds_helper' => 'Time period for the rate limiter.',
|
||||
|
||||
// Queue Settings Additions
|
||||
'settings_queue_name' => 'Queue Name',
|
||||
'settings_queue_name_helper' => 'The target queue to which tracking and buffering sync jobs are dispatched. Default: "default".',
|
||||
'settings_queue_worker_info' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M8 0C3.58125 0 0 3.58125 0 8C0 12.4187 3.58125 16 8 16C12.4187 16 16 12.4187 16 8C16 3.58125 12.4187 0 8 0ZM8 14.5C4.41563 14.5 1.5 11.5841 1.5 8C1.5 4.41594 4.41563 1.5 8 1.5C11.5844 1.5 14.5 4.41594 14.5 8C14.5 11.5841 11.5844 14.5 8 14.5ZM9.25 10.5H8.75V7.75C8.75 7.3375 8.41563 7 8 7H7C6.5875 7 6.25 7.3375 6.25 7.75C6.25 8.1625 6.5875 8.5 7 8.5H7.25V10.5H6.75C6.3375 10.5 6 10.8375 6 11.25C6 11.6625 6.3375 12 6.75 12H9.25C9.66406 12 10 11.6641 10 11.25C10 10.8359 9.66563 10.5 9.25 10.5ZM8 6C8.55219 6 9 5.55219 9 5C9 4.44781 8.55219 4 8 4C7.44781 4 7 4.44687 7 5C7 5.55313 7.44687 6 8 6Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full [&_kbd]:bg-background-light dark:[&_kbd]:bg-background-dark [_code]:!text-current [_kbd]:!text-current [_a]:!text-current [_a]:border-current [_strong]:!text-current text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>Queue Worker Required:</strong> The selected connection runs asynchronously. You must run a background worker to process these jobs:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">php artisan queue:work --queue=:queue</code></span></div></div>',
|
||||
'settings_geoip_stats_cache_ttl' => 'Stats Dashboard Cache TTL',
|
||||
'settings_geoip_stats_cache_ttl_helper' => 'Time (in seconds) to cache stats calculation on the dashboard. When viewing charts and logs, the system loads calculated numbers from cache instead of querying millions of rows from SQL on every refresh. Default: 300 (5 minutes). Max: 86400 (24 hours).',
|
||||
|
||||
// Default Tracking Tab
|
||||
'settings_tab_tracking_defaults' => 'Default Tracking',
|
||||
'settings_section_tracking_defaults' => 'Default Visitor Tracking Settings',
|
||||
'settings_section_tracking_defaults_helper' => 'These settings govern the default tracking status for newly created short URLs.',
|
||||
'settings_track_visits_default' => 'Enable Tracking by Default',
|
||||
'settings_track_ip_default' => 'Default IP Address Tracking',
|
||||
'settings_track_browser_default' => 'Default Browser Tracking',
|
||||
'settings_track_browser_version_default' => 'Default Browser Version Tracking',
|
||||
'settings_track_os_default' => 'Default OS Tracking',
|
||||
'settings_track_os_version_default' => 'Default OS Version Tracking',
|
||||
'settings_track_referer_default' => 'Default Referer URL Tracking',
|
||||
'settings_track_device_type_default' => 'Default Device Type Tracking',
|
||||
'settings_track_browser_language_default' => 'Default Browser Language Tracking',
|
||||
|
||||
// QR Defaults Tab
|
||||
'settings_tab_qr' => 'QR Defaults',
|
||||
'settings_section_qr_defaults' => 'Default QR Code Design Options',
|
||||
'settings_section_qr_defaults_helper' => 'Configure standard design styling automatically loaded for newly generated QR codes.',
|
||||
'settings_qr_size' => 'Default Size (px)',
|
||||
'settings_qr_margin' => 'Default Margin',
|
||||
'settings_qr_dot_style' => 'Default Dot Style',
|
||||
'settings_qr_foreground_color' => 'Default Foreground Color',
|
||||
'settings_qr_background_color' => 'Default Background Color',
|
||||
'settings_qr_gradient_enabled' => 'Enable Gradient by Default',
|
||||
'settings_qr_gradient_from' => 'Gradient Color From',
|
||||
'settings_qr_gradient_to' => 'Gradient Color To',
|
||||
'settings_qr_gradient_type' => 'Default Gradient Type',
|
||||
|
||||
// Views
|
||||
'password_title' => 'Password Required',
|
||||
'password_description' => 'This link is password-protected. Please enter the correct password to continue.',
|
||||
'password_placeholder' => 'Enter password',
|
||||
'password_btn_unlock' => 'Unlock & Redirect',
|
||||
'password_error' => 'Incorrect password.',
|
||||
'warning_title' => 'Security Redirect Warning',
|
||||
'warning_description' => 'You are leaving this secure portal and being redirected to an external target link. Please ensure you trust the address below:',
|
||||
'warning_btn_continue' => 'Continue to Destination',
|
||||
'warning_btn_back' => 'Go Back',
|
||||
'pixel_loading_title' => 'Connecting...',
|
||||
'pixel_loading_description' => 'Preparing your connection and forwarding you now.',
|
||||
|
||||
// Marketing & API Tab
|
||||
'tab_marketing' => 'Marketing & API',
|
||||
'marketing_pixels_title' => 'Retargeting Pixels (Client-Side)',
|
||||
'marketing_pixels_desc' => 'Add ad tracking scripts to capture visitor cookies and build remarketing lists.',
|
||||
'pixel_meta' => 'Meta Pixel ID',
|
||||
'pixel_google' => 'Google Tag / GA4 ID',
|
||||
'pixel_linkedin' => 'LinkedIn Partner ID',
|
||||
'marketing_webhooks_title' => 'Link Webhook Integration',
|
||||
'marketing_webhooks_desc' => 'Configure a custom destination URL to send a real-time HTTP POST notification on each click.',
|
||||
'webhook_url' => 'Dedicated Webhook URL',
|
||||
|
||||
// Settings tab additions
|
||||
'settings_tab_developer' => 'API & Webhooks',
|
||||
'settings_section_rest_api' => 'REST API Access',
|
||||
'settings_api_enabled' => 'Enable Developer REST API',
|
||||
'settings_api_enabled_helper' => 'Allow external systems to create, list, and delete short URLs via the REST API. Disable to block all /api/short-url/* endpoints with a 503 response.',
|
||||
'settings_section_global_webhook' => 'Global Webhook Configuration',
|
||||
'settings_global_webhook_enabled' => 'Enable Global Webhook',
|
||||
'settings_global_webhook_enabled_helper' => 'When enabled, the plugin will dispatch HTTP POST notifications for all short link events.',
|
||||
'settings_global_webhook_url' => 'Global Webhook URL',
|
||||
'settings_global_webhook_url_helper' => 'Destination URL to dispatch event payloads asynchronously in the background for all links.',
|
||||
'settings_webhook_events' => 'Monitored Webhook Events',
|
||||
'settings_webhook_events_helper' => 'Select which events should trigger a POST notification.',
|
||||
'webhook_event_visited' => 'Link Visited (Click)',
|
||||
'webhook_event_created' => 'Link Created',
|
||||
'webhook_event_expired' => 'Link Expired (Date)',
|
||||
'webhook_event_limit_reached' => 'Link Click Limit Reached',
|
||||
'settings_section_api_keys' => 'Developer API Keys',
|
||||
'settings_api_keys_description' => 'Manage API keys to integrate with external systems (e.g. CRM, Zapier, Make). Authenticate requests using the X-Api-Key header.',
|
||||
'settings_api_keys' => 'Active API Keys',
|
||||
'api_key_name' => 'Key Name (Description)',
|
||||
'api_key' => 'API Key',
|
||||
'active' => 'Active',
|
||||
|
||||
// Security v2.0
|
||||
'settings_section_security_v2' => 'Security & Anti-Fraud v2.0',
|
||||
'settings_section_security_v2_desc' => 'Configure VPN/proxy detection and Google Safe Browsing URL validation.',
|
||||
'settings_vpn_detection_enabled' => 'Enable VPN & Proxy Detection',
|
||||
'settings_vpn_detection_enabled_helper' => 'When enabled, incoming visits will be checked for VPN, proxy, or Tor usage.',
|
||||
'settings_vpn_driver' => 'Detection Driver',
|
||||
'settings_vpn_driver_helper' => 'Choose between the free IP-API service or VPNAPI.io.',
|
||||
'settings_vpn_driver_ipapi' => 'IP-API (Free)',
|
||||
'settings_vpn_driver_vpnapi' => 'VPNAPI.io (Premium / Free)',
|
||||
'settings_vpnapi_key' => 'VPNAPI.io Key',
|
||||
'settings_vpnapi_key_helper' => 'Your API key from vpnapi.io (required for vpnapi driver).',
|
||||
'settings_vpn_block_action' => 'VPN Block Action',
|
||||
'settings_vpn_block_action_helper' => 'Choose whether to only flag VPN traffic in statistics or actively block them with a 403 Forbidden page.',
|
||||
'settings_vpn_block_flag_only' => 'Flag in stats only (allow redirect)',
|
||||
'settings_vpn_block_block_403' => 'Block traffic (serve 403 Forbidden)',
|
||||
'settings_safe_browsing_enabled' => 'Enable Google Safe Browsing URL Verification',
|
||||
'settings_safe_browsing_enabled_helper' => 'Verify target URLs against Google\'s Threat List to block phishing, malware, and social engineering links.',
|
||||
'settings_safe_browsing_api_key' => 'Google Safe Browsing API Key',
|
||||
'settings_safe_browsing_api_key_helper' => 'Your Google Developer Console Web API key.',
|
||||
'settings_safe_browsing_test' => 'Test API Connection',
|
||||
'settings_safe_browsing_test_empty' => 'Please enter an API key first.',
|
||||
'settings_safe_browsing_test_ok' => 'Google Safe Browsing connection OK (API is active).',
|
||||
'settings_safe_browsing_test_fail' => 'Google Safe Browsing key validation failed.',
|
||||
'settings_safe_browsing_test_error' => 'API connection error.',
|
||||
|
||||
// World Map Widget
|
||||
'world_map_title' => 'Visitor World Map',
|
||||
'world_map_total_clicks' => 'total clicks',
|
||||
'world_map_countries' => 'countries',
|
||||
'world_map_fewer' => 'Fewer',
|
||||
'world_map_more' => 'More',
|
||||
'world_map_top_countries' => 'Top Countries',
|
||||
'world_map_no_data' => 'No geographic data yet.',
|
||||
'world_map_no_data_sub' => 'Enable Geo-IP detection to start recording visitor locations.',
|
||||
'stats_card_qr_scans' => 'QR Code Scans',
|
||||
'stats_breakdown_languages' => 'Top Languages',
|
||||
'stats_no_language_data' => 'No language data.',
|
||||
'badge_clicks' => 'clicks',
|
||||
'badge_unique' => 'unique',
|
||||
'badge_qr' => 'QR',
|
||||
'badge_qr_scans' => 'QR scans',
|
||||
'badge_expires' => 'Expires: :date',
|
||||
'badge_no_expiry' => 'No expiry',
|
||||
'badge_redirect' => ':code redirect',
|
||||
'pixels_navigation_label' => 'Retargeting Pixels',
|
||||
'pixel_resource_title' => 'Retargeting Pixel',
|
||||
'pixel_name' => 'Pixel Name',
|
||||
'pixel_type' => 'Provider',
|
||||
'pixel_id_label' => 'Pixel ID / Tag ID',
|
||||
'pixel_status_active' => 'Active',
|
||||
'settings_tab_deep_linking' => 'Deep Linking',
|
||||
'settings_section_deep_linking' => 'Universal Links & App Links Association Files',
|
||||
'settings_deep_linking_enabled' => 'Enable Deep Linking Files',
|
||||
'settings_deep_linking_enabled_helper' => 'When enabled, the plugin serves AASA and AssetLinks configuration files from your root domain.',
|
||||
'settings_aasa_json' => 'apple-app-site-association (iOS)',
|
||||
'settings_aasa_json_helper' => 'Specify the valid JSON configuration for Apple App Site Association. Do not include wrapping script tags.',
|
||||
'settings_assetlinks_json' => 'assetlinks.json (Android)',
|
||||
'settings_assetlinks_json_helper' => 'Specify the valid JSON array configuration for Android Digital Asset Links.',
|
||||
'validation_invalid_json' => 'The entered text is not a valid JSON string.',
|
||||
|
||||
// App Linking Preview
|
||||
'app_linking_supported_os' => 'Supported Operating Systems:',
|
||||
'app_linking_supported_apps' => 'Supported Apps',
|
||||
'app_linking_supported_apps_helper' => 'If you do not find your app here, you can use the Targeting feature to redirect the user to your app with your custom deep link.',
|
||||
'app_linking_matched' => 'Matched',
|
||||
'app_linking_preconfigured_count' => ':count apps pre-configured for automatic redirection.',
|
||||
'app_linking_redirect_active' => 'Opens directly inside :app',
|
||||
'app_linking_auto_open' => 'Auto-Open',
|
||||
'app_linking_matched_description' => 'When clicked on a mobile device, this link will open immediately inside the native <strong>:app</strong> application instead of the web browser.',
|
||||
'app_linking_deep_link_scheme' => 'Mobile app path (Deep Link): :scheme',
|
||||
'app_linking_standard_redirect' => 'This link will open in a standard mobile browser (no matching mobile app found).',
|
||||
|
||||
// App Redirect Page
|
||||
'app_redirect_title' => 'Opening App...',
|
||||
'app_redirect_opening_in' => 'Opening in :app',
|
||||
'app_redirect_waiting_text' => 'We are redirecting you to the native application. Please wait a moment...',
|
||||
'app_redirect_btn_open' => 'Open Native App',
|
||||
'app_redirect_btn_browser' => 'Open in Browser',
|
||||
'app_redirect_in_app_warning_title' => 'In-App Browser Detected',
|
||||
'app_redirect_in_app_warning_desc' => 'Redirections can be restricted inside Instagram or Facebook. Tap the three dots (•••) in the top-right corner and select "Open in Safari" or "Open in Chrome" to launch the app.',
|
||||
|
||||
// Link Expired Page
|
||||
'expired_title' => 'Link Inactive or Expired',
|
||||
'expired_description' => 'This link is no longer active. It may have reached its click limit, expiration date, or has been deactivated.',
|
||||
'expired_btn_home' => 'Go to Homepage',
|
||||
];
|
||||
|
||||
44
resources/lang/en/languages.php
Normal file
44
resources/lang/en/languages.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'pl' => 'Polish',
|
||||
'en' => 'English',
|
||||
'de' => 'German',
|
||||
'fr' => 'French',
|
||||
'es' => 'Spanish',
|
||||
'it' => 'Italian',
|
||||
'pt' => 'Portuguese',
|
||||
'ru' => 'Russian',
|
||||
'zh' => 'Chinese',
|
||||
'ja' => 'Japanese',
|
||||
'ar' => 'Arabic',
|
||||
'nl' => 'Dutch',
|
||||
'cs' => 'Czech',
|
||||
'sk' => 'Slovak',
|
||||
'hu' => 'Hungarian',
|
||||
'ro' => 'Romanian',
|
||||
'bg' => 'Bulgarian',
|
||||
'hr' => 'Croatian',
|
||||
'sr' => 'Serbian',
|
||||
'sl' => 'Slovenian',
|
||||
'uk' => 'Ukrainian',
|
||||
'tr' => 'Turkish',
|
||||
'da' => 'Danish',
|
||||
'sv' => 'Swedish',
|
||||
'no' => 'Norwegian',
|
||||
'fi' => 'Finnish',
|
||||
'el' => 'Greek',
|
||||
'he' => 'Hebrew',
|
||||
'hi' => 'Hindi',
|
||||
'th' => 'Thai',
|
||||
'vi' => 'Vietnamese',
|
||||
'ko' => 'Korean',
|
||||
'id' => 'Indonesian',
|
||||
'ms' => 'Malay',
|
||||
'et' => 'Estonian',
|
||||
'lv' => 'Latvian',
|
||||
'lt' => 'Lithuanian',
|
||||
'is' => 'Icelandic',
|
||||
'ga' => 'Irish',
|
||||
'fa' => 'Persian',
|
||||
];
|
||||
253
resources/lang/pl/countries.php
Normal file
253
resources/lang/pl/countries.php
Normal file
@@ -0,0 +1,253 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'AF' => 'Afganistan',
|
||||
'AX' => 'Wyspy Alandzkie',
|
||||
'AL' => 'Albania',
|
||||
'DZ' => 'Algieria',
|
||||
'AS' => 'Samoa Amerykańskie',
|
||||
'AD' => 'Andora',
|
||||
'AO' => 'Angola',
|
||||
'AI' => 'Anguilla',
|
||||
'AQ' => 'Antarktyda',
|
||||
'AG' => 'Antigua i Barbuda',
|
||||
'AR' => 'Argentyna',
|
||||
'AM' => 'Armenia',
|
||||
'AW' => 'Aruba',
|
||||
'AU' => 'Australia',
|
||||
'AT' => 'Austria',
|
||||
'AZ' => 'Azerbejdżan',
|
||||
'BS' => 'Bahamy',
|
||||
'BH' => 'Bahrajn',
|
||||
'BD' => 'Bangladesz',
|
||||
'BB' => 'Barbados',
|
||||
'BY' => 'Białoruś',
|
||||
'BE' => 'Belgia',
|
||||
'BZ' => 'Belize',
|
||||
'BJ' => 'Benin',
|
||||
'BM' => 'Bermudy',
|
||||
'BT' => 'Bhutan',
|
||||
'BO' => 'Boliwia',
|
||||
'BQ' => 'Bonaire, Sint Eustatius i Saba',
|
||||
'BA' => 'Bośnia i Hercegowina',
|
||||
'BW' => 'Botswana',
|
||||
'BV' => 'Wyspa Bouveta',
|
||||
'BR' => 'Brazylia',
|
||||
'IO' => 'Brytyjskie Terytorium Oceanu Indyjskiego',
|
||||
'BN' => 'Brunei',
|
||||
'BG' => 'Bułgaria',
|
||||
'BF' => 'Burkina Faso',
|
||||
'BI' => 'Burundi',
|
||||
'CV' => 'Cabo Verde',
|
||||
'KH' => 'Kambodża',
|
||||
'CM' => 'Kamerun',
|
||||
'CA' => 'Kanada',
|
||||
'KY' => 'Kajmany',
|
||||
'CF' => 'Republika Środkowoafrykańska',
|
||||
'TD' => 'Czad',
|
||||
'CL' => 'Chile',
|
||||
'CN' => 'Chiny',
|
||||
'CX' => 'Wyspa Bożego Narodzenia',
|
||||
'CC' => 'Wyspy Kokosowe',
|
||||
'CO' => 'Kolumbia',
|
||||
'KM' => 'Komory',
|
||||
'CD' => 'Demokratyczna Republika Konga',
|
||||
'CG' => 'Kongo',
|
||||
'CK' => 'Wyspy Cooka',
|
||||
'CR' => 'Kostaryka',
|
||||
'CI' => 'Wybrzeże Kości Słoniowej',
|
||||
'HR' => 'Chorwacja',
|
||||
'CU' => 'Kuba',
|
||||
'CW' => 'Curaçao',
|
||||
'CY' => 'Cypr',
|
||||
'CZ' => 'Czechy',
|
||||
'DK' => 'Dania',
|
||||
'DJ' => 'Dżibuti',
|
||||
'DM' => 'Dominika',
|
||||
'DO' => 'Dominikana',
|
||||
'EC' => 'Ekwador',
|
||||
'EG' => 'Egipt',
|
||||
'SV' => 'Salwador',
|
||||
'GQ' => 'Gwinea Równikowa',
|
||||
'ER' => 'Erytrea',
|
||||
'EE' => 'Estonia',
|
||||
'SZ' => 'Eswatini',
|
||||
'ET' => 'Etiopia',
|
||||
'FK' => 'Falklandy',
|
||||
'FO' => 'Wyspy Owcze',
|
||||
'FJ' => 'Fidżi',
|
||||
'FI' => 'Finlandia',
|
||||
'FR' => 'Francja',
|
||||
'GF' => 'Gujana Francuska',
|
||||
'PF' => 'Polinezja Francuska',
|
||||
'TF' => 'Francuskie Terytoria Południowe',
|
||||
'GA' => 'Gabon',
|
||||
'GM' => 'Gambia',
|
||||
'GE' => 'Gruzja',
|
||||
'DE' => 'Niemcy',
|
||||
'GH' => 'Ghana',
|
||||
'GI' => 'Gibraltar',
|
||||
'GR' => 'Grecja',
|
||||
'GL' => 'Grenlandia',
|
||||
'GD' => 'Grenada',
|
||||
'GP' => 'Gwadelupa',
|
||||
'GU' => 'Guam',
|
||||
'GT' => 'Gwatemala',
|
||||
'GG' => 'Guernsey',
|
||||
'GN' => 'Gwinea',
|
||||
'GW' => 'Gwinea Bissau',
|
||||
'GY' => 'Gujana',
|
||||
'HT' => 'Haiti',
|
||||
'HM' => 'Wyspy Heard i McDonalda',
|
||||
'VA' => 'Watykan',
|
||||
'HN' => 'Honduras',
|
||||
'HK' => 'Hongkong',
|
||||
'HU' => 'Węgry',
|
||||
'IS' => 'Islandia',
|
||||
'IN' => 'Indie',
|
||||
'ID' => 'Indonezja',
|
||||
'IR' => 'Iran',
|
||||
'IQ' => 'Irak',
|
||||
'IE' => 'Irlandia',
|
||||
'IM' => 'Wyspa Man',
|
||||
'IL' => 'Izrael',
|
||||
'IT' => 'Włochy',
|
||||
'JM' => 'Jamajka',
|
||||
'JP' => 'Japonia',
|
||||
'JE' => 'Jersey',
|
||||
'JO' => 'Jordania',
|
||||
'KZ' => 'Kazachstan',
|
||||
'KE' => 'Kenia',
|
||||
'KI' => 'Kiribati',
|
||||
'KP' => 'Korea Północna',
|
||||
'KR' => 'Korea Południowa',
|
||||
'KW' => 'Kuwejt',
|
||||
'KG' => 'Kirgistan',
|
||||
'LA' => 'Laos',
|
||||
'LV' => 'Łotwa',
|
||||
'LB' => 'Liban',
|
||||
'LS' => 'Lesotho',
|
||||
'LR' => 'Liberia',
|
||||
'LY' => 'Libia',
|
||||
'LI' => 'Liechtenstein',
|
||||
'LT' => 'Litwa',
|
||||
'LU' => 'Luksemburg',
|
||||
'MO' => 'Makau',
|
||||
'MG' => 'Madagaskar',
|
||||
'MW' => 'Malawi',
|
||||
'MY' => 'Malezja',
|
||||
'MV' => 'Malediwy',
|
||||
'ML' => 'Mali',
|
||||
'MT' => 'Malta',
|
||||
'MH' => 'Wyspy Marshalla',
|
||||
'MQ' => 'Martynika',
|
||||
'MR' => 'Mauretania',
|
||||
'MU' => 'Mauritius',
|
||||
'YT' => 'Majotta',
|
||||
'MX' => 'Meksyk',
|
||||
'FM' => 'Mikronezja',
|
||||
'MD' => 'Mołdawia',
|
||||
'MC' => 'Monako',
|
||||
'MN' => 'Mongolia',
|
||||
'ME' => 'Czarnogóra',
|
||||
'MS' => 'Montserrat',
|
||||
'MA' => 'Maroko',
|
||||
'MZ' => 'Mozambik',
|
||||
'MM' => 'Mjanma',
|
||||
'NA' => 'Namibia',
|
||||
'NR' => 'Nauru',
|
||||
'NP' => 'Nepal',
|
||||
'NL' => 'Holandia',
|
||||
'NC' => 'Nowa Kaledonia',
|
||||
'NZ' => 'Nowa Zelandia',
|
||||
'NI' => 'Nikaragua',
|
||||
'NE' => 'Niger',
|
||||
'NG' => 'Nigeria',
|
||||
'NU' => 'Niue',
|
||||
'NF' => 'Norfolk',
|
||||
'MK' => 'Macedonia Północna',
|
||||
'MP' => 'Mariany Północne',
|
||||
'NO' => 'Norwegia',
|
||||
'OM' => 'Oman',
|
||||
'PK' => 'Pakistan',
|
||||
'PW' => 'Palau',
|
||||
'PS' => 'Palestyna',
|
||||
'PA' => 'Panama',
|
||||
'PG' => 'Papua-Nowa Gwinea',
|
||||
'PY' => 'Paragwaj',
|
||||
'PE' => 'Peru',
|
||||
'PH' => 'Filipiny',
|
||||
'PN' => 'Pitcairn',
|
||||
'PL' => 'Polska',
|
||||
'PT' => 'Portugalia',
|
||||
'PR' => 'Portoryko',
|
||||
'QA' => 'Katar',
|
||||
'RE' => 'Reunion',
|
||||
'RO' => 'Rumunia',
|
||||
'RU' => 'Rosja',
|
||||
'RW' => 'Rwanda',
|
||||
'BL' => 'Saint-Barthélemy',
|
||||
'SH' => 'Święta Helena, Wyspa Wniebowstąpienia i Tristan da Cunha',
|
||||
'KN' => 'Saint Kitts i Nevis',
|
||||
'LC' => 'Saint Lucia',
|
||||
'MF' => 'Saint-Martin',
|
||||
'PM' => 'Saint-Pierre i Miquelon',
|
||||
'VC' => 'Saint Vincent i Grenadyny',
|
||||
'WS' => 'Samoa',
|
||||
'SM' => 'San Marino',
|
||||
'ST' => 'Wyspy Świętego Tomasza i Książęca',
|
||||
'SA' => 'Arabia Saudyjska',
|
||||
'SN' => 'Senegal',
|
||||
'RS' => 'Serbia',
|
||||
'SC' => 'Seszele',
|
||||
'SL' => 'Sierra Leone',
|
||||
'SG' => 'Singapur',
|
||||
'SX' => 'Sint Maarten',
|
||||
'SK' => 'Słowacja',
|
||||
'SI' => 'Słowenia',
|
||||
'SB' => 'Wyspy Salomona',
|
||||
'SO' => 'Somalia',
|
||||
'ZA' => 'Południowa Afryka',
|
||||
'GS' => 'Georgia Południowa i Sandwich Południowy',
|
||||
'SS' => 'Sudan Południowy',
|
||||
'ES' => 'Hiszpania',
|
||||
'LK' => 'Sri Lanka',
|
||||
'SD' => 'Sudan',
|
||||
'SR' => 'Surinam',
|
||||
'SJ' => 'Svalbard i Jan Mayen',
|
||||
'SE' => 'Szwecja',
|
||||
'CH' => 'Szwajcaria',
|
||||
'SY' => 'Syria',
|
||||
'TW' => 'Tajwan',
|
||||
'TJ' => 'Tadżykistan',
|
||||
'TZ' => 'Tanzania',
|
||||
'TH' => 'Tajlandia',
|
||||
'TL' => 'Timor Wschodni',
|
||||
'TG' => 'Togo',
|
||||
'TK' => 'Tokelau',
|
||||
'TO' => 'Tonga',
|
||||
'TT' => 'Trynidad i Tobago',
|
||||
'TN' => 'Tunezja',
|
||||
'TR' => 'Turcja',
|
||||
'TM' => 'Turkmenistan',
|
||||
'TC' => 'Turks i Caicos',
|
||||
'TV' => 'Tuvalu',
|
||||
'UG' => 'Uganda',
|
||||
'UA' => 'Ukraina',
|
||||
'AE' => 'Zjednoczone Emiraty Arabskie',
|
||||
'GB' => 'Wielka Brytania',
|
||||
'UM' => 'Dalekie Wyspy Mniejsze Stanów Zjednoczonych',
|
||||
'US' => 'Stany Zjednoczone',
|
||||
'UY' => 'Urugwaj',
|
||||
'UZ' => 'Uzbekistan',
|
||||
'VU' => 'Vanuatu',
|
||||
'VE' => 'Wenezuela',
|
||||
'VN' => 'Wietnam',
|
||||
'VG' => 'Brytyjskie Wyspy Dziewicze',
|
||||
'VI' => 'Wyspy Dziewicze Stanów Zjednoczonych',
|
||||
'WF' => 'Wallis i Futuna',
|
||||
'EH' => 'Sahara Zachodnia',
|
||||
'YE' => 'Jemen',
|
||||
'ZM' => 'Zambia',
|
||||
'ZW' => 'Zimbabwe',
|
||||
];
|
||||
@@ -9,6 +9,12 @@ return [
|
||||
'tab_link' => 'Szczegóły linku',
|
||||
'tab_tracking' => 'Ustawienia śledzenia',
|
||||
'tab_qr_design' => 'Wygląd kodu QR',
|
||||
'tab_app_linking' => 'Autootwieranie aplikacji',
|
||||
|
||||
// App Linking
|
||||
'form_section_app_linking' => 'Autootwieranie aplikacji / Głębokie linkowanie',
|
||||
'auto_open_app_mobile' => 'Automatycznie otwieraj aplikację na urządzeniach mobilnych',
|
||||
'auto_open_app_mobile_helper' => 'Włącz tę opcję, jeśli chcesz, aby po kliknięciu linku na telefonie automatycznie otwierała się dedykowana aplikacja mobilna zamiast przeglądarki.',
|
||||
|
||||
// Link Form Fields
|
||||
'destination_url' => 'Docelowy URL',
|
||||
@@ -24,6 +30,13 @@ return [
|
||||
'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',
|
||||
'activated_at' => 'Aktywny od',
|
||||
'max_visits' => 'Maksymalny limit wejść',
|
||||
'max_visits_helper' => 'Opcjonalny limit liczby wejść, po osiągnięciu którego link zostanie automatycznie wyłączony.',
|
||||
'expiration_redirect_url' => 'URL przekierowania po wygaśnięciu',
|
||||
'expiration_redirect_url_helper' => 'Adres URL, na który nastąpi przekierowanie, gdy link wygaśnie lub będzie nieaktywny (pozostaw puste, aby wyświetlić błąd 410 Gone).',
|
||||
'form_section_validity' => 'Ważność i limity',
|
||||
'use_date_validity' => 'Ustaw limity datowe',
|
||||
'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.',
|
||||
@@ -51,6 +64,7 @@ return [
|
||||
'track_os_version' => 'Śledź wersję systemu operacyjnego',
|
||||
'track_device_type' => 'Śledź typ urządzenia (desktop/mobile/tablet)',
|
||||
'track_referer' => 'Śledź referer URL',
|
||||
'track_browser_language' => 'Śledź język przeglądarki',
|
||||
|
||||
// QR Design Fields
|
||||
'qr_size' => 'Rozmiar kodu QR (px)',
|
||||
@@ -91,8 +105,26 @@ return [
|
||||
'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',
|
||||
'qr_label_dots_background' => 'Punkty i tło',
|
||||
'qr_label_custom_eye_config' => 'Własny styl oczu',
|
||||
'qr_label_logo_overlay' => 'Nakładka logo',
|
||||
'qr_label_drag_drop_upload' => 'Przeciągnij i upuść lub kliknij, aby wgrać logo',
|
||||
'qr_label_upload_supports' => 'Obsługuje PNG, JPG, SVG do 10MB',
|
||||
'qr_label_drag_drop_replace' => 'Przeciągnij i upuść lub kliknij, aby zastąpić logo',
|
||||
'qr_label_uploading_logo' => 'Wgrywanie logo...',
|
||||
'qr_logo_upload_parse_error' => 'Błąd przetwarzania odpowiedzi serwera!',
|
||||
'qr_logo_upload_error' => 'Błąd podczas wgrywania pliku logo!',
|
||||
'qr_logo_upload_connection_error' => 'Błąd połączenia podczas wgrywania pliku logo!',
|
||||
'qr_label_remove_logo' => 'Usuń logo',
|
||||
'qr_label_logo_shape' => 'Kształt logo',
|
||||
'qr_label_logo_size' => 'Rozmiar logo',
|
||||
'qr_label_logo_margin' => 'Margines logo',
|
||||
'qr_label_clear_dots' => 'Usuń punkty pod logo',
|
||||
'qr_label_live_preview' => 'Podgląd na żywo',
|
||||
'qr_option_circle' => 'Koło',
|
||||
'qr_label_png' => 'PNG',
|
||||
'qr_label_svg' => 'SVG',
|
||||
|
||||
// Table Columns
|
||||
'col_short_url' => 'Krótki URL',
|
||||
@@ -106,6 +138,22 @@ return [
|
||||
'action_stats' => 'Statystyki',
|
||||
'action_copy' => 'Kopiuj link',
|
||||
'action_qr' => 'Kod QR',
|
||||
'action_edit' => 'Edytuj URL',
|
||||
'action_delete' => 'Usuń URL',
|
||||
'action_share' => 'Udostępnij link',
|
||||
'share_title' => 'Udostępnij Link',
|
||||
'share_description' => 'Udostępnij ten krótki link przez:',
|
||||
'share_copy' => 'Skopiuj',
|
||||
'share_copied' => 'Krótki link skopiowany do schowka!',
|
||||
'qr_modal_helper' => 'Zeskanuj, skopiuj lub pobierz swój spersonalizowany kod QR.',
|
||||
'qr_download_svg' => 'Pobierz SVG',
|
||||
'qr_download_png' => 'Pobierz PNG',
|
||||
'success_modal_title' => 'Twój link i kod QR są gotowe!',
|
||||
'success_modal_subtitle' => 'Czas na kliknięcia 🎉',
|
||||
'success_modal_helper' => 'Skopiuj i udostępnij ręcznie lub wybierz platformę.',
|
||||
'open_link' => 'Otwórz link',
|
||||
'close_button' => 'Zamknij',
|
||||
'dont_show_again' => "Nie pokazuj opcji udostępniania po utworzeniu linku",
|
||||
|
||||
// Stats Page
|
||||
'stats_title' => 'Statystyki',
|
||||
@@ -114,6 +162,7 @@ return [
|
||||
'stats_card_total' => 'Wszystkie wizyty',
|
||||
'stats_card_unique' => 'Unikalni goście',
|
||||
'stats_card_today' => 'Dzisiaj',
|
||||
'stats_card_today_clicks' => 'Kliknięcia dzisiaj',
|
||||
'stats_card_week' => 'W tym tygodniu',
|
||||
'stats_card_month' => 'W tym miesiącu',
|
||||
'stats_chart_title' => 'Wizyty — Ostatnie 30 dni',
|
||||
@@ -154,8 +203,11 @@ return [
|
||||
'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_site_name' => 'Niestandardowa nazwa witryny',
|
||||
'settings_site_name_helper' => 'Własna nazwa witryny/marki wyświetlana na ekranach przekierowań (monit o hasło, ostrzeżenia, piksele). W przypadku braku wartości, zostanie użyta nazwa z konfiguracji config("app.name").',
|
||||
'settings_route_prefix' => 'Prefiks trasy',
|
||||
'settings_route_prefix_helper' => 'Segment URL przed kluczem, np. "/s/abc123". Zmiana wymaga php artisan config:clear.',
|
||||
'settings_route_prefix_helper' => 'Segment URL przed kluczem, np. "/s/abc123". Pozostaw puste, aby serwować linki bezpośrednio w głównej domenie (np. "domena.com/abc123"). Zmiana wymaga php artisan config:clear.',
|
||||
'settings_redirect_code_helper' => 'Przekierowanie tymczasowe (302) zmusza przeglądarki do każdorazowego odpytywania serwera, co pozwala na dokładne zliczanie wszystkich wizyt. Przekierowanie stałe (301) jest zapisywane w pamięci podręcznej przeglądarek i wyszukiwarek (lepsze pod SEO), przez co kolejne przejścia tego samego użytkownika mogą nie być rejestrowane w statystykach.',
|
||||
'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',
|
||||
@@ -170,15 +222,290 @@ return [
|
||||
'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_cache_ttl' => 'Czas życia (TTL) pamięci podręcznej IP',
|
||||
'settings_geoip_cache_ttl_helper' => 'Czas (w sekundach) przechowywania wyniku wykrywania lokalizacji dla danego IP. Zapobiega to wielokrotnemu odpytywaniu API lub baz danych przy kolejnych kliknięciach tej samej osoby. Domyślnie: 86400 (24 godziny). Maksymalnie: 31536000 (1 rok).',
|
||||
'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_maxmind_info_callout' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M8 0C3.58125 0 0 3.58125 0 8C0 12.4187 3.58125 16 8 16C12.4187 16 16 12.4187 16 8C16 3.58125 12.4187 0 8 0ZM8 14.5C4.41563 14.5 1.5 11.5841 1.5 8C1.5 4.41594 4.41563 1.5 8 1.5C11.5844 1.5 14.5 4.41594 14.5 8C14.5 11.5841 11.5844 14.5 8 14.5ZM9.25 10.5H8.75V7.75C8.75 7.3375 8.41563 7 8 7H7C6.5875 7 6.25 7.3375 6.25 7.75C6.25 8.1625 6.5875 8.5 7 8.5H7.25V10.5H6.75C6.3375 10.5 6 10.8375 6 11.25C6 11.6625 6.3375 12 6.75 12H9.25C9.66406 12 10 11.6641 10 11.25C10 10.8359 9.66563 10.5 9.25 10.5ZM8 6C8.55219 6 9 5.55219 9 5C9 4.44781 8.55219 4 8 4C7.44781 4 7 4.44687 7 5C7 5.55313 7.44687 6 8 6Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>Jak skonfigurować MaxMind (Offline Geo-IP):</strong><br>1. Zarejestruj darmowe konto na <a href="https://www.maxmind.com" target="_blank" class="underline text-primary-600 dark:text-primary-400">maxmind.com</a>.<br>2. Pobierz bezpłatną bazę danych <strong>GeoLite2 Country</strong> lub <strong>GeoLite2 City</strong> w formacie binarnym (plik z rozszerzeniem <code class="px-1 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">.mmdb</code>).<br>3. Prześlij plik na swój serwer, np. do katalogu <code class="px-1 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">storage/geoip/GeoLite2-Country.mmdb</code>.<br>4. Wpisz pełną ścieżkę bezwzględną poniżej i kliknij przycisk <strong>Zweryfikuj plik</strong> z prawej strony pola.</span></div></div>',
|
||||
'settings_maxmind_verify' => 'Weryfikuj plik',
|
||||
'settings_maxmind_verify_ok' => '✅ Plik znaleziony i dostępny',
|
||||
'settings_maxmind_verify_fail' => '❌ Plik nie istnieje lub jest niedostępny',
|
||||
'settings_maxmind_verify_empty' => 'Najpierw podaj ścieżkę do pliku.',
|
||||
|
||||
'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.',
|
||||
'settings_ga4_verify' => 'Testuj połączenie',
|
||||
'settings_ga4_verify_ok' => '✅ Klucz API jest prawidłowy — połączenie z GA4 udane',
|
||||
'settings_ga4_verify_fail' => '❌ Nieprawidłowy klucz API — GA4 odrzuciło żądanie',
|
||||
'settings_ga4_verify_empty' => 'Najpierw wprowadź klucz API.',
|
||||
'settings_ga4_verify_error' => '⚠️ Błąd połączenia — nie można nawiązać kontaktu z GA4',
|
||||
|
||||
'settings_section_buffering' => 'Buforowanie liczników wizyt',
|
||||
'settings_buffering_enabled' => 'Buforuj kliknięcia w pamięci podręcznej (Cache)',
|
||||
'settings_buffering_helper' => 'Po włączeniu, zliczenia kliknięć będą buforowane w pamięci podręcznej aplikacji i muszą być okresowo synchronizowane z bazą danych za pomocą komendy "php artisan short-url:sync-counters". Zapobiega to blokowaniu wierszy bazy danych i spadkom wydajności przy masowym ruchu.',
|
||||
'settings_buffering_worker_info' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M8 0C3.58125 0 0 3.58125 0 8C0 12.4187 3.58125 16 8 16C12.4187 16 16 12.4187 16 8C16 3.58125 12.4187 0 8 0ZM8 14.5C4.41563 14.5 1.5 11.5841 1.5 8C1.5 4.41594 4.41563 1.5 8 1.5C11.5844 1.5 14.5 4.41594 14.5 8C14.5 11.5841 11.5844 14.5 8 14.5ZM9.25 10.5H8.75V7.75C8.75 7.3375 8.41563 7 8 7H7C6.5875 7 6.25 7.3375 6.25 7.75C6.25 8.1625 6.5875 8.5 7 8.5H7.25V10.5H6.75C6.3375 10.5 6 10.8375 6 11.25C6 11.6625 6.3375 12 6.75 12H9.25C9.66406 12 10 11.6641 10 11.25C10 10.8359 9.66563 10.5 9.25 10.5ZM8 6C8.55219 6 9 5.55219 9 5C9 4.44781 8.55219 4 8 4C7.44781 4 7 4.44687 7 5C7 5.55313 7.44687 6 8 6Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full [&_kbd]:bg-background-light dark:[&_kbd]:bg-background-dark [_code]:!text-current [_kbd]:!text-current [_a]:!text-current [_a]:border-current [_strong]:!text-current text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>Wymagane zadanie Cron (Scheduler):</strong> Włączyłeś buforowanie kliknięć. Musisz dodać do harmonogramu zadań (cron) komendę synchronizującą liczniki z bazą danych:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">* * * * * cd /sciezka-do-twojego-projektu && php artisan schedule:run >> /dev/null 2>&1</code><br>Komenda uruchamiana automatycznie w tle przez harmonogram:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">php artisan short-url:sync-counters</code></span></div></div>',
|
||||
|
||||
// CDN Trust Settings
|
||||
'settings_trust_cdn_headers' => 'Ufaj nagłówkom CDN i proxy',
|
||||
'settings_trust_cdn_headers_helper' => 'Włącz tę opcję, jeśli Twoja aplikacja działa za CDN (np. Cloudflare, AWS CloudFront) lub reverse proxy. Pozwala to na pobieranie prawdziwego adresu IP klienta i kodu kraju z nagłówków CDN. Uwaga: włączaj tylko wtedy, gdy faktycznie korzystasz z proxy, w przeciwnym razie adresy IP mogą być sfałszowane!',
|
||||
'settings_trust_cdn_headers_info_callout' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-amber-200 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/20" data-callout-type="warning"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-amber-800 dark:text-amber-300" aria-label="Warning"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" /></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full text-amber-900 dark:text-amber-200" data-component-part="callout-content"><span><strong>Uwaga (Bezpieczeństwo adresów IP):</strong> Włączyłeś ufanie nagłówkom proxy. Włączaj tę opcję <u>wyłącznie</u> wtedy, gdy Twoja strona jest podpięta pod:<br>• <strong>Cloudflare</strong> (odczyt z nagłówka CF-Connecting-IP)<br>• <strong>AWS CloudFront</strong> lub inny system CDN<br>• <strong>Nginx/Apache reverse proxy</strong> przekazujący nagłówki X-Forwarded-For.<br><br><strong>Wyłącz tę opcję</strong>, jeśli Twój serwer łączy się z użytkownikami bezpośrednio. Jeśli pozostawisz ją włączoną bez CDN, złośliwi użytkownicy mogą łatwo sfałszować swój adres IP, wysyłając własny nagłówek.</span></div></div>',
|
||||
'settings_geoip_headers_warning' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-amber-200 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/20" data-callout-type="warning"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-amber-800 dark:text-amber-300" aria-label="Warning"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" /></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full text-amber-900 dark:text-amber-200" data-component-part="callout-content"><span><strong>Brak zaufania do nagłówków proxy:</strong> Wybrałeś <em>Nagłówki CDN</em> jako sterownik wykrywania lokalizacji, ale opcja <strong>„Ufaj nagłówkom CDN i proxy”</strong> w zakładce <em>Ogólne</em> jest wyłączona. Wykrywanie kraju nie będzie działać, dopóki jej nie włączysz.</span></div></div>',
|
||||
|
||||
// Stats View & Export Localization
|
||||
'stats_filter_visited_from' => 'Odwiedzono od',
|
||||
'stats_filter_visited_until' => 'Odwiedzono do',
|
||||
'stats_action_export' => 'Eksportuj CSV',
|
||||
'stats_csv_time' => 'Czas',
|
||||
'stats_csv_ip' => 'Adres IP',
|
||||
'stats_csv_country' => 'Kraj',
|
||||
'stats_csv_device' => 'Urządzenie',
|
||||
'stats_csv_browser' => 'Przeglądarka',
|
||||
'stats_csv_os' => 'System operacyjny',
|
||||
'stats_csv_referer' => 'Źródło (Referer)',
|
||||
|
||||
// Bulk Actions Localization
|
||||
'action_enable_selected' => 'Włącz zaznaczone',
|
||||
'action_disable_selected' => 'Wyłącz zaznaczone',
|
||||
|
||||
// Form Sections Localization
|
||||
'form_section_options' => 'Opcje',
|
||||
'form_section_notes' => 'Wewnętrzne notatki',
|
||||
'form_section_tracking' => 'Śledzenie wizyt',
|
||||
'form_section_tracked_fields' => 'Śledzone pola',
|
||||
'form_section_analytics' => 'Zewnętrzne systemy analityczne',
|
||||
|
||||
// New Dashboard Analytics Keys
|
||||
'stats_card_top_source' => 'Główne źródło UTM',
|
||||
'stats_card_top_country' => 'Główny kraj',
|
||||
'stats_filter_date_range' => 'Zakres dat',
|
||||
'stats_preset_24_hours' => 'Ostatnie 24 godziny',
|
||||
'stats_preset_7_days' => 'Ostatnie 7 dni',
|
||||
'stats_preset_30_days' => 'Ostatnie 30 dni',
|
||||
'stats_preset_90_days' => 'Ostatnie 90 dni',
|
||||
'stats_preset_custom' => 'Własny zakres',
|
||||
'stats_breakdown_cities' => 'Najpopularniejsze miasta',
|
||||
'stats_no_city_data' => 'Brak danych o miastach.',
|
||||
'stats_breakdown_utm_source' => 'Źródła kampanii',
|
||||
'stats_breakdown_utm_medium' => 'Media kampanii',
|
||||
'stats_breakdown_utm_campaign' => 'Nazwy kampanii',
|
||||
'stats_no_utm_data' => 'Brak danych o parametrach UTM.',
|
||||
|
||||
// New Targeting & Security Tab and fields
|
||||
'tab_targeting' => 'Targetowanie i bezpieczeństwo',
|
||||
'security_section_title' => 'Kontrola bezpieczeństwa',
|
||||
'password' => 'Hasło dostępu',
|
||||
'password_helper' => 'Wymagaj od odwiedzających wprowadzenia hasła przed przekierowaniem.',
|
||||
'show_warning_page' => 'Pokaż stronę ostrzegającą przed przekierowaniem',
|
||||
'show_warning_page_helper' => 'Pokaż ekran pośredni ostrzegający o przekierowaniu na zewnętrzny adres (ochrona przed phishingiem/bezpieczeństwo NSFW).',
|
||||
'targeting_type' => 'Strategia targetowania',
|
||||
'targeting_type_none' => 'Brak (Bezpośrednie przekierowanie)',
|
||||
'targeting_type_device' => 'Przekierowania zależne od urządzenia',
|
||||
'targeting_type_country' => 'Przekierowania zależne od kraju (Geo-IP)',
|
||||
'targeting_type_rotation' => 'Podział ruchu A/B (Rotacja)',
|
||||
'targeting_type_language' => 'Przekierowania zależne od języka przeglądarki',
|
||||
'device_targeting_rules' => 'Reguły urządzeń',
|
||||
'country_targeting_rules' => 'Reguły krajów',
|
||||
'language_targeting_rules' => 'Reguły języków',
|
||||
'rotation_targeting_rules' => 'Cele rotacji',
|
||||
'device_mobile' => 'Docelowy URL dla urządzeń mobilnych',
|
||||
'device_tablet' => 'Docelowy URL dla tabletów',
|
||||
'device_desktop' => 'Docelowy URL dla komputerów stacjonarnych',
|
||||
'country_code' => 'Kod kraju',
|
||||
'language_code' => 'Kod języka',
|
||||
'rotation_url' => 'Docelowy URL',
|
||||
'rotation_weight' => 'Waga ruchu',
|
||||
'rotation_weight_helper' => 'Procent ruchu kierowany na ten URL (np. 50 dla 50%). Wagi są bilansowane proporcjonalnie.',
|
||||
|
||||
// New Settings Page Fields
|
||||
'settings_tab_advanced' => 'Wydajność i bezpieczeństwo',
|
||||
'settings_section_aggregation' => 'Zarządzanie logami o dużym natężeniu ruchu',
|
||||
'settings_retention_days' => 'Czas retencji surowych logów',
|
||||
'settings_retention_days_helper' => 'Wybierz, przez jaki okres czasu chcesz przechowywać szczegółowe logi pojedynczych wizyt przed ich usunięciem.',
|
||||
'settings_aggregation_enabled' => 'Włącz automatyczną agregację i czyszczenie logów',
|
||||
'settings_aggregation_enabled_helper' => 'Gdy włączone, wtyczka automatycznie rejestruje zadanie w harmonogramie (codziennie o 02:00), aby zsumować wizyty do statystyk dziennych i usunąć surowe logi starsze niż wybrany okres retencji.',
|
||||
'retention_30_days' => '30 dni',
|
||||
'retention_60_days' => '60 dni',
|
||||
'retention_90_days' => '90 dni',
|
||||
'retention_180_days' => '180 dni (pół roku)',
|
||||
'retention_365_days' => '365 dni (rok)',
|
||||
'retention_730_days' => '2 lata',
|
||||
'settings_section_rate_limiting' => 'Ograniczanie częstotliwości żądań / Ochrona przed botami',
|
||||
'settings_rate_limiting_enabled' => 'Włącz ochronę limitów',
|
||||
'settings_rate_limiting_enabled_helper' => 'Ograniczaj liczbę przekierowań na adres IP klienta.',
|
||||
'settings_rate_limiting_max_attempts' => 'Maksymalna dopuszczalna liczba przekierowań',
|
||||
'settings_rate_limiting_max_attempts_helper' => 'Maksymalna liczba żądań w oknie wygasania.',
|
||||
'settings_rate_limiting_decay_seconds' => 'Okno wygasania (sekundy)',
|
||||
'settings_rate_limiting_decay_seconds_helper' => 'Przedział czasowy dla ograniczenia liczby żądań.',
|
||||
|
||||
// Queue Settings Additions
|
||||
'settings_queue_name' => 'Nazwa kolejki',
|
||||
'settings_queue_name_helper' => 'Docelowa nazwa kolejki, do której wysyłane są zadania śledzenia i synchronizacji liczników. Domyślnie: "default".',
|
||||
'settings_queue_worker_info' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M8 0C3.58125 0 0 3.58125 0 8C0 12.4187 3.58125 16 8 16C12.4187 16 16 12.4187 16 8C16 3.58125 12.4187 0 8 0ZM8 14.5C4.41563 14.5 1.5 11.5841 1.5 8C1.5 4.41594 4.41563 1.5 8 1.5C11.5844 1.5 14.5 4.41594 14.5 8C14.5 11.5841 11.5844 14.5 8 14.5ZM9.25 10.5H8.75V7.75C8.75 7.3375 8.41563 7 8 7H7C6.5875 7 6.25 7.3375 6.25 7.75C6.25 8.1625 6.5875 8.5 7 8.5H7.25V10.5H6.75C6.3375 10.5 6 10.8375 6 11.25C6 11.6625 6.3375 12 6.75 12H9.25C9.66406 12 10 11.6641 10 11.25C10 10.8359 9.66563 10.5 9.25 10.5ZM8 6C8.55219 6 9 5.55219 9 5C9 4.44781 8.55219 4 8 4C7.44781 4 7 4.44687 7 5C7 5.55313 7.44687 6 8 6Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full [&_kbd]:bg-background-light dark:[&_kbd]:bg-background-dark [_code]:!text-current [_kbd]:!text-current [_a]:!text-current [_a]:border-current [_strong]:!text-current text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>Wymagany proces w tle (Queue Worker):</strong> Wybrane połączenie działa asynchronicznie. Musisz uruchomić proces w tle (workera), aby przetwarzać te zadania:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">php artisan queue:work --queue=:queue</code></span></div></div>',
|
||||
'settings_geoip_stats_cache_ttl' => 'Czas życia (TTL) pamięci podręcznej statystyk',
|
||||
'settings_geoip_stats_cache_ttl_helper' => 'Czas (w sekundach) buforowania obliczeń statystyk linków na pulpicie. Kiedy przeglądasz statystyki, system pobiera dane z cache zamiast każdorazowo przeliczać miliony rekordów w bazie danych. Domyślnie: 300 (5 minut). Maksymalnie: 86400 (24 godziny).',
|
||||
|
||||
// Default Tracking Tab
|
||||
'settings_tab_tracking_defaults' => 'Domyślne śledzenie',
|
||||
'settings_section_tracking_defaults' => 'Domyślne ustawienia śledzenia wizyt',
|
||||
'settings_section_tracking_defaults_helper' => 'Ustawienia te określają domyślny status śledzenia dla nowo tworzonych krótkich adresów URL.',
|
||||
'settings_track_visits_default' => 'Śledź wizyty domyślnie',
|
||||
'settings_track_ip_default' => 'Domyślne śledzenie adresu IP',
|
||||
'settings_track_browser_default' => 'Domyślne śledzenie przeglądarki',
|
||||
'settings_track_browser_version_default' => 'Domyślne śledzenie wersji przeglądarki',
|
||||
'settings_track_os_default' => 'Domyślne śledzenie systemu operacyjnego',
|
||||
'settings_track_os_version_default' => 'Domyślne śledzenie wersji systemu operacyjnego',
|
||||
'settings_track_referer_default' => 'Domyślne śledzenie adresu referera',
|
||||
'settings_track_device_type_default' => 'Domyślne śledzenie typu urządzenia',
|
||||
'settings_track_browser_language_default' => 'Domyślne śledzenie języka przeglądarki',
|
||||
|
||||
// QR Defaults Tab
|
||||
'settings_tab_qr' => 'Domyślne QR',
|
||||
'settings_section_qr_defaults' => 'Domyślne opcje wyglądu kodów QR',
|
||||
'settings_section_qr_defaults_helper' => 'Skonfiguruj standardowy styl wyglądu automatycznie ładowany dla nowo generowanych kodów QR.',
|
||||
'settings_qr_size' => 'Domyślny rozmiar (px)',
|
||||
'settings_qr_margin' => 'Domyślny margines',
|
||||
'settings_qr_dot_style' => 'Domyślny styl kropek',
|
||||
'settings_qr_foreground_color' => 'Domyślny kolor pierwszego planu',
|
||||
'settings_qr_background_color' => 'Domyślny kolor tła',
|
||||
'settings_qr_gradient_enabled' => 'Domyślnie włącz gradient',
|
||||
'settings_qr_gradient_from' => 'Kolor gradientu od',
|
||||
'settings_qr_gradient_to' => 'Kolor gradientu do',
|
||||
'settings_qr_gradient_type' => 'Domyślny typ gradientu',
|
||||
|
||||
// Views
|
||||
'password_title' => 'Wymagane hasło',
|
||||
'password_description' => 'Ten link jest chroniony hasłem. Wprowadź poprawne hasło, aby kontynuować.',
|
||||
'password_placeholder' => 'Wpisz hasło',
|
||||
'password_btn_unlock' => 'Odblokuj i przekieruj',
|
||||
'password_error' => 'Nieprawidłowe hasło.',
|
||||
'warning_title' => 'Ostrzeżenie o przekierowaniu',
|
||||
'warning_description' => 'Opuszczasz ten bezpieczny portal i zostajesz przekierowany na zewnętrzny link docelowy. Upewnij się, że ufasz poniższemu adresowi:',
|
||||
'warning_btn_continue' => 'Kontynuuj do celu',
|
||||
'warning_btn_back' => 'Wróć',
|
||||
'pixel_loading_title' => 'Łączenie...',
|
||||
'pixel_loading_description' => 'Przygotowujemy połączenie i za chwilę zostaniesz przekierowany.',
|
||||
|
||||
// Marketing & API Tab
|
||||
'tab_marketing' => 'Marketing i API',
|
||||
'marketing_pixels_title' => 'Piksele retargetingowe (Client-Side)',
|
||||
'marketing_pixels_desc' => 'Dodaj skrypty śledzące, aby dołączać ciasteczka odwiedzających i budować grupy remarketingowe reklam.',
|
||||
'pixel_meta' => 'Meta Pixel ID',
|
||||
'pixel_google' => 'Google Tag / GA4 ID',
|
||||
'pixel_linkedin' => 'LinkedIn Partner ID',
|
||||
'marketing_webhooks_title' => 'Integracja Webhook linku',
|
||||
'marketing_webhooks_desc' => 'Skonfiguruj niestandardowy adres URL, pod który wysłane zostanie natychmiastowe powiadomienie HTTP POST po kliknięciu.',
|
||||
'webhook_url' => 'Dedykowany Webhook URL',
|
||||
|
||||
// Settings tab additions
|
||||
'settings_tab_developer' => 'API i Webhooki',
|
||||
'settings_section_rest_api' => 'Dostęp do REST API',
|
||||
'settings_api_enabled' => 'Włącz Developer REST API',
|
||||
'settings_api_enabled_helper' => 'Zezwól zewnętrznym systemom na tworzenie, listowanie i usuwanie krótkich linków przez REST API. Wyłącz, aby zablokować wszystkie endpointy /api/short-url/* odpowiedzią 503.',
|
||||
'settings_section_global_webhook' => 'Konfiguracja globalnego Webhooka',
|
||||
'settings_global_webhook_enabled' => 'Włącz globalny webhook',
|
||||
'settings_global_webhook_enabled_helper' => 'Gdy włączone, wtyczka będzie wysyłać powiadomienia HTTP POST w tle przy zdarzeniach wszystkich linków.',
|
||||
'settings_global_webhook_url' => 'Globalny Webhook URL',
|
||||
'settings_global_webhook_url_helper' => 'Adres URL, pod który wysyłane będą w tle powiadomienia o zdarzeniach ze wszystkich linków.',
|
||||
'settings_webhook_events' => 'Monitorowane zdarzenia Webhooka',
|
||||
'settings_webhook_events_helper' => 'Wybierz zdarzenia, które powinny wyzwalać wysłanie powiadomienia POST.',
|
||||
'webhook_event_visited' => 'Odwiedziny linku (Wizyta)',
|
||||
'webhook_event_created' => 'Utworzenie linku',
|
||||
'webhook_event_expired' => 'Wygaśnięcie linku (Data)',
|
||||
'webhook_event_limit_reached' => 'Osiągnięcie limitu kliknięć',
|
||||
'settings_section_api_keys' => 'Klucze API Dewelopera',
|
||||
'settings_api_keys_description' => 'Zarządzaj kluczami API do integracji z zewnętrznymi systemami (np. CRM, Zapier, Make). Autoryzacja odbywa się za pomocą nagłówka X-Api-Key.',
|
||||
'settings_api_keys' => 'Aktywne klucze API',
|
||||
'api_key_name' => 'Nazwa klucza (Opis)',
|
||||
'api_key' => 'Klucz API',
|
||||
'active' => 'Aktywny',
|
||||
|
||||
// Ochrona v2.0
|
||||
'settings_section_security_v2' => 'Bezpieczeństwo i ochrona przed nadużyciami v2.0',
|
||||
'settings_section_security_v2_desc' => 'Skonfiguruj wykrywanie sieci VPN/Proxy oraz walidację adresów przez Google Safe Browsing.',
|
||||
'settings_vpn_detection_enabled' => 'Włącz wykrywanie VPN i Proxy',
|
||||
'settings_vpn_detection_enabled_helper' => 'Po włączeniu każda wizyta będzie sprawdzana pod kątem korzystania z połączeń VPN, proxy lub sieci Tor.',
|
||||
'settings_vpn_driver' => 'Sterownik wykrywania',
|
||||
'settings_vpn_driver_helper' => 'Wybierz darmową usługę IP-API lub platformę VPNAPI.io.',
|
||||
'settings_vpn_driver_ipapi' => 'IP-API (Darmowy)',
|
||||
'settings_vpn_driver_vpnapi' => 'VPNAPI.io (Premium / Darmowy)',
|
||||
'settings_vpnapi_key' => 'Klucz VPNAPI.io',
|
||||
'settings_vpnapi_key_helper' => 'Twój klucz API pobrany z vpnapi.io (wymagany dla sterownika vpnapi).',
|
||||
'settings_vpn_block_action' => 'Akcja blokowania VPN',
|
||||
'settings_vpn_block_action_helper' => 'Wybierz, czy ruch z sieci VPN ma być jedynie flagowany w statystykach, czy aktywnie blokowany stroną 403 Forbidden.',
|
||||
'settings_vpn_block_flag_only' => 'Tylko flaguj w statystykach (zezwól na przekierowanie)',
|
||||
'settings_vpn_block_block_403' => 'Blokuj ruch (wyświetl błąd 403 Forbidden)',
|
||||
'settings_safe_browsing_enabled' => 'Włącz weryfikację przez Google Safe Browsing',
|
||||
'settings_safe_browsing_enabled_helper' => 'Automatycznie sprawdzaj docelowe adresy URL pod kątem phishingu, złośliwego oprogramowania i oszustw.',
|
||||
'settings_safe_browsing_api_key' => 'Klucz API Google Safe Browsing',
|
||||
'settings_safe_browsing_api_key_helper' => 'Klucz API Web Developer pobrany z Google Cloud Console.',
|
||||
'settings_safe_browsing_test' => 'Testuj połączenie API',
|
||||
'settings_safe_browsing_test_empty' => 'Najpierw wprowadź klucz API.',
|
||||
'settings_safe_browsing_test_ok' => 'Połączenie z Google Safe Browsing powiodło się (API jest aktywne).',
|
||||
'settings_safe_browsing_test_fail' => 'Weryfikacja klucza Google Safe Browsing nie powiodła się.',
|
||||
'settings_safe_browsing_test_error' => 'Błąd połączenia z API.',
|
||||
|
||||
// World Map Widget
|
||||
'world_map_title' => 'Mapa odwiedzin',
|
||||
'world_map_total_clicks' => 'wszystkich kliknięć',
|
||||
'world_map_countries' => 'krajów',
|
||||
'world_map_fewer' => 'Mniej',
|
||||
'world_map_more' => 'Więcej',
|
||||
'world_map_top_countries' => 'Najpopularniejsze kraje',
|
||||
'world_map_no_data' => 'Brak danych geograficznych.',
|
||||
'world_map_no_data_sub' => 'Włącz wykrywanie Geo-IP, aby zacząć rejestrować lokalizacje odwiedzających.',
|
||||
'stats_card_qr_scans' => 'Zeskanowania QR',
|
||||
'stats_breakdown_languages' => 'Najpopularniejsze języki',
|
||||
'stats_no_language_data' => 'Brak danych o językach.',
|
||||
'badge_clicks' => 'kliknięć',
|
||||
'badge_unique' => 'unikalnych',
|
||||
'badge_qr' => 'QR',
|
||||
'badge_qr_scans' => 'odczytów QR',
|
||||
'badge_expires' => 'Wygasa: :date',
|
||||
'badge_no_expiry' => 'Bez wygasania',
|
||||
'badge_redirect' => 'Przekierowanie :code',
|
||||
'pixels_navigation_label' => 'Piksele retargetingowe',
|
||||
'pixel_resource_title' => 'Piksel retargetingowy',
|
||||
'pixel_name' => 'Nazwa piksela',
|
||||
'pixel_type' => 'Dostawca',
|
||||
'pixel_id_label' => 'ID Piksela / Tagu',
|
||||
'pixel_status_active' => 'Aktywny',
|
||||
'settings_tab_deep_linking' => 'Głębokie linkowanie',
|
||||
'settings_section_deep_linking' => 'Pliki powiązań Universal Links & App Links',
|
||||
'settings_deep_linking_enabled' => 'Włącz serwowanie plików powiązań',
|
||||
'settings_deep_linking_enabled_helper' => 'Po włączeniu wtyczka będzie serwować pliki konfiguracyjne AASA (iOS) oraz AssetLinks (Android) z poziomu domeny głównej.',
|
||||
'settings_aasa_json' => 'apple-app-site-association (iOS)',
|
||||
'settings_aasa_json_helper' => 'Wprowadź prawidłowy kod JSON dla Apple App Site Association. Nie dołączaj znaczników HTML/JavaScript.',
|
||||
'settings_assetlinks_json' => 'assetlinks.json (Android)',
|
||||
'settings_assetlinks_json_helper' => 'Wprowadź prawidłową tablicę JSON dla Android Digital Asset Links.',
|
||||
'validation_invalid_json' => 'Wprowadzony tekst nie jest poprawnym ciągiem JSON.',
|
||||
|
||||
// App Linking Preview
|
||||
'app_linking_supported_os' => 'Wspierane systemy operacyjne:',
|
||||
'app_linking_supported_apps' => 'Wspierane aplikacje',
|
||||
'app_linking_supported_apps_helper' => 'Jeśli nie znajdziesz swojej aplikacji na liście, możesz użyć funkcji Targetowania, aby przekierować użytkownika za pomocą własnego głębokiego linku (Deep Link).',
|
||||
'app_linking_matched' => 'Dopasowano',
|
||||
'app_linking_preconfigured_count' => ':count gotowych aplikacji do automatycznego przekierowania.',
|
||||
'app_linking_redirect_active' => 'Otwiera się bezpośrednio w :app',
|
||||
'app_linking_auto_open' => 'Autootwieranie',
|
||||
'app_linking_matched_description' => 'Po kliknięciu na telefonie lub tablecie, ten link otworzy się od razu w dedykowanej aplikacji <strong>:app</strong> zamiast w zwykłej przeglądarce.',
|
||||
'app_linking_deep_link_scheme' => 'Używany schemat (Deep Link): :scheme',
|
||||
'app_linking_standard_redirect' => 'Ten link otworzy się w zwykłej przeglądarce internetowej (brak dopasowanej aplikacji mobilnej).',
|
||||
|
||||
// App Redirect Page
|
||||
'app_redirect_title' => 'Otwieranie aplikacji...',
|
||||
'app_redirect_opening_in' => 'Otwieranie w: :app',
|
||||
'app_redirect_waiting_text' => 'Przekierowujemy Cię do natywnej aplikacji. Proszę czekać...',
|
||||
'app_redirect_btn_open' => 'Otwórz aplikację',
|
||||
'app_redirect_btn_browser' => 'Otwórz w przeglądarce',
|
||||
'app_redirect_in_app_warning_title' => 'Wykryto przeglądarkę wewnątrz aplikacji',
|
||||
'app_redirect_in_app_warning_desc' => 'Przekierowania mogą być blokowane wewnątrz Instagrama lub Facebooka. Kliknij ikonę trzech kropek (•••) w prawym górnym rogu i wybierz „Otwórz w Safari” lub „Otwórz w Chrome”, aby uruchomić aplikację.',
|
||||
|
||||
// Link Expired Page
|
||||
'expired_title' => 'Link nieaktywny lub wygasł',
|
||||
'expired_description' => 'Ten link jest już nieaktywny. Mógł osiągnąć limit kliknięć, minął jego termin ważności lub został wyłączony.',
|
||||
'expired_btn_home' => 'Przejdź do strony głównej',
|
||||
];
|
||||
|
||||
44
resources/lang/pl/languages.php
Normal file
44
resources/lang/pl/languages.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'pl' => 'Polski',
|
||||
'en' => 'Angielski',
|
||||
'de' => 'Niemiecki',
|
||||
'fr' => 'Francuski',
|
||||
'es' => 'Hiszpański',
|
||||
'it' => 'Włoski',
|
||||
'pt' => 'Portugalski',
|
||||
'ru' => 'Rosyjski',
|
||||
'zh' => 'Chiński',
|
||||
'ja' => 'Japoński',
|
||||
'ar' => 'Arabski',
|
||||
'nl' => 'Holenderski',
|
||||
'cs' => 'Czeski',
|
||||
'sk' => 'Słowacki',
|
||||
'hu' => 'Węgierski',
|
||||
'ro' => 'Rumuński',
|
||||
'bg' => 'Bułgarski',
|
||||
'hr' => 'Chorwacki',
|
||||
'sr' => 'Serbski',
|
||||
'sl' => 'Słoweński',
|
||||
'uk' => 'Ukraiński',
|
||||
'tr' => 'Turecki',
|
||||
'da' => 'Duński',
|
||||
'sv' => 'Szwedzki',
|
||||
'no' => 'Norweski',
|
||||
'fi' => 'Fiński',
|
||||
'el' => 'Grecki',
|
||||
'he' => 'Hebrajski',
|
||||
'hi' => 'Hindi',
|
||||
'th' => 'Tajski',
|
||||
'vi' => 'Wietnamski',
|
||||
'ko' => 'Koreański',
|
||||
'id' => 'Indonezyjski',
|
||||
'ms' => 'Malajski',
|
||||
'et' => 'Estoński',
|
||||
'lv' => 'Łotewski',
|
||||
'lt' => 'Litewski',
|
||||
'is' => 'Islandzki',
|
||||
'ga' => 'Irlandzki',
|
||||
'fa' => 'Perski',
|
||||
];
|
||||
105
resources/views/app-linking-preview.blade.php
Normal file
105
resources/views/app-linking-preview.blade.php
Normal file
@@ -0,0 +1,105 @@
|
||||
@php
|
||||
$matchedAppId = \Bjanczak\FilamentShortUrl\Services\AppLinkingEngine::matchApp($destinationUrl);
|
||||
$apps = \Bjanczak\FilamentShortUrl\Services\AppLinkingEngine::getSupportedApps();
|
||||
@endphp
|
||||
|
||||
<div class="mt-4 space-y-4">
|
||||
<!-- macOS style main block -->
|
||||
<div class="p-4 rounded-xl border border-gray-200 dark:border-gray-800 bg-gray-50/50 dark:bg-gray-900/30 shadow-none">
|
||||
|
||||
<!-- Header Info -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-2 pb-3 mb-4 border-b border-gray-200 dark:border-gray-800">
|
||||
<div>
|
||||
<h4 class="text-xs font-bold text-gray-850 dark:text-white uppercase tracking-wider">
|
||||
{{ __('filament-short-url::default.app_linking_supported_apps') }}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 text-[10px]">
|
||||
<span class="text-gray-500 dark:text-gray-450 font-medium">
|
||||
{{ __('filament-short-url::default.app_linking_supported_os') }}
|
||||
</span>
|
||||
<span class="px-2 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-gray-650 dark:text-gray-300 font-semibold">iOS</span>
|
||||
<span class="px-2 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-gray-650 dark:text-gray-300 font-semibold">Android</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Matched App Banner -->
|
||||
@if ($matchedAppId && isset($apps[$matchedAppId]))
|
||||
@php
|
||||
$matchedApp = $apps[$matchedAppId];
|
||||
$matchedDomain = explode('/', $matchedApp['domains'][0])[0];
|
||||
$matchedFavicon = "https://icons.duckduckgo.com/ip2/{$matchedDomain}.ico";
|
||||
$deepLink = \Bjanczak\FilamentShortUrl\Services\AppLinkingEngine::convertToScheme($destinationUrl, $matchedAppId);
|
||||
@endphp
|
||||
<div class="mb-4 p-4 rounded-xl bg-emerald-500/5 dark:bg-emerald-500/5 border border-emerald-500 dark:border-emerald-600 text-xs shadow-none">
|
||||
<div class="flex items-start gap-4">
|
||||
<!-- Squircle Favicon Box -->
|
||||
<div class="w-10 h-10 rounded-lg bg-white dark:bg-gray-800 flex items-center justify-center p-2 flex-shrink-0">
|
||||
<img src="{{ $matchedFavicon }}" alt="{{ $matchedApp['name'] }}" class="w-6 h-6 object-contain" onerror="this.src='https://icons.duckduckgo.com/ip2/google.com.ico'">
|
||||
</div>
|
||||
|
||||
<div class="flex-grow min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h5 class="text-xs font-bold text-emerald-850 dark:text-emerald-450">
|
||||
{{ __('filament-short-url::default.app_linking_redirect_active', ['app' => $matchedApp['name']]) }}
|
||||
</h5>
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded-full text-[9px] font-bold bg-emerald-500 text-white uppercase tracking-wider">
|
||||
{{ __('filament-short-url::default.app_linking_auto_open') }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-[11px] text-emerald-700 dark:text-emerald-450 mt-1 leading-relaxed">
|
||||
{!! __('filament-short-url::default.app_linking_matched_description', ['app' => e($matchedApp['name'])]) !!}
|
||||
</p>
|
||||
<div class="mt-2 text-[10px] font-mono text-emerald-600 dark:text-emerald-400 break-all select-all">
|
||||
{{ __('filament-short-url::default.app_linking_deep_link_scheme', ['scheme' => $deepLink]) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<!-- Standard Web Redirect banner -->
|
||||
<div class="mb-4 p-3.5 rounded-xl bg-gray-100/50 dark:bg-gray-800/10 border border-gray-200 dark:border-gray-800 text-xs flex items-center gap-3 shadow-none">
|
||||
<div class="w-7 h-7 rounded-[6px] bg-white dark:bg-gray-800 flex items-center justify-center p-1.5 flex-shrink-0">
|
||||
<svg class="w-4 h-4 text-gray-450" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-gray-500 dark:text-gray-450 leading-relaxed text-[11px]">
|
||||
{{ __('filament-short-url::default.app_linking_standard_redirect') }}
|
||||
</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Flat iOS-like Grid without Borders -->
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2 mb-4">
|
||||
@foreach ($apps as $appId => $app)
|
||||
@php
|
||||
$isMatched = ($appId === $matchedAppId);
|
||||
$appDomain = explode('/', $app['domains'][0])[0];
|
||||
$appFavicon = "https://icons.duckduckgo.com/ip2/{$appDomain}.ico";
|
||||
@endphp
|
||||
<div class="flex items-center gap-2.5 p-1.5 rounded-lg transition {{ $isMatched ? 'bg-emerald-500/10 text-emerald-800 dark:text-emerald-400 font-bold' : 'hover:bg-gray-100/60 dark:hover:bg-gray-800/50 text-gray-700 dark:text-gray-300' }}">
|
||||
<!-- Favicon Squircle -->
|
||||
<div class="w-7 h-7 rounded-[6px] bg-white dark:bg-gray-800 flex items-center justify-center p-1.5 flex-shrink-0">
|
||||
<img src="{{ $appFavicon }}" alt="{{ $app['name'] }}" class="w-4 h-4 object-contain" onerror="this.src='https://icons.duckduckgo.com/ip2/google.com.ico'">
|
||||
</div>
|
||||
<span class="text-xs font-semibold truncate">
|
||||
{{ $app['name'] }}
|
||||
</span>
|
||||
@if ($isMatched)
|
||||
<span class="ml-auto flex h-3.5 w-3.5 items-center justify-center rounded-full bg-emerald-500 text-white text-[8px] font-bold shrink-0">
|
||||
✓
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<!-- Help footer -->
|
||||
<p class="text-[11px] text-gray-450 dark:text-gray-500 border-t border-gray-200 dark:border-gray-800 pt-3 leading-relaxed">
|
||||
{{ __('filament-short-url::default.app_linking_supported_apps_helper') }}
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
274
resources/views/app-redirect.blade.php
Normal file
274
resources/views/app-redirect.blade.php
Normal file
@@ -0,0 +1,274 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="h-full">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ __('filament-short-url::default.app_redirect_title') ?? 'Opening App...' }}</title>
|
||||
|
||||
<!-- Premium Google Fonts: Bricolage Grotesque -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,200..800&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Self-contained Tailwind CSS CDN for maximum plug-and-play reliability -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Bricolage Grotesque', 'sans-serif'],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detect system dark mode preferences
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
</script>
|
||||
|
||||
@php
|
||||
$pixelMetaIds = $pixels->where('type', 'meta')->pluck('pixel_id');
|
||||
$pixelGoogleIds = $pixels->where('type', 'google')->pluck('pixel_id');
|
||||
$pixelLinkedinIds = $pixels->where('type', 'linkedin')->pluck('pixel_id');
|
||||
$pixelTiktokIds = $pixels->where('type', 'tiktok')->pluck('pixel_id');
|
||||
$pixelPinterestIds = $pixels->where('type', 'pinterest')->pluck('pixel_id');
|
||||
@endphp
|
||||
|
||||
<!-- Meta / Facebook Pixel -->
|
||||
@if($pixelMetaIds->isNotEmpty())
|
||||
<script>
|
||||
!function(f,b,e,v,n,t,s)
|
||||
{if(f.fbq)return;n=f.fbq=function(){n.callMethod?
|
||||
n.callMethod.apply(n,arguments):n.queue.push(arguments)};
|
||||
if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';
|
||||
n.queue=[];t=b.createElement(e);t.async=!0;
|
||||
t.src=v;s=b.getElementsByTagName(e)[0];
|
||||
s.parentNode.insertBefore(t,s)}(window, document,'script',
|
||||
'https://connect.facebook.net/en_US/fbevents.js');
|
||||
@foreach($pixelMetaIds as $id)
|
||||
fbq('init', '{{ $id }}');
|
||||
fbq('track', 'PageView');
|
||||
@endforeach
|
||||
</script>
|
||||
@foreach($pixelMetaIds as $id)
|
||||
<noscript><img height="1" width="1" style="display:none" src="https://www.facebook.com/tr?id={{ $id }}&ev=PageView&noscript=1" /></noscript>
|
||||
@endforeach
|
||||
@endif
|
||||
|
||||
<!-- Google Analytics / GTM -->
|
||||
@if($pixelGoogleIds->isNotEmpty())
|
||||
@php $firstGoogleId = $pixelGoogleIds->first(); @endphp
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id={{ $firstGoogleId }}"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
@foreach($pixelGoogleIds as $id)
|
||||
gtag('config', '{{ $id }}');
|
||||
@endforeach
|
||||
</script>
|
||||
@endif
|
||||
|
||||
<!-- LinkedIn Insight -->
|
||||
@if($pixelLinkedinIds->isNotEmpty())
|
||||
<script type="text/javascript">
|
||||
window._linkedin_data_partner_ids = window._linkedin_data_partner_ids || [];
|
||||
@foreach($pixelLinkedinIds as $id)
|
||||
window._linkedin_data_partner_ids.push("{{ $id }}");
|
||||
@endforeach
|
||||
(function(l) {
|
||||
if (!l){window.lintrk = function(a,b){window.lintrk.q.push([a,b])};
|
||||
window.lintrk.q=[]}
|
||||
var s = document.getElementsByTagName("script")[0];
|
||||
var b = document.createElement("script");
|
||||
b.type = "text/javascript";b.async = true;
|
||||
b.src = "https://snap.licdn.com/li.lms-analytics/insight.min.js";
|
||||
s.parentNode.insertBefore(b, s);})(window.lintrk);
|
||||
</script>
|
||||
@foreach($pixelLinkedinIds as $id)
|
||||
<noscript><img height="1" width="1" style="display:none;" alt="" src="https://px.ads.linkedin.com/collect/?pid={{ $id }}&fmt=gif" /></noscript>
|
||||
@endforeach
|
||||
@endif
|
||||
|
||||
<!-- TikTok Pixel -->
|
||||
@if($pixelTiktokIds->isNotEmpty())
|
||||
<script>
|
||||
!function (w, d, t) {
|
||||
w.TiktokAnalyticsObject=t;var ttq=w[t]=w[t]||[];ttq.methods=["page","track","identify","instances","debug","on","off","once","ready","alias","group","enableCookie","disableCookie","holdConsent","revokeConsent","grantConsent"],ttq.setAndDefer=function(t,e){t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}};for(var i=0;i<ttq.methods.length;i++)ttq.setAndDefer(ttq,ttq.methods[i]);ttq.instance=function(t){for(var e=ttq._i[t]||[],n=0;n<ttq.methods.length;n++)ttq.setAndDefer(e,ttq.methods[n]);return e},ttq.load=function(e,n){var r="https://analytics.tiktok.com/i18n/pixel/events.js",o=n&&n.mixpanel;ttq._i=ttq._i||{},ttq._i[e]=[],ttq._i[e]._u=r,ttq._t=ttq._t||{},ttq._t[e]=+new Date,ttq._o=ttq._o||{},ttq._o[e]=o||{};var a=d.createElement("script");a.type="text/javascript",a.async=!0,a.src=r+"?sdkid="+e+"&lib="+t;var c=d.getElementsByTagName("script")[0];c.parentNode.insertBefore(a,c)};
|
||||
@foreach($pixelTiktokIds as $id)
|
||||
ttq.load('{{ $id }}');
|
||||
ttq.page();
|
||||
@endforeach
|
||||
}(window, document, 'ttq');
|
||||
</script>
|
||||
@endif
|
||||
|
||||
<!-- Pinterest Tag -->
|
||||
@if($pixelPinterestIds->isNotEmpty())
|
||||
<script>
|
||||
!function(e,n,t,r,a,s,o){e[r]||(e[r]=function(){(e[r].q=e[r].q||[]).push(arguments)},e[r].q=e[r].q||[],s=n.createElement(t),s.async=!0,s.src="https://s.pntrac.com/tag.js",o=n.getElementsByTagName(t)[0],o.parentNode.insertBefore(s,o))}(window,document,"script","pintrk");
|
||||
@foreach($pixelPinterestIds as $id)
|
||||
pintrk('load', '{{ $id }}');
|
||||
pintrk('page');
|
||||
@endforeach
|
||||
</script>
|
||||
@foreach($pixelPinterestIds as $id)
|
||||
<noscript><img height="1" width="1" style="display:none;" alt="" src="https://ct.pinterest.com/v3/?event=init&tid={{ $id }}&noscript=1" /></noscript>
|
||||
@endforeach
|
||||
@endif
|
||||
</head>
|
||||
<body class="bg-[#FCFCFC] dark:bg-[#0C0C0C] min-h-screen flex flex-col justify-between items-center py-10 px-6 font-sans antialiased">
|
||||
@php
|
||||
$siteName = config('filament-short-url.site_name') ?: config('app.name', 'Laravel');
|
||||
|
||||
// Retrieve app metadata for premium visual hints
|
||||
$apps = \Bjanczak\FilamentShortUrl\Services\AppLinkingEngine::getSupportedApps();
|
||||
$matchedApp = $apps[$appId] ?? null;
|
||||
$appColor = $matchedApp['color'] ?? '#3b82f6';
|
||||
$appName = $matchedApp['name'] ?? 'App';
|
||||
@endphp
|
||||
|
||||
{{-- Main Container --}}
|
||||
<div class="w-full max-w-[360px] flex flex-col items-center gap-6 my-auto">
|
||||
<div class="flex flex-col items-center text-center pb-2 select-none w-full">
|
||||
<span class="text-2xl font-extrabold tracking-tight text-neutral-900 dark:text-white mb-6 select-none">{{ $siteName }}</span>
|
||||
|
||||
<!-- Premium Dynamic Loader with App Theme Highlight -->
|
||||
<div class="relative flex items-center justify-center w-20 h-20 mb-6">
|
||||
<!-- Outer Pulse ring -->
|
||||
<div class="absolute inset-0 rounded-full opacity-20 animate-ping" style="background-color: {{ $appColor }};"></div>
|
||||
<!-- Inner Spinning Border -->
|
||||
<div class="absolute inset-0 rounded-full border-4 border-neutral-100 dark:border-neutral-900"></div>
|
||||
<div class="absolute inset-0 rounded-full border-4 border-transparent border-t-current animate-spin" style="color: {{ $appColor }};"></div>
|
||||
<!-- Center App Accent -->
|
||||
<div class="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold shadow-md" style="background-color: {{ $appColor }};">
|
||||
{{ substr($appName, 0, 1) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xl font-bold text-neutral-900 dark:text-white mt-2">
|
||||
{{ __('filament-short-url::default.app_redirect_opening_in', ['app' => $appName]) ?? "Opening in {$appName}" }}
|
||||
</p>
|
||||
<p class="text-sm text-neutral-400 dark:text-neutral-500 mt-2 max-w-[280px]">
|
||||
{{ __('filament-short-url::default.app_redirect_waiting_text') ?? 'We are redirecting you to the native application. Please wait a moment...' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="w-full flex flex-col gap-4">
|
||||
{{-- Action Buttons --}}
|
||||
<div class="flex flex-col gap-3 w-full mt-2">
|
||||
<!-- Manual trigger if redirect didn't catch -->
|
||||
<a href="{{ $deepLink }}"
|
||||
class="w-full py-3.5 rounded-xl text-white font-semibold text-sm transition duration-200 shadow-md flex justify-center items-center gap-2 text-center"
|
||||
style="background-color: {{ $appColor }}; filter: brightness(0.95);"
|
||||
onmouseover="this.style.filter='brightness(1.05)';"
|
||||
onmouseout="this.style.filter='brightness(0.95)';"
|
||||
id="deep-link-btn">
|
||||
<span>{{ __('filament-short-url::default.app_redirect_btn_open') ?? 'Open Native App' }}</span>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<!-- Fallback Button -->
|
||||
<a href="{{ $destination }}"
|
||||
class="w-full py-3 rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 hover:bg-neutral-50 dark:hover:bg-neutral-800 text-neutral-500 dark:text-neutral-400 font-semibold text-sm transition duration-200 flex justify-center items-center gap-2 shadow-sm"
|
||||
id="fallback-btn">
|
||||
<span>{{ __('filament-short-url::default.app_redirect_btn_browser') ?? 'Open in Browser' }}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{{-- In-App Browser Alert Banner (Hidden by default, shown via JS) --}}
|
||||
<div id="in-app-alert" class="hidden mt-4 p-3.5 rounded-xl border border-amber-200 bg-amber-50 dark:border-amber-950/20 dark:bg-amber-950/10 text-amber-800 dark:text-amber-400 text-xs text-center flex flex-col gap-1 select-none">
|
||||
<p class="font-bold flex items-center justify-center gap-1.5 text-amber-900 dark:text-amber-300">
|
||||
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span>{{ __('filament-short-url::default.app_redirect_in_app_warning_title') ?? 'In-App Browser Detected' }}</span>
|
||||
</p>
|
||||
<p class="text-[11px] leading-relaxed opacity-95">
|
||||
{{ __('filament-short-url::default.app_redirect_in_app_warning_desc') ?? 'Redirections can be restricted inside Instagram or Facebook. Tap the three dots (•••) in the top-right corner and select "Open in Safari" or "Open in Chrome" to launch the app.' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Footer section --}}
|
||||
<div class="flex flex-col items-center gap-2 mt-auto select-none">
|
||||
<span class="text-xs font-medium text-neutral-400 dark:text-neutral-600">© {{ date('Y') }} {{ $siteName }}. All rights reserved.</span>
|
||||
</div>
|
||||
|
||||
<!-- Automatic JS Deep Linking Engine -->
|
||||
<script>
|
||||
(function() {
|
||||
var deepLink = @json($deepLink);
|
||||
var fallbackUrl = @json($destination);
|
||||
var hasPixels = @json(!empty($pixels) && $pixels->isNotEmpty());
|
||||
|
||||
// Detect in-app webviews
|
||||
var ua = navigator.userAgent || navigator.vendor || window.opera;
|
||||
var isInstagram = ua.indexOf('Instagram') > -1;
|
||||
var isFacebook = (ua.indexOf('FBAN') > -1) || (ua.indexOf('FBAV') > -1);
|
||||
var isMessenger = (ua.indexOf('Messenger') > -1) || (ua.indexOf('FB_IAB') > -1);
|
||||
|
||||
if (isInstagram || isFacebook || isMessenger) {
|
||||
var alertBox = document.getElementById('in-app-alert');
|
||||
if (alertBox) {
|
||||
alertBox.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
var fallbackTimeout = null;
|
||||
|
||||
function launchApp() {
|
||||
// Try opening native app scheme
|
||||
window.location.replace(deepLink);
|
||||
|
||||
// Trigger fallback if not installed/unsupported after 1.8 seconds
|
||||
fallbackTimeout = setTimeout(function() {
|
||||
window.location.replace(fallbackUrl);
|
||||
}, 1800);
|
||||
|
||||
// Cancel fallback if user switches context (app successfully opened)
|
||||
window.addEventListener('pagehide', function() {
|
||||
if (fallbackTimeout) clearTimeout(fallbackTimeout);
|
||||
});
|
||||
window.addEventListener('visibilitychange', function() {
|
||||
if (document.hidden && fallbackTimeout) {
|
||||
clearTimeout(fallbackTimeout);
|
||||
}
|
||||
});
|
||||
window.addEventListener('blur', function() {
|
||||
if (fallbackTimeout) {
|
||||
clearTimeout(fallbackTimeout);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Also clear timeout if user clicks manual trigger button
|
||||
var deepLinkBtn = document.getElementById('deep-link-btn');
|
||||
if (deepLinkBtn) {
|
||||
deepLinkBtn.addEventListener('click', function() {
|
||||
if (fallbackTimeout) {
|
||||
clearTimeout(fallbackTimeout);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (hasPixels) {
|
||||
// Wait 250ms for pixels to fire before triggering redirect
|
||||
setTimeout(launchApp, 250);
|
||||
} else {
|
||||
launchApp();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
90
resources/views/expired.blade.php
Normal file
90
resources/views/expired.blade.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="h-full">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ __('filament-short-url::default.expired_title') ?? 'Link Inactive or Expired' }}</title>
|
||||
|
||||
<!-- Premium Google Fonts: Bricolage Grotesque -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,200..800&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Self-contained Tailwind CSS CDN for maximum plug-and-play reliability -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Bricolage Grotesque', 'sans-serif'],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detect system dark mode preferences
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-[#FCFCFC] dark:bg-[#0C0C0C] min-h-screen flex flex-col justify-between items-center py-10 px-6 font-sans antialiased">
|
||||
@php
|
||||
$siteName = config('filament-short-url.site_name') ?: config('app.name', 'Laravel');
|
||||
@endphp
|
||||
|
||||
{{-- Main Expiry Box --}}
|
||||
<div class="w-full max-w-[360px] flex flex-col items-center gap-6 my-auto">
|
||||
<div class="flex flex-col items-center text-center pb-2 select-none w-full">
|
||||
<span class="text-3xl font-extrabold tracking-tight text-neutral-900 dark:text-white mb-3">{{ $siteName }}</span>
|
||||
|
||||
<div class="w-16 h-16 rounded-full bg-amber-500/10 dark:bg-amber-500/20 flex items-center justify-center mt-4 mb-2 text-amber-500">
|
||||
<svg class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<p class="text-xl font-medium text-neutral-900 dark:text-white mt-2">
|
||||
{{ __('filament-short-url::default.expired_title') ?? 'Link Inactive or Expired' }}
|
||||
</p>
|
||||
<p class="text-sm text-neutral-400 dark:text-neutral-500 mt-1">
|
||||
{{ __('filament-short-url::default.expired_description') ?? 'This link is no longer active. It may have reached its click limit, expiration date, or has been deactivated.' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="w-full flex flex-col gap-4">
|
||||
{{-- Short URL info box --}}
|
||||
@if ($shortUrl)
|
||||
<div class="relative flex items-center justify-center px-3 py-3.5 w-full rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 transition duration-200">
|
||||
<div class="flex flex-col justify-center text-center min-w-0">
|
||||
<span class="text-[10px] font-bold text-neutral-400 dark:text-neutral-500 uppercase tracking-wide select-none">
|
||||
Expired Link
|
||||
</span>
|
||||
<p class="break-all text-xs font-mono font-semibold text-rose-600 dark:text-rose-400 mt-1 select-all">
|
||||
{{ $shortUrl->getShortUrl() }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Action Button --}}
|
||||
<a href="{{ url('/') }}" class="w-full mt-2 py-3.5 rounded-xl bg-neutral-900 hover:bg-neutral-800 dark:bg-white dark:hover:bg-neutral-200 dark:text-neutral-900 text-white font-semibold text-sm transition duration-200 flex justify-center items-center gap-2 text-center">
|
||||
<span>{{ __('filament-short-url::default.expired_btn_home') ?? 'Go to Homepage' }}</span>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Footer section --}}
|
||||
<div class="flex flex-col items-center gap-2 mt-auto select-none">
|
||||
<span class="text-xs font-medium text-neutral-400 dark:text-neutral-600">© {{ date('Y') }} {{ $siteName }} Inc. All rights reserved.</span>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
101
resources/views/password-prompt.blade.php
Normal file
101
resources/views/password-prompt.blade.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="h-full">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ __('filament-short-url::default.password_title') ?? 'Password Protected' }}</title>
|
||||
|
||||
<!-- Premium Google Fonts: Bricolage Grotesque -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,200..800&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Self-contained Tailwind CSS CDN for maximum plug-and-play reliability -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Bricolage Grotesque', 'sans-serif'],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detect system dark mode preferences
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-[#FCFCFC] dark:bg-[#0C0C0C] min-h-screen flex flex-col justify-between items-center py-10 px-6 font-sans antialiased">
|
||||
@php
|
||||
$logoPath = function_exists('setting') ? setting('logo_path') : null;
|
||||
$logoUrl = $logoPath ? \Illuminate\Support\Facades\Storage::disk('public')->url($logoPath) : null;
|
||||
$siteName = config('filament-short-url.site_name') ?: config('app.name', 'Laravel');
|
||||
@endphp
|
||||
|
||||
{{-- Main Sign-in Box --}}
|
||||
<div class="w-full max-w-[360px] flex flex-col items-center gap-6 my-auto">
|
||||
<div class="flex flex-col items-center text-center pb-2 select-none w-full">
|
||||
@if ($logoUrl)
|
||||
<img src="{{ $logoUrl }}" alt="{{ $siteName }}" class="h-[60px] w-auto object-contain mb-4" />
|
||||
@else
|
||||
<span class="text-3xl font-extrabold tracking-tight text-neutral-900 dark:text-white mb-3">{{ $siteName }}</span>
|
||||
@endif
|
||||
<p class="text-xl font-medium text-neutral-900 dark:text-white mt-2">
|
||||
{{ __('filament-short-url::default.password_title') ?? 'Password Protected' }}
|
||||
</p>
|
||||
<p class="text-sm text-neutral-400 dark:text-neutral-500 mt-1">
|
||||
{{ __('filament-short-url::default.password_description') ?? 'This link is password-protected. Please enter the correct password to continue.' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form method="POST" class="w-full flex flex-col gap-4">
|
||||
@csrf
|
||||
|
||||
{{-- Password input styled exactly like Custom Login --}}
|
||||
<div class="flex flex-col gap-1.5 w-full">
|
||||
<div class="relative flex flex-col justify-center px-3 py-2 w-full rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 focus-within:ring-2 focus-within:ring-neutral-900 dark:focus-within:ring-white transition duration-200">
|
||||
<label for="password" class="text-[10px] font-bold text-neutral-400 dark:text-neutral-500 uppercase tracking-wide select-none">
|
||||
{{ __('filament-short-url::default.password_placeholder') ?? 'Password' }}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
autofocus
|
||||
class="w-full bg-transparent text-sm text-neutral-900 dark:text-white placeholder-neutral-300 dark:placeholder-neutral-700 focus:outline-none py-0.5 mt-0.5"
|
||||
/>
|
||||
</div>
|
||||
@if ($errors->has('password'))
|
||||
<span class="text-xs text-red-500 px-1 mt-0.5">{{ $errors->first('password') }}</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Submit button --}}
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full mt-2 py-3.5 rounded-xl bg-neutral-900 hover:bg-neutral-800 dark:bg-white dark:hover:bg-neutral-200 dark:text-neutral-900 text-white font-semibold text-sm transition duration-200 shadow-sm flex justify-center items-center gap-2"
|
||||
>
|
||||
<span>{{ __('filament-short-url::default.password_btn_unlock') ?? 'Unlock & Redirect' }}</span>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{-- Footer section --}}
|
||||
<div class="flex flex-col items-center gap-2 mt-auto select-none">
|
||||
<span class="text-xs font-medium text-neutral-400 dark:text-neutral-600">© {{ date('Y') }} {{ $siteName }} Inc. All rights reserved.</span>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
181
resources/views/pixel-loading.blade.php
Normal file
181
resources/views/pixel-loading.blade.php
Normal file
@@ -0,0 +1,181 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="h-full">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ __('filament-short-url::default.pixel_loading_title') ?? 'Connecting...' }}</title>
|
||||
|
||||
<!-- Premium Google Fonts: Bricolage Grotesque -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,200..800&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Self-contained Tailwind CSS CDN for maximum plug-and-play reliability -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Bricolage Grotesque', 'sans-serif'],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detect system dark mode preferences
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
</script>
|
||||
|
||||
@php
|
||||
$pixelMetaIds = $pixels->where('type', 'meta')->pluck('pixel_id');
|
||||
$pixelGoogleIds = $pixels->where('type', 'google')->pluck('pixel_id');
|
||||
$pixelLinkedinIds = $pixels->where('type', 'linkedin')->pluck('pixel_id');
|
||||
$pixelTiktokIds = $pixels->where('type', 'tiktok')->pluck('pixel_id');
|
||||
$pixelPinterestIds = $pixels->where('type', 'pinterest')->pluck('pixel_id');
|
||||
@endphp
|
||||
|
||||
<!-- Meta / Facebook Pixel -->
|
||||
@if($pixelMetaIds->isNotEmpty())
|
||||
<script>
|
||||
!function(f,b,e,v,n,t,s)
|
||||
{if(f.fbq)return;n=f.fbq=function(){n.callMethod?
|
||||
n.callMethod.apply(n,arguments):n.queue.push(arguments)};
|
||||
if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';
|
||||
n.queue=[];t=b.createElement(e);t.async=!0;
|
||||
t.src=v;s=b.getElementsByTagName(e)[0];
|
||||
s.parentNode.insertBefore(t,s)}(window, document,'script',
|
||||
'https://connect.facebook.net/en_US/fbevents.js');
|
||||
@foreach($pixelMetaIds as $id)
|
||||
fbq('init', '{{ $id }}');
|
||||
fbq('track', 'PageView');
|
||||
@endforeach
|
||||
</script>
|
||||
@foreach($pixelMetaIds as $id)
|
||||
<noscript><img height="1" width="1" style="display:none" src="https://www.facebook.com/tr?id={{ $id }}&ev=PageView&noscript=1" /></noscript>
|
||||
@endforeach
|
||||
@endif
|
||||
|
||||
<!-- Google Analytics / GTM -->
|
||||
@if($pixelGoogleIds->isNotEmpty())
|
||||
@php $firstGoogleId = $pixelGoogleIds->first(); @endphp
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id={{ $firstGoogleId }}"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
@foreach($pixelGoogleIds as $id)
|
||||
gtag('config', '{{ $id }}');
|
||||
@endforeach
|
||||
</script>
|
||||
@endif
|
||||
|
||||
<!-- LinkedIn Insight -->
|
||||
@if($pixelLinkedinIds->isNotEmpty())
|
||||
<script type="text/javascript">
|
||||
window._linkedin_data_partner_ids = window._linkedin_data_partner_ids || [];
|
||||
@foreach($pixelLinkedinIds as $id)
|
||||
window._linkedin_data_partner_ids.push("{{ $id }}");
|
||||
@endforeach
|
||||
(function(l) {
|
||||
if (!l){window.lintrk = function(a,b){window.lintrk.q.push([a,b])};
|
||||
window.lintrk.q=[]}
|
||||
var s = document.getElementsByTagName("script")[0];
|
||||
var b = document.createElement("script");
|
||||
b.type = "text/javascript";b.async = true;
|
||||
b.src = "https://snap.licdn.com/li.lms-analytics/insight.min.js";
|
||||
s.parentNode.insertBefore(b, s);})(window.lintrk);
|
||||
</script>
|
||||
@foreach($pixelLinkedinIds as $id)
|
||||
<noscript><img height="1" width="1" style="display:none;" alt="" src="https://px.ads.linkedin.com/collect/?pid={{ $id }}&fmt=gif" /></noscript>
|
||||
@endforeach
|
||||
@endif
|
||||
|
||||
<!-- TikTok Pixel -->
|
||||
@if($pixelTiktokIds->isNotEmpty())
|
||||
<script>
|
||||
!function (w, d, t) {
|
||||
w.TiktokAnalyticsObject=t;var ttq=w[t]=w[t]||[];ttq.methods=["page","track","identify","instances","debug","on","off","once","ready","alias","group","enableCookie","disableCookie","holdConsent","revokeConsent","grantConsent"],ttq.setAndDefer=function(t,e){t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}};for(var i=0;i<ttq.methods.length;i++)ttq.setAndDefer(ttq,ttq.methods[i]);ttq.instance=function(t){for(var e=ttq._i[t]||[],n=0;n<ttq.methods.length;n++)ttq.setAndDefer(e,ttq.methods[n]);return e},ttq.load=function(e,n){var r="https://analytics.tiktok.com/i18n/pixel/events.js",o=n&&n.mixpanel;ttq._i=ttq._i||{},ttq._i[e]=[],ttq._i[e]._u=r,ttq._t=ttq._t||{},ttq._t[e]=+new Date,ttq._o=ttq._o||{},ttq._o[e]=o||{};var a=d.createElement("script");a.type="text/javascript",a.async=!0,a.src=r+"?sdkid="+e+"&lib="+t;var c=d.getElementsByTagName("script")[0];c.parentNode.insertBefore(a,c)};
|
||||
@foreach($pixelTiktokIds as $id)
|
||||
ttq.load('{{ $id }}');
|
||||
ttq.page();
|
||||
@endforeach
|
||||
}(window, document, 'ttq');
|
||||
</script>
|
||||
@endif
|
||||
|
||||
<!-- Pinterest Tag -->
|
||||
@if($pixelPinterestIds->isNotEmpty())
|
||||
<script>
|
||||
!function(e,n,t,r,a,s,o){e[r]||(e[r]=function(){(e[r].q=e[r].q||[]).push(arguments)},e[r].q=e[r].q||[],s=n.createElement(t),s.async=!0,s.src="https://s.pntrac.com/tag.js",o=n.getElementsByTagName(t)[0],o.parentNode.insertBefore(s,o))}(window,document,"script","pintrk");
|
||||
@foreach($pixelPinterestIds as $id)
|
||||
pintrk('load', '{{ $id }}');
|
||||
pintrk('page');
|
||||
@endforeach
|
||||
</script>
|
||||
@foreach($pixelPinterestIds as $id)
|
||||
<noscript><img height="1" width="1" style="display:none;" alt="" src="https://ct.pinterest.com/v3/?event=init&tid={{ $id }}&noscript=1" /></noscript>
|
||||
@endforeach
|
||||
@endif
|
||||
</head>
|
||||
<body class="bg-[#FCFCFC] dark:bg-[#0C0C0C] min-h-screen flex flex-col justify-between items-center py-10 px-6 font-sans antialiased">
|
||||
@php
|
||||
$logoPath = function_exists('setting') ? setting('logo_path') : null;
|
||||
$logoUrl = $logoPath ? \Illuminate\Support\Facades\Storage::disk('public')->url($logoPath) : null;
|
||||
$siteName = config('filament-short-url.site_name') ?: config('app.name', 'Laravel');
|
||||
@endphp
|
||||
|
||||
{{-- Main card --}}
|
||||
<div class="w-full max-w-[360px] flex flex-col items-center gap-6 my-auto">
|
||||
<div class="flex flex-col items-center text-center pb-2 select-none w-full">
|
||||
@if ($logoUrl)
|
||||
<img src="{{ $logoUrl }}" alt="{{ $siteName }}" class="h-[60px] w-auto object-contain mb-4" />
|
||||
@else
|
||||
<span class="text-3xl font-extrabold tracking-tight text-neutral-900 dark:text-white mb-3">{{ $siteName }}</span>
|
||||
@endif
|
||||
|
||||
{{-- Animated spinner --}}
|
||||
<div class="my-5 flex items-center justify-center gap-1.5" aria-hidden="true">
|
||||
<span class="block w-2.5 h-2.5 rounded-full bg-neutral-300 dark:bg-neutral-600 animate-bounce [animation-delay:-0.3s]"></span>
|
||||
<span class="block w-2.5 h-2.5 rounded-full bg-neutral-400 dark:bg-neutral-500 animate-bounce [animation-delay:-0.15s]"></span>
|
||||
<span class="block w-2.5 h-2.5 rounded-full bg-neutral-500 dark:bg-neutral-400 animate-bounce"></span>
|
||||
</div>
|
||||
|
||||
<p class="text-xl font-medium text-neutral-900 dark:text-white mt-2">
|
||||
{{ __('filament-short-url::default.pixel_loading_title') ?? 'Connecting...' }}
|
||||
</p>
|
||||
<p class="text-sm text-neutral-400 dark:text-neutral-500 mt-1">
|
||||
{{ __('filament-short-url::default.pixel_loading_description') ?? 'Preparing your connection and forwarding you now.' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{-- Progress bar --}}
|
||||
<div class="w-full h-1 rounded-full bg-neutral-200 dark:bg-neutral-800 overflow-hidden">
|
||||
<div id="pixel-progress" class="h-full w-0 rounded-full bg-neutral-900 dark:bg-white transition-all ease-linear" style="transition-duration: 220ms;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Footer --}}
|
||||
<div class="flex flex-col items-center gap-2 mt-auto select-none">
|
||||
<span class="text-xs font-medium text-neutral-400 dark:text-neutral-600">© {{ date('Y') }} {{ $siteName }} Inc. All rights reserved.</span>
|
||||
</div>
|
||||
|
||||
{{-- Redirect after pixels fire --}}
|
||||
<script>
|
||||
// Animate the progress bar to full in sync with the redirect delay
|
||||
requestAnimationFrame(function() {
|
||||
document.getElementById('pixel-progress').style.width = '100%';
|
||||
});
|
||||
|
||||
setTimeout(function() {
|
||||
window.location.replace("{!! addslashes($destination) !!}");
|
||||
}, 250);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,18 +16,49 @@
|
||||
</x-filament::tabs.item>
|
||||
</x-filament::tabs>
|
||||
|
||||
<div class="space-y-6">
|
||||
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlStatsOverview::class, ['record' => $record])
|
||||
@php
|
||||
$dateFrom = $this->filterData['date_from'] ?? null;
|
||||
$dateTo = $this->filterData['date_to'] ?? null;
|
||||
$isCustom = ($this->filterData['preset'] ?? '') === 'custom';
|
||||
@endphp
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<div class="lg:col-span-2">
|
||||
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsChart::class, ['record' => $record])
|
||||
</div>
|
||||
<div>
|
||||
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsRightBreakdown::class, ['record' => $record])
|
||||
<div class="space-y-6">
|
||||
<div class="flex justify-end items-center">
|
||||
<div class="w-full {{ $isCustom ? 'max-w-xl' : 'max-w-[200px]' }}">
|
||||
<form>
|
||||
{{ $this->form }}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsBottomBreakdown::class, ['record' => $record])
|
||||
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlStatsOverview::class, [
|
||||
'record' => $record,
|
||||
'dateFrom' => $dateFrom,
|
||||
'dateTo' => $dateTo,
|
||||
], key('stats-overview-' . $dateFrom . '-' . $dateTo))
|
||||
|
||||
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsChart::class, [
|
||||
'record' => $record,
|
||||
'dateFrom' => $dateFrom,
|
||||
'dateTo' => $dateTo,
|
||||
], key('stats-chart-' . $dateFrom . '-' . $dateTo))
|
||||
|
||||
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsRightBreakdown::class, [
|
||||
'record' => $record,
|
||||
'dateFrom' => $dateFrom,
|
||||
'dateTo' => $dateTo,
|
||||
], key('stats-right-breakdown-' . $dateFrom . '-' . $dateTo))
|
||||
|
||||
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlWorldMapWidget::class, [
|
||||
'record' => $record,
|
||||
'dateFrom' => $dateFrom,
|
||||
'dateTo' => $dateTo,
|
||||
], key('stats-world-map-' . $dateFrom . '-' . $dateTo))
|
||||
|
||||
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsBottomBreakdown::class, [
|
||||
'record' => $record,
|
||||
'dateFrom' => $dateFrom,
|
||||
'dateTo' => $dateTo,
|
||||
], key('stats-bottom-breakdown-' . $dateFrom . '-' . $dateTo))
|
||||
</div>
|
||||
</x-filament-panels::page>
|
||||
|
||||
113
resources/views/warning.blade.php
Normal file
113
resources/views/warning.blade.php
Normal file
@@ -0,0 +1,113 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="h-full">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ __('filament-short-url::default.warning_title') ?? 'Redirect Warning' }}</title>
|
||||
|
||||
<!-- Premium Google Fonts: Bricolage Grotesque -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,200..800&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Self-contained Tailwind CSS CDN for maximum plug-and-play reliability -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Bricolage Grotesque', 'sans-serif'],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detect system dark mode preferences
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-[#FCFCFC] dark:bg-[#0C0C0C] min-h-screen flex flex-col justify-between items-center py-10 px-6 font-sans antialiased">
|
||||
@php
|
||||
$logoPath = function_exists('setting') ? setting('logo_path') : null;
|
||||
$logoUrl = $logoPath ? \Illuminate\Support\Facades\Storage::disk('public')->url($logoPath) : null;
|
||||
$siteName = config('filament-short-url.site_name') ?: config('app.name', 'Laravel');
|
||||
@endphp
|
||||
|
||||
{{-- Main Warning Box --}}
|
||||
<div class="w-full max-w-[360px] flex flex-col items-center gap-6 my-auto">
|
||||
<div class="flex flex-col items-center text-center pb-2 select-none w-full">
|
||||
@if ($logoUrl)
|
||||
<img src="{{ $logoUrl }}" alt="{{ $siteName }}" class="h-[60px] w-auto object-contain mb-4" />
|
||||
@else
|
||||
<span class="text-3xl font-extrabold tracking-tight text-neutral-900 dark:text-white mb-3">{{ $siteName }}</span>
|
||||
@endif
|
||||
<p class="text-xl font-medium text-neutral-900 dark:text-white mt-2">
|
||||
{{ __('filament-short-url::default.warning_title') ?? 'Security Redirect Warning' }}
|
||||
</p>
|
||||
<p class="text-sm text-neutral-400 dark:text-neutral-500 mt-1">
|
||||
{{ __('filament-short-url::default.warning_description') ?? 'You are leaving this secure portal and being redirected to an external target link. Please ensure you trust the address below:' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="w-full flex flex-col gap-4">
|
||||
{{-- Destination URL Container styled exactly like Custom Login input box --}}
|
||||
<div class="flex flex-col gap-1.5 w-full">
|
||||
<div class="relative flex items-center justify-between px-3 py-3 w-full rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 transition duration-200">
|
||||
<div class="flex-grow flex flex-col justify-center min-w-0 pr-4">
|
||||
<span class="text-[10px] font-bold text-neutral-400 dark:text-neutral-500 uppercase tracking-wide select-none">
|
||||
Destination URL
|
||||
</span>
|
||||
<p id="dest-url-text" class="break-all text-xs font-mono font-semibold text-rose-600 dark:text-rose-400 mt-1 select-all">
|
||||
{{ $destinationUrl }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Quick Copy Button -->
|
||||
<button onclick="
|
||||
const text = document.getElementById('dest-url-text').innerText;
|
||||
navigator.clipboard.writeText(text);
|
||||
const btn = this;
|
||||
const origHtml = btn.innerHTML;
|
||||
btn.innerHTML = `<svg class='h-4 w-4 text-emerald-500' fill='none' viewBox='0 0 24 24' stroke='currentColor' stroke-width='3.5'><path stroke-linecap='round' stroke-linejoin='round' d='M5 13l4 4L19 7' /></svg>`;
|
||||
setTimeout(() => { btn.innerHTML = origHtml; }, 2000);
|
||||
" class="flex-shrink-0 p-2 rounded-lg border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-950 text-neutral-400 hover:text-neutral-600 dark:text-neutral-500 dark:hover:text-neutral-300 transition-colors focus:outline-none" title="Copy URL">
|
||||
<svg class="h-4 w-4 pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Action Buttons --}}
|
||||
<div class="flex flex-col gap-3 w-full mt-2">
|
||||
<!-- Continue Button (Primary Accent) -->
|
||||
<a href="{{ request()->fullUrlWithQuery(['confirmed' => 1]) }}"
|
||||
class="w-full py-3.5 rounded-xl bg-rose-600 hover:bg-rose-500 dark:bg-rose-500 dark:hover:bg-rose-400 text-white font-semibold text-sm transition duration-200 shadow-sm flex justify-center items-center gap-2 text-center">
|
||||
<span>{{ __('filament-short-url::default.warning_btn_continue') ?? 'Continue to Destination' }}</span>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<!-- Go Back Button (Secondary) -->
|
||||
<button onclick="window.history.back()"
|
||||
class="w-full py-3 rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 hover:bg-neutral-50 dark:hover:bg-neutral-800 text-neutral-900 dark:text-white font-semibold text-sm transition duration-200 flex justify-center items-center gap-2 shadow-sm">
|
||||
{{ __('filament-short-url::default.warning_btn_back') ?? 'Go Back' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Footer section --}}
|
||||
<div class="flex flex-col items-center gap-2 mt-auto select-none">
|
||||
<span class="text-xs font-medium text-neutral-400 dark:text-neutral-600">© {{ date('Y') }} {{ $siteName }} Inc. All rights reserved.</span>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -9,7 +9,7 @@
|
||||
$browserIcons = [
|
||||
'Chrome' => 'heroicon-m-globe-alt',
|
||||
'Firefox' => 'heroicon-m-globe-americas',
|
||||
'Safari' => 'heroicon-m-compass',
|
||||
'Safari' => 'heroicon-m-globe-europe-africa',
|
||||
'Edge' => 'heroicon-m-globe-asia-australia',
|
||||
'Opera' => 'heroicon-m-bolt',
|
||||
'Internet Explorer' => 'heroicon-m-wrench',
|
||||
@@ -27,118 +27,231 @@
|
||||
@endphp
|
||||
|
||||
<x-filament-widgets::widget>
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<div class="space-y-6">
|
||||
{{-- Row 1: Devices, Browsers, Operating Systems --}}
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
|
||||
{{-- Devices --}}
|
||||
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
|
||||
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-50 text-emerald-500 dark:bg-emerald-950/50 dark:text-emerald-400">
|
||||
<x-filament::icon icon="heroicon-o-device-phone-mobile" class="h-5 w-5" />
|
||||
{{-- Devices --}}
|
||||
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
|
||||
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-50 text-emerald-500 dark:bg-emerald-950/50 dark:text-emerald-400">
|
||||
<x-filament::icon icon="heroicon-o-device-phone-mobile" class="h-5 w-5" />
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_devices') }}</h3>
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_devices') }}</h3>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
@forelse ($visitsByDevice as $device => $count)
|
||||
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gray-50 text-gray-500 dark:bg-gray-800 dark:text-gray-400">
|
||||
<x-filament::icon :icon="$deviceIcons[$device] ?? 'heroicon-m-question-mark-circle'" class="h-4 w-4" />
|
||||
<div 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>
|
||||
<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>
|
||||
@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>
|
||||
|
||||
</div>
|
||||
|
||||
{{-- Row 2: Referrers, Cities --}}
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
|
||||
{{-- Referers --}}
|
||||
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
|
||||
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-purple-50 text-purple-500 dark:bg-purple-950/50 dark:text-purple-400">
|
||||
<x-filament::icon icon="heroicon-o-link" class="h-5 w-5" />
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_referers') }}</h3>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
@forelse ($visitsByReferer as $referer => $count)
|
||||
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
|
||||
<div class="flex items-center justify-between text-sm gap-4">
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<x-filament::icon icon="heroicon-m-link" class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" />
|
||||
@if ($referer === 'Direct')
|
||||
<span class="font-medium text-gray-500 dark:text-gray-400 truncate">{{ $referer }}</span>
|
||||
@else
|
||||
<a href="https://{{ $referer }}" target="_blank" rel="noopener"
|
||||
class="truncate text-indigo-600 hover:underline dark:text-indigo-400 font-medium">
|
||||
{{ $referer }}
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0 ml-2">
|
||||
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
|
||||
</span>
|
||||
</div>
|
||||
@empty
|
||||
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_referer_data') }}</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Cities --}}
|
||||
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
|
||||
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-50 text-emerald-500 dark:bg-emerald-950/50 dark:text-emerald-400">
|
||||
<x-filament::icon icon="heroicon-o-map-pin" class="h-5 w-5" />
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_cities') }}</h3>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
@forelse ($visitsByCity as $city => $count)
|
||||
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
|
||||
<div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ $city }}</span>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span></span>
|
||||
</div>
|
||||
<div class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
|
||||
<div class="h-full rounded-full bg-emerald-500 transition-all duration-500" style="width: {{ $pct }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_device_data') }}</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Browsers --}}
|
||||
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
|
||||
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-amber-50 text-amber-500 dark:bg-amber-950/50 dark:text-amber-400">
|
||||
<x-filament::icon icon="heroicon-o-globe-asia-australia" class="h-5 w-5" />
|
||||
@empty
|
||||
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_city_data') }}</p>
|
||||
@endforelse
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_browsers') }}</h3>
|
||||
</div>
|
||||
<div class="space-y-3.5">
|
||||
@forelse ($visitsByBrowser as $browser => $count)
|
||||
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<x-filament::icon :icon="$browserIcons[$browser] ?? 'heroicon-m-globe-alt'" class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" />
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300 truncate">{{ $browser }}</span>
|
||||
</div>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0 ml-2">
|
||||
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
|
||||
</span>
|
||||
</div>
|
||||
@empty
|
||||
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_browser_data') }}</p>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{{-- Operating Systems --}}
|
||||
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
|
||||
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-blue-50 text-blue-500 dark:bg-blue-950/50 dark:text-blue-400">
|
||||
<x-filament::icon icon="heroicon-o-computer-desktop" class="h-5 w-5" />
|
||||
{{-- UTM Campaigns Section --}}
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
|
||||
{{-- UTM Sources --}}
|
||||
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
|
||||
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-rose-50 text-rose-500 dark:bg-rose-950/50 dark:text-rose-400">
|
||||
<x-filament::icon icon="heroicon-o-megaphone" class="h-5 w-5" />
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_utm_source') }}</h3>
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_os') }}</h3>
|
||||
</div>
|
||||
<div class="space-y-3.5">
|
||||
@forelse ($visitsByOs as $os => $count)
|
||||
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<x-filament::icon :icon="$osIcons[$os] ?? 'heroicon-m-computer-desktop'" class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" />
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300 truncate">{{ $os }}</span>
|
||||
<div class="space-y-3.5">
|
||||
@forelse ($utmSources as $source => $count)
|
||||
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300 truncate mr-2">{{ $source }}</span>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0">
|
||||
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
|
||||
</span>
|
||||
</div>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0 ml-2">
|
||||
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
|
||||
</span>
|
||||
</div>
|
||||
@empty
|
||||
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_os_data') }}</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Referers --}}
|
||||
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
|
||||
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-purple-50 text-purple-500 dark:bg-purple-950/50 dark:text-purple-400">
|
||||
<x-filament::icon icon="heroicon-o-link" class="h-5 w-5" />
|
||||
@empty
|
||||
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_utm_data') }}</p>
|
||||
@endforelse
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_referers') }}</h3>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
@forelse ($visitsByReferer as $referer => $count)
|
||||
<div class="flex items-center justify-between text-sm gap-4">
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<x-filament::icon icon="heroicon-m-link" class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" />
|
||||
<a href="{{ $referer }}" target="_blank" rel="noopener"
|
||||
class="truncate text-indigo-600 hover:underline dark:text-indigo-400 font-medium">
|
||||
{{ $referer }}
|
||||
</a>
|
||||
</div>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0">
|
||||
{{ number_format($count) }}
|
||||
</span>
|
||||
</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>
|
||||
|
||||
{{-- UTM Mediums --}}
|
||||
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
|
||||
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-teal-50 text-teal-500 dark:bg-teal-950/50 dark:text-teal-400">
|
||||
<x-filament::icon icon="heroicon-o-tag" class="h-5 w-5" />
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_utm_medium') }}</h3>
|
||||
</div>
|
||||
<div class="space-y-3.5">
|
||||
@forelse ($utmMediums as $medium => $count)
|
||||
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300 truncate mr-2">{{ $medium }}</span>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0">
|
||||
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
|
||||
</span>
|
||||
</div>
|
||||
@empty
|
||||
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_utm_data') }}</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- UTM Campaigns --}}
|
||||
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
|
||||
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-cyan-50 text-cyan-500 dark:bg-cyan-950/50 dark:text-cyan-400">
|
||||
<x-filament::icon icon="heroicon-o-flag" class="h-5 w-5" />
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_utm_campaign') }}</h3>
|
||||
</div>
|
||||
<div class="space-y-3.5">
|
||||
@forelse ($utmCampaigns as $campaign => $count)
|
||||
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300 truncate mr-2">{{ $campaign }}</span>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0">
|
||||
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
|
||||
</span>
|
||||
</div>
|
||||
@empty
|
||||
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_utm_data') }}</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</x-filament-widgets::widget>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<x-filament-widgets::widget>
|
||||
<div class="space-y-6">
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
|
||||
{{-- 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">
|
||||
@@ -11,10 +11,13 @@
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
@forelse ($visitsByCountry as $country => $count)
|
||||
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
|
||||
@php
|
||||
$pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0;
|
||||
$translatedCountry = \Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsRightBreakdown::getCountryTranslation($country);
|
||||
@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-medium text-gray-700 dark:text-gray-300">{{ $translatedCountry }}</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">
|
||||
@@ -27,5 +30,34 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Languages --}}
|
||||
<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-language" 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_languages') }}</h3>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
@forelse ($visitsByLanguage as $langCode => $count)
|
||||
@php
|
||||
$pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0;
|
||||
$langName = \Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsRightBreakdown::getLanguageTranslation($langCode);
|
||||
@endphp
|
||||
<div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ $langName }} ({{ strtoupper($langCode) }})</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_language_data') }}</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</x-filament-widgets::widget>
|
||||
|
||||
331
resources/views/widgets/world-map.blade.php
Normal file
331
resources/views/widgets/world-map.blade.php
Normal file
@@ -0,0 +1,331 @@
|
||||
@php
|
||||
// Computes top 10 for the ranked sidebar
|
||||
$topCountries = collect($countryData)->sortDesc()->take(10);
|
||||
|
||||
// Country name lookup (ISO-2 → English name)
|
||||
$countryNames = [
|
||||
'AF'=>'Afghanistan','AL'=>'Albania','DZ'=>'Algeria','AO'=>'Angola','AR'=>'Argentina','AU'=>'Australia',
|
||||
'AT'=>'Austria','AZ'=>'Azerbaijan','BD'=>'Bangladesh','BE'=>'Belgium','BF'=>'Burkina Faso','BY'=>'Belarus',
|
||||
'BJ'=>'Benin','BO'=>'Bolivia','BA'=>'Bosnia & Herz.','BW'=>'Botswana','BR'=>'Brazil','BN'=>'Brunei',
|
||||
'BG'=>'Bulgaria','KH'=>'Cambodia','CM'=>'Cameroon','CA'=>'Canada','CF'=>'C. African Rep.','TD'=>'Chad',
|
||||
'CL'=>'Chile','CN'=>'China','CO'=>'Colombia','CD'=>'DR Congo','CG'=>'Congo','CR'=>'Costa Rica',
|
||||
'HR'=>'Croatia','CU'=>'Cuba','CY'=>'Cyprus','CZ'=>'Czech Rep.','DK'=>'Denmark','DO'=>'Dominican Rep.',
|
||||
'EC'=>'Ecuador','EG'=>'Egypt','SV'=>'El Salvador','GQ'=>'Eq. Guinea','ER'=>'Eritrea','EE'=>'Estonia',
|
||||
'ET'=>'Ethiopia','FI'=>'Finland','FR'=>'France','GA'=>'Gabon','GM'=>'Gambia','GE'=>'Georgia',
|
||||
'DE'=>'Germany','GH'=>'Ghana','GR'=>'Greece','GT'=>'Guatemala','GN'=>'Guinea','GW'=>'Guinea-Bissau',
|
||||
'GY'=>'Guyana','HT'=>'Haiti','HN'=>'Honduras','HU'=>'Hungary','IN'=>'India','ID'=>'Indonesia',
|
||||
'IR'=>'Iran','IQ'=>'Iraq','IE'=>'Ireland','IL'=>'Israel','IT'=>'Italy','CI'=>'Ivory Coast',
|
||||
'JP'=>'Japan','JO'=>'Jordan','KZ'=>'Kazakhstan','KE'=>'Kenya','KP'=>'North Korea','KR'=>'South Korea',
|
||||
'KW'=>'Kuwait','KG'=>'Kyrgyzstan','LA'=>'Laos','LV'=>'Latvia','LB'=>'Lebanon','LS'=>'Lesotho',
|
||||
'LR'=>'Liberia','LY'=>'Libya','LT'=>'Lithuania','MK'=>'N. Macedonia','MG'=>'Madagascar','MW'=>'Malawi',
|
||||
'MY'=>'Malaysia','ML'=>'Mali','MR'=>'Mauritania','MX'=>'Mexico','MD'=>'Moldova','MN'=>'Mongolia',
|
||||
'ME'=>'Montenegro','MA'=>'Morocco','MZ'=>'Mozambique','MM'=>'Myanmar','NA'=>'Namibia','NP'=>'Nepal',
|
||||
'NL'=>'Netherlands','NZ'=>'New Zealand','NI'=>'Nicaragua','NE'=>'Niger','NG'=>'Nigeria','NO'=>'Norway',
|
||||
'OM'=>'Oman','PK'=>'Pakistan','PS'=>'Palestine','PA'=>'Panama','PG'=>'Papua N. Guinea','PY'=>'Paraguay',
|
||||
'PE'=>'Peru','PH'=>'Philippines','PL'=>'Poland','PT'=>'Portugal','RO'=>'Romania','RU'=>'Russia',
|
||||
'RW'=>'Rwanda','SA'=>'Saudi Arabia','SN'=>'Senegal','RS'=>'Serbia','SL'=>'Sierra Leone','SO'=>'Somalia',
|
||||
'ZA'=>'South Africa','SS'=>'South Sudan','ES'=>'Spain','LK'=>'Sri Lanka','SD'=>'Sudan','SR'=>'Suriname',
|
||||
'SZ'=>'Eswatini','SE'=>'Sweden','CH'=>'Switzerland','SY'=>'Syria','TW'=>'Taiwan','TJ'=>'Tajikistan',
|
||||
'TZ'=>'Tanzania','TH'=>'Thailand','TG'=>'Togo','TN'=>'Tunisia','TR'=>'Turkey','TM'=>'Turkmenistan',
|
||||
'UG'=>'Uganda','UA'=>'Ukraine','AE'=>'UAE','GB'=>'United Kingdom','US'=>'United States','UY'=>'Uruguay',
|
||||
'UZ'=>'Uzbekistan','VE'=>'Venezuela','VN'=>'Vietnam','YE'=>'Yemen','ZM'=>'Zambia','ZW'=>'Zimbabwe',
|
||||
];
|
||||
@endphp
|
||||
|
||||
<x-filament-widgets::widget>
|
||||
<div class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-800 dark:bg-gray-900/50">
|
||||
|
||||
{{-- Header --}}
|
||||
<div class="mb-5 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<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>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">
|
||||
{{ __('filament-short-url::default.world_map_title') }}
|
||||
</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ number_format($totalClicks) }} {{ __('filament-short-url::default.world_map_total_clicks') }}
|
||||
· {{ count($countryData) }} {{ __('filament-short-url::default.world_map_countries') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Legend --}}
|
||||
@if(!empty($countryData))
|
||||
<div class="hidden items-center gap-2 sm:flex">
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.world_map_fewer') }}</span>
|
||||
<div class="flex gap-0.5">
|
||||
@foreach([10, 25, 45, 65, 85, 100] as $intensity)
|
||||
<div class="h-4 w-4 rounded-sm" style="background: hsl(243 100% {{ max(30, 90 - $intensity * 0.55) }}% / {{ max(0.12, $intensity / 100) }});"></div>
|
||||
@endforeach
|
||||
</div>
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.world_map_more') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if(empty($countryData))
|
||||
{{-- Empty state --}}
|
||||
<div class="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div class="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-gray-100 dark:bg-gray-800">
|
||||
<x-filament::icon icon="heroicon-o-globe-alt" class="h-8 w-8 text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ __('filament-short-url::default.world_map_no_data') }}</p>
|
||||
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.world_map_no_data_sub') }}</p>
|
||||
</div>
|
||||
@else
|
||||
{{-- Map + Sidebar layout --}}
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-4">
|
||||
|
||||
{{-- SVG Map --}}
|
||||
<div class="relative lg:col-span-3"
|
||||
x-data="{
|
||||
countryData: {{ json_encode($countryData) }},
|
||||
normalized: {{ json_encode($normalized) }},
|
||||
countryNames: {{ json_encode($countryNames) }},
|
||||
tooltip: { show: false, country: '', count: 0, x: 0, y: 0 },
|
||||
|
||||
init() {
|
||||
this.$nextTick(() => {
|
||||
const svg = this.$el.querySelector('#world-map');
|
||||
if (!svg) return;
|
||||
|
||||
// 1. Annotate paths/groups inside the SVG dynamically
|
||||
const elements = svg.querySelectorAll('[id]');
|
||||
elements.forEach(el => {
|
||||
const code = el.id.toUpperCase();
|
||||
if (code.length !== 2) return;
|
||||
|
||||
const count = this.countryData[code] || 0;
|
||||
if (count > 0) {
|
||||
const intensity = this.normalized[code] || 0;
|
||||
el.classList.add('world-map-country-active');
|
||||
el.style.setProperty('--intensity', intensity / 100);
|
||||
} else {
|
||||
el.classList.add('world-map-country-inactive');
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Render dynamic pulsing dots at the center of top 5 countries
|
||||
const topActiveCodes = Object.keys(this.countryData).slice(0, 5);
|
||||
topActiveCodes.forEach((code, index) => {
|
||||
const el = svg.querySelector(`#${code.toLowerCase()}`);
|
||||
if (el) {
|
||||
try {
|
||||
const bbox = el.getBBox();
|
||||
if (bbox && bbox.width > 0 && bbox.height > 0) {
|
||||
const cx = bbox.x + bbox.width / 2;
|
||||
const cy = bbox.y + bbox.height / 2;
|
||||
|
||||
// Create pulsing halo circle
|
||||
const pulse = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
pulse.setAttribute('cx', cx);
|
||||
pulse.setAttribute('cy', cy);
|
||||
pulse.setAttribute('r', '3');
|
||||
pulse.setAttribute('class', 'world-map-pulse-dot');
|
||||
pulse.style.setProperty('--delay', `${index * 0.4}s`);
|
||||
|
||||
// Create solid core circle
|
||||
const core = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
core.setAttribute('cx', cx);
|
||||
core.setAttribute('cy', cy);
|
||||
core.setAttribute('r', '3');
|
||||
core.setAttribute('class', 'world-map-core-dot');
|
||||
|
||||
svg.appendChild(pulse);
|
||||
svg.appendChild(core);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Could not get bounding box for country ' + code, e);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
handleMouseMove(event) {
|
||||
const activeEl = event.target.closest('.world-map-country-active');
|
||||
if (activeEl) {
|
||||
const code = activeEl.id.toUpperCase();
|
||||
const count = this.countryData[code] || 0;
|
||||
const name = this.countryNames[code] || code;
|
||||
|
||||
this.tooltip.show = true;
|
||||
this.tooltip.country = name;
|
||||
this.tooltip.count = count;
|
||||
|
||||
const rect = this.$el.getBoundingClientRect();
|
||||
this.tooltip.x = event.clientX - rect.left;
|
||||
this.tooltip.y = event.clientY - rect.top;
|
||||
} else {
|
||||
this.tooltip.show = false;
|
||||
}
|
||||
},
|
||||
|
||||
hideTooltip() {
|
||||
this.tooltip.show = false;
|
||||
}
|
||||
}"
|
||||
@mousemove="handleMouseMove($event)"
|
||||
@mouseleave="hideTooltip()"
|
||||
>
|
||||
{{-- Tooltip --}}
|
||||
<div x-show="tooltip.show"
|
||||
x-cloak
|
||||
:style="`left: ${tooltip.x + 12}px; top: ${tooltip.y - 8}px`"
|
||||
class="pointer-events-none absolute z-20 rounded-lg border border-gray-200 bg-white px-3 py-2 shadow-xl dark:border-gray-700 dark:bg-gray-800"
|
||||
style="min-width: 130px; transition: left 0.05s ease, top 0.05s ease;"
|
||||
>
|
||||
<p class="text-xs font-semibold text-gray-800 dark:text-gray-200" x-text="tooltip.country"></p>
|
||||
<p class="mt-0.5 text-xs text-indigo-600 dark:text-indigo-400">
|
||||
<span x-text="tooltip.count.toLocaleString()"></span> clicks
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-xl border border-gray-100 dark:border-gray-800/80">
|
||||
{!! $svgContent !!}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Ranked Country Sidebar --}}
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="mb-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ __('filament-short-url::default.world_map_top_countries') }}
|
||||
</p>
|
||||
@forelse($topCountries as $code => $count)
|
||||
@php
|
||||
$pct = $totalClicks > 0 ? round($count / $totalClicks * 100, 1) : 0;
|
||||
$name = $countryNames[$code] ?? $code;
|
||||
$barWidth = $maxCount > 0 ? round($count / $maxCount * 100) : 0;
|
||||
@endphp
|
||||
<div class="group rounded-lg px-2 py-1.5 transition-colors hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-5 text-center text-xs font-bold text-gray-400 dark:text-gray-500">
|
||||
{{ $loop->iteration }}
|
||||
</span>
|
||||
<span class="flex-1 truncate text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ $name }}
|
||||
</span>
|
||||
<span class="shrink-0 font-mono text-xs font-semibold text-gray-900 dark:text-white">
|
||||
{{ number_format($count) }}
|
||||
</span>
|
||||
<span class="w-9 shrink-0 text-right text-xs text-gray-400 dark:text-gray-500">
|
||||
{{ $pct }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1 ml-7 h-1 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
|
||||
<div class="h-full rounded-full bg-indigo-500 transition-all duration-700"
|
||||
style="width: {{ $barWidth }}%">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">
|
||||
{{ __('filament-short-url::default.world_map_no_data') }}
|
||||
</p>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-filament-widgets::widget>
|
||||
|
||||
<style>
|
||||
/* Responsive World Map SVG */
|
||||
#world-map {
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
display: block;
|
||||
background-color: #f8fafc; /* Slate-50 (light mode ocean) */
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.dark #world-map {
|
||||
background-color: #0b0f19; /* Custom premium dark blue-gray (dark mode ocean) */
|
||||
}
|
||||
|
||||
/* Transition for SVG map paths */
|
||||
#world-map path, #world-map g {
|
||||
transition: fill 0.3s ease, stroke 0.3s ease, stroke-width 0.2s ease;
|
||||
}
|
||||
|
||||
/* Inactive countries */
|
||||
.world-map-country-inactive {
|
||||
fill: #f1f5f9 !important; /* Slate-100 */
|
||||
stroke: #cbd5e1 !important; /* Slate-300 */
|
||||
stroke-width: 0.4px !important;
|
||||
}
|
||||
|
||||
.dark .world-map-country-inactive {
|
||||
fill: #1e293b !important; /* Slate-800 */
|
||||
stroke: #334155 !important; /* Slate-700 */
|
||||
}
|
||||
|
||||
/* Active countries heatmap */
|
||||
.world-map-country-active {
|
||||
/* Light mode: higher intensity = darker, richer indigo-700; lower intensity = light indigo-100 */
|
||||
fill: hsla(243, 85%, calc(90% - (var(--intensity) * 45%)), calc(0.25 + var(--intensity) * 0.75)) !important;
|
||||
stroke: rgba(99, 102, 241, 0.4) !important; /* Indigo-500 with opacity */
|
||||
stroke-width: 0.8px !important;
|
||||
}
|
||||
|
||||
.world-map-country-active:hover {
|
||||
stroke: #6366f1 !important; /* Indigo-500 highlight */
|
||||
stroke-width: 1.2px !important;
|
||||
filter: brightness(0.95);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dark .world-map-country-active {
|
||||
/* Dark mode: higher intensity = glowing indigo-400; lower intensity = deep indigo-900 */
|
||||
fill: hsla(243, 90%, calc(25% + (var(--intensity) * 40%)), calc(0.3 + var(--intensity) * 0.7)) !important;
|
||||
stroke: rgba(129, 140, 248, 0.5) !important; /* Indigo-400 with opacity */
|
||||
}
|
||||
|
||||
.dark .world-map-country-active:hover {
|
||||
stroke: #818cf8 !important; /* Indigo-400 highlight */
|
||||
stroke-width: 1.2px !important;
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
/* Pulse Dots CSS */
|
||||
.world-map-pulse-dot {
|
||||
fill: #6366f1;
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
animation: world-map-pulse 2.2s infinite ease-in-out;
|
||||
animation-delay: var(--delay, 0s);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dark .world-map-pulse-dot {
|
||||
fill: #818cf8;
|
||||
}
|
||||
|
||||
.world-map-core-dot {
|
||||
fill: #4f46e5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dark .world-map-core-dot {
|
||||
fill: #6366f1;
|
||||
}
|
||||
|
||||
@keyframes world-map-pulse {
|
||||
0% {
|
||||
r: 3px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
50% {
|
||||
r: 9px;
|
||||
opacity: 0.25;
|
||||
}
|
||||
100% {
|
||||
r: 3px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
409
resources/views/widgets/world-map.svg
Normal file
409
resources/views/widgets/world-map.svg
Normal file
@@ -0,0 +1,409 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
x="0px"
|
||||
y="0px"
|
||||
width="784.077px"
|
||||
height="458.627px"
|
||||
viewBox="30.767 241.591 784.077 458.627"
|
||||
id="world-map">
|
||||
<title>Simple World Map</title>
|
||||
<desc>
|
||||
Author: Al MacDonald
|
||||
Editor: Fritz Lekschas
|
||||
License: CC BY-SA 3.0
|
||||
ID: ISO 3166-1 or "_[a-zA-Z]" if an ISO code is not available
|
||||
</desc>
|
||||
<g>
|
||||
<path id="_somaliland" d="M512.674,502.797l3.526,2.403l1.046-0.052l8.757-3.008l0.994,3.206l-0.701,2.706l-1.893,1.503l-4.729-0.302l-6.769-4.158L512.674,502.797L512.674,502.797z"/>
|
||||
<path id="ae" d="M528.466,468.135l0.753,3.008l8.522,0.752l0.596-6.172l1.644-0.897l0.448-2.257l-2.688,0.753l-2.99,4.521L528.466,468.135L528.466,468.135z"/>
|
||||
<path id="af" d="M545.85,435.383l1.374,10.771l3.423,0.752l0.32,1.937l-2.455,2.049l4.573,3.691l8.885-3.198l0.709-3.786l5.593-3.491l2.145-8.091l1.599-1.722l-1.659-2.887l5.412-3.347l-0.691-0.968l-2.498,0.155l-0.226,2.299l-3.354-0.033l-0.062-3.068l-1.079-1.288l-1.815,1.649l0.052,1.515l-2.739,1.036l-5.059-0.319l-6.568,6.881L545.85,435.383L545.85,435.383z"/>
|
||||
<path id="al" d="M450.679,420.438v3.984l1.142,2.152l0.82-0.096l1.409-2.566l-0.821-1.15l-0.319-2.844l-1.089-1.012L450.679,420.438L450.679,420.438z"/>
|
||||
<path id="am" d="M507.47,420.549l4.149,5.411l-1.218,1.427l-2.939-0.51l-3.646-3.268l0.196-2.146L507.47,420.549L507.47,420.549z"/>
|
||||
<g id="ao">
|
||||
<path class="mainland" d="M437.366,547.461l2.948,11.003l-0.069,3.478l-4.313,4.633l-0.647,7.527l16.597,0.147l5.395,1.954l4.45-0.58l-2.594-3.251l0.01-9.282l5.101-0.217v-3.621l-4.142-0.172l-0.829-8.575l-1.746,0.024l-0.943-0.848l-1.027,0.054l-1.365,2.646h-5.255l-1.22-1.227l0.363-1.738l-1.436-2.101L437.366,547.461L437.366,547.461z"/>
|
||||
<path d="M435.577,544.453l1.504,1.953l1.945-1.842l-0.571-1.909l-0.483-0.035L435.577,544.453L435.577,544.453z"/>
|
||||
</g>
|
||||
<g id="ar">
|
||||
<path class="mainland" d="M279.05,600.613l1.677,1.571l-6.371,9.467l-2.239,2.479l0.777,10.813l4.918,5.974l-4.132,7.209l-3.129,1.35h-3.579l1.003,5.627l-5.593,1.92l1.34,4.729l-3.354,10.701l4.141,3.38l-2.239,5.515l-3.804,5.975l2.014,4.165l-4.918,0.786l-4.028-4.951l-0.674-15.432l-6.258-26.209l1.893-9.163l-4.028-11.713l2.68-15.204l2.463-2.931l-0.605-2.222l3.164-2.888l7.054,0.483l3.942,4.21l4.555,0.078l4.668,2.853l-1.375,3.217l0.329,3.25l6.612-0.312L279.05,600.613L279.05,600.613z"/>
|
||||
<path d="M264.745,687.564l0.225,4.951l3.803-0.337l3.242-2.144l-5.48-1.124L264.745,687.564L264.745,687.564z"/>
|
||||
</g>
|
||||
<path id="at" d="M430.46,403.459l-0.562,1.167l0.483,0.83l2.015-0.415h1.711l1.857,1.572l3.95-0.717l2.904-1.729l0.743-1.167l-0.112-1.504l-2.611-1.954l-3.501,0.035l-0.294,1.988l-3.683,1.797L430.46,403.459L430.46,403.459z"/>
|
||||
<g id="au">
|
||||
<path class="mainland" d="M672.961,609.067l-0.303,21.938l-3.371,2.472l-0.303,2.161l4.598,3.086l11.35-2.161h5.826l2.145-3.095l12.879-2.472l9.198,2.784l-0.614,3.708l1.228,3.708l7.055-1.236l0.303,1.851l-4.599,3.396l1.53,1.236l3.37-1.236l-0.917,10.2l6.44,4.944l3.683-1.236l1.841,1.852l10.735-1.548l10.123-16.381l3.682-0.925l7.357-13.596l1.841-11.739l-4.599-5.869l1.842-1.236l-3.684-11.436l-3.984-2.783l0.614-15.448l-3.684-2.782l-0.916-8.652h-1.842l-6.138,20.392l-3.37,0.312l-7.668-7.728l4.297-11.437l-7.971-1.547l-8.896,2.472l-2.454,7.104l-3.984,0.925l-0.304-4.944l-16.252,9.889l0.304,3.708l-2.454,3.397h-6.139l-13.19,5.56L672.961,609.067L672.961,609.067z"/>
|
||||
<path d="M728.775,668.089l-1.531,6.181l0.304,4.322l4.599-0.312l5.213-8.03L728.775,668.089L728.775,668.089z"/>
|
||||
</g>
|
||||
<path id="az" d="M508.931,418.674l-0.873,1.486l4.071,5.342l1.418-0.458l2.334,2.446l1.011-4.287l2.533,0.406l-0.104-1.229l-4.165-3.646l-0.795,2.143L508.931,418.674L508.931,418.674z"/>
|
||||
<path id="ba" d="M442.708,411.084l-0.319,0.527l5.801,5.981l2.127-3.13l-0.078-1.234l-1.857-2.256L442.708,411.084L442.708,411.084z"/>
|
||||
<path id="bd" d="M616.256,457.908l-1.134,2.049l2.938,5.584l0.087,4.357l0.535,1.166l3.449,0.061l1.953-1.875l1.418,0.855l0.285,2.652l1.133-0.708l0.069-3.389l-0.951-0.112l-0.596-2.879l-2.403-0.086l-0.596-1.6l1.469-1.962l0.024-0.97h-4.269L616.256,457.908L616.256,457.908z"/>
|
||||
<path id="be" d="M414.019,391.704l-0.554,1.383l5.947,3.925l0.4,0.051l0.375-1.094l0.837-0.588l-1.336-1.499h-0.916l-1.255-1.426L414.019,391.704L414.019,391.704z"/>
|
||||
<path id="bf" d="M404.493,493.496l3.146-0.25l5.16,7.295l-4.789,3.613l-3.466-0.892l-4.66,0.062l-0.752,2.73l-3.907,0.19l-1.071-1.461l1.385-4.442L404.493,493.496L404.493,493.496z"/>
|
||||
<path id="bg" d="M457.092,414.066l0.139,4.305l1.452,3.025l5.454,0.095l2.455-1.737l2.412-0.959l-0.588-2.75l0.545-1.469l-1.228-0.641l-1.687,0.139l-1.323,1.332l-5.549,0.043L457.092,414.066L457.092,414.066z"/>
|
||||
<path id="bi" d="M478.504,538.385l3.691-0.078l-0.959,3.232l-0.935,0.812h-1.142l-0.812-2.187L478.504,538.385L478.504,538.385z"/>
|
||||
<path id="bj" d="M411.512,515.288h1.833l0.104-5.204l2.315-3.363l-0.104-5.852l-2.102-0.052l-3.604,2.816l1.504,2.87L411.512,515.288L411.512,515.288z"/>
|
||||
<path id="bn" d="M689.038,515.08l-2.489,3.018l2.04,0.641l1.149-1.608L689.038,515.08L689.038,515.08z"/>
|
||||
<path id="bo" d="M238.631,561.361l7.114-3.104l2.351,0.226l1.565,6.534l10.839,3.604l1.79,5.523l4.469,0.562l1.902,4.729l-1.34,4.278l-7.27,0.562l-2.68,6.872l-5.705-0.112l-1.79-0.337l-3.293,3.197l-1.625-0.156l-5.593-12.957l1.547-2.316l0.545-9.163l-1.383-5.455L238.631,561.361L238.631,561.361z"/>
|
||||
<path id="br" d="M286.631,618.464l5.402-10.391l0.198-8.73l10.079-6.501h5.645l4.435-7.512l0.804-14.418l-1.815-3.855l10.684-9.751l0.406-10.761l-14.514-7.105l-17.53-5.48l-8.264-0.812l2.221-4.669l-0.604-7.104l-1.808-0.596l-2.671,5.308l-1.4,1.754l-3.595-1.59l-12.093,4.261l-4.028-5.073l0.648-5.299l-3.804,3.872l-4.201-2.265l-0.424,0.597l0.009,1.841l3.622,1.945l-5.437,5.73l-3.432-0.034l-3.475-3.535l-3.933,0.121l-0.484,4.2l2.256,2.739l-2.663,8.532l-3.112,0.241l-4.953,3.13l-1.21,6.146l4.296,4.599l0.787-0.89l3.017-0.812l2.576,4.34l7.374-3.164l2.861,0.165l1.971,6.976l10.52,3.337l1.815,5.564l4.478,0.537l2.135,5.314l-1.443,4.729l1.884,2.474l-0.276,3.683l5.048-0.477l4.625,5.844l-0.363,4.105l2.74,2.316l-6.57,9.95L286.631,618.464L286.631,618.464z"/>
|
||||
<g id="bs">
|
||||
<path d="M222.121,463.112l-1.089-0.337l-0.086,2.101l1.34,1.349l0.917-1.349L222.121,463.112L222.121,463.112z"/>
|
||||
<path d="M224.29,466.397l-1.504,0.838l1.417,2.021l0.752-1.011L224.29,466.397L224.29,466.397z"/>
|
||||
<path d="M229.14,467.91l-1.591-0.087l0.165,1.012l1.167,1.687l1.002-1.099L229.14,467.91L229.14,467.91z"/>
|
||||
<path d="M228.388,465.896l-2.593-1.099l-0.501-2.609l1.002-0.425l1.003,2.023l1.002,0.76L228.388,465.896L228.388,465.896z"/>
|
||||
<path d="M225.881,460.588l-1.34-0.337l-0.251-1.686l-1.417-0.501l0.917-0.926l1.668,0.588l1.253,0.762L225.881,460.588L225.881,460.588z"/>
|
||||
</g>
|
||||
<path id="bt" d="M616.108,453.561l1.34,1.833l4.528,0.034l-0.458-2.507L616.108,453.561L616.108,453.561z"/>
|
||||
<path id="bw" d="M454.56,594.589l1.858,0.57l-0.26,5.314l1.911,0.26l4.391-3.959l5.273,0.57l1.399-3.545l6.673-6.095l-8.013-9.223l-0.104-1.514l-0.883-0.26l-2.43,2.24l-6.31,0.154l-0.884,7.866l-2.479,0.57L454.56,594.589L454.56,594.589z"/>
|
||||
<path id="by" d="M456.418,382.861l1.297,2.135l-0.519,1.703l0.086,1.349l0.476,1.616l2.68-1.521l3.329,0.086l2.334,0.959h5.922l1.729-4.141l1.037-1.564v-1.045l-3.717-5.23l-3.285-1.305l-2.68-0.303l-2.335,0.743l0.088,2.351l-3.241,4.098L456.418,382.861L456.418,382.861z"/>
|
||||
<path id="bz" d="M191.823,483.228l-0.043,3.154h0.726l2.472-4.615h-1.677L191.823,483.228L191.823,483.228z"/>
|
||||
<g id="ca">
|
||||
<path class="mainland" d="M151.767,281.182l1.72,2.602l0.864,3.475l4.305,1.081l3.017-3.25l2.585,1.306l7.321,0.647l5.169-2.17l0.864,7.157h3.017v-3.034l3.017,0.216l7.538,8.895l4.953,3.035l-2.584,4.123l1.081,1.081l9.673,1.953l0.216,4.34l2.585,0.433l0.648-6.51l4.089-1.08l3.017,4.556l6.457,3.034l3.233,0.647l2.152-2.602l0.216-4.124l3.873-2.387l1.288,3.476l-3.449,6.077l0.432,3.033l1.937-3.033l3.873-3.475l0.216-4.556l-2.152-3.476l0.648-2.817l5.169-2.603l2.369,1.738l0.432,15.188l3.657-3.25l2.152,1.305l-3.017,5.204l3.873,0.865l5.602-8.679l4.737,4.986l-1.936,8.895l-4.737,2.603l-4.521-2.169l-8.177,1.736l0.864,2.818l-2.152,3.476l-6.673,1.521l-7.538,5.86l-6.673,8.896l-0.864,2.817l4.521,1.737l1.72,4.339l6.241,6.293l9.906,4.339l-2.152,9.975l-0.216,2.818l2.585,1.736l3.449-4.555l0.432-8.679l5.385-0.216l2.584-4.988l0.433-7.589l6.889-13.45l8.609,3.034l4.521,6.293l-1.937,6.292l3.449,1.954l8.394-5.646l2.368,15.404l7.754,9.327l0.216,4.771l-8.609,2.17l-4.088,4.338l-8.609-1.953l-4.305-0.217l-7.538,5.86l4.521-1.081l5.601-1.08l1.081,1.305l-1.504,4.771l0.216,4.34l2.585,1.736l2.584-0.648l1.296-1.953h1.72l-2.801,5.205l-5.385,0.215l-2.368,3.476h-3.017l-0.864-2.603l4.305-4.338l-5.169,1.737l-0.233-7.374l-1.487-0.863l-4.521,1.953l-0.432,3.691h-10.338l-8.832,6.08l-11.842,3.908l-1.288-1.738l5.964-8.902l-3.388-3.261l-2.152-4.132l-4.383-3.346l-4.702-0.389l-8.428-5.904l-61.122-10.043l-1.011-4.142l-5.602-5.204v-4.339l0.865-3.907l-0.433-2.168l-2.152-2.171l-0.432-3.475l5.602-3.908l-3.449-18.653l-4.737-0.217l-4.305-5.645L151.767,281.182L151.767,281.182z"/>
|
||||
<path d="M130.684,350.117l-1.47,2.818l0.51,1.996l0.959,0.598l-0.225,0.812l-1.029,0.294l0.294,2.965l1.106,1.115l0.882-0.959l-1.106-2.887l0.657-2.299l1.616-2.152l-1.175-1.997L130.684,350.117L130.684,350.117z"/>
|
||||
<path d="M135.542,367.008l-1.323,0.52l2.429,2.818l0.588,3.336l2.429,2.592l2.058-0.371v-3.406l-2.498-1.557L135.542,367.008L135.542,367.008z"/>
|
||||
<path d="M268.15,295.833l-1.53,1.547l1.34,2.126l6.328,0.77l-4.028-4.252L268.15,295.833L268.15,295.833z"/>
|
||||
<path d="M191.105,270.143l0.19,3.475l-6.898,7.148l1.729,5.791l4.979-1.348l2.878-4.253l7.278-2.706l5.938-0.389l-4.599-5.022l-2.299,1.737l-1.729-0.579l-0.959-2.126l-2.109-2.126L191.105,270.143L191.105,270.143z"/>
|
||||
<path d="M200.113,259.908l-1.53,2.706l7.477,2.706l2.68-4.055l1.15,2.707h1.919l3.639-4.055l-4.409-1.158l-1.729-1.348l-2.299,2.316L200.113,259.908L200.113,259.908z"/>
|
||||
<path d="M213.148,265.318l-5.938,2.508v1.928l7.667,2.896l-1.729,1.928l1.15,2.507l4.789-2.126h4.028l1.919,3.086l3.259-3.285l-0.77-3.095l-2.68,0.968l-0.38-3.863l1.34-2.316h-1.34l-2.109,1.349l-0.959,0.769l0.579,2.707l-1.53,1.158l-2.299-0.19l-0.579-3.476L213.148,265.318L213.148,265.318z"/>
|
||||
<path d="M221.005,259.329l-0.579,1.927l3.639,1.738l2.68-1.547l-0.19-1.158L221.005,259.329L221.005,259.329z"/>
|
||||
<path d="M223.875,256.044l-2.679,0.968l0.19,1.35l5.938-0.389l-0.19-1.349L223.875,256.044L223.875,256.044z"/>
|
||||
<path d="M236.72,259.329l-0.38,1.348l-0.959,1.349v1.929l3.639-0.579l3.83,3.285h1.34v-3.285l-3.83-4.253L236.72,259.329L236.72,259.329z"/>
|
||||
<path d="M246.497,263.191l1.53,1.738l-1.34,2.316l0.959,2.507l4.218-2.317v-1.736l-2.49-2.896L246.497,263.191L246.497,263.191z"/>
|
||||
<path d="M252.055,258.75l0.19,3.086h5.178l1.34,1.158l-0.19,1.348l-4.599,0.58l3.259,4.443l4.409,0.77l6.128-2.705l-8.817-13.33l-2.68,1.738l0.19,2.316l-3.069-1.158L252.055,258.75L252.055,258.75z"/>
|
||||
<path d="M207.399,280.576l-7.278,1.928l-4.219,3.673l0.38,4.054l7.667,2.317l-1.729,3.864l-5.558-3.477l-1.53,2.896l3.639,2.507l-0.19,4.054l5.558,1.547l6.708-0.389l1.149-2.126l4.979,5.601l3.449-1.157l0.579-3.864l2.49,1.737l0.38-3.864l-3.068-1.928l0.19-12.162l-2.68-2.126l-2.87,3.864L207.399,280.576L207.399,280.576z"/>
|
||||
<path d="M230.782,289.073l-2.489-1.158l-1.34,1.737l2.68,4.253l0.19,4.054l5.749-3.475v-5.022l2.109-2.126l-2.109-1.547h-3.449L230.782,289.073L230.782,289.073z"/>
|
||||
<path d="M243.048,287.335l-4.028,3.285l0.959,4.054h2.49l1.149-2.126l1.729,1.737l1.729-0.19l4.599-3.864L243.048,287.335L243.048,287.335z"/>
|
||||
<path d="M242.659,280.956l-0.959,1.928l4.218,1.548l1.15-1.738L242.659,280.956L242.659,280.956z"/>
|
||||
<path d="M240.169,273.617l-4.218,0.579l-2.49,2.315l4.599,0.19l-1.34,3.475l0.959,1.548l1.34-0.19l3.259-5.212L240.169,273.617L240.169,273.617z"/>
|
||||
<path d="M247.456,272.27l-2.299,0.77l0.38,3.086l3.83,2.507l0.19,1.927l-1.149,1.159l0.579,3.864l14.755,4.823l4.028,1.349l4.028-3.475l-4.789-3.864l-4.409,1.158l-6.128-0.579l-2.299-2.316l-0.579-6.371l-3.83-1.928L247.456,272.27L247.456,272.27z"/>
|
||||
<path d="M259.523,292.357l-4.218-0.39l-4.979,1.927l-2.68,3.666l0.769,10.043l8.238,0.39l7.857,3.864l5.559,6.371l4.218-0.19l-1.15,5.981l-3.829,6.371l-4.218,1.926l-3.069-0.578l-1.53-1.348l-2.299,3.086l0.959,3.086l3.259,0.189l4.028-1.928l3.449,8.886l8.626,5.603l5.938-7.529l-4.979-8.108l2.878-3.284l4.028,6.758l7.278-6.371l-1.339-2.896l-4.98,1.548l-3.448-9.466l3.259-5.403l-6.518-6.949l-3.639,2.507l-3.449-7.529l-7.277,0.968l-1.919-9.076l-5.938,4.055l-0.579,5.021h-3.259l0.38-4.443L259.523,292.357L259.523,292.357z"/>
|
||||
<path d="M262.021,274.006v1.738l-4.218,0.968l1.149,1.927l4.789,1.928l5.368,0.58l3.831,2.705l3.829-2.127l-2.681-2.705h3.449l2.109-2.316l5.178-0.77v-1.158l-2.878-1.928l0.379-2.127l8.048,1.348l11.886-4.633l-4.41-1.348l1.15-1.547h9.197l1.531-1.547l-18.593-6.569l-4.41-1.547l-4.789,3.476l-5.368-4.443l-2.878-0.19l-0.579,3.675l-3.638-3.285l-4.218,1.349l0.769,2.126l6.328,1.35l-0.38,3.086l3.449,2.125l8.438-2.125l0.19,2.896l-6.898,3.285l-4.218-3.285l-3.83,0.389l3.83,5.411l-1.919,0.969l-2.878-2.508l-2.109,1.348l1.919,3.666h3.259l-0.769,3.475l-2.68-0.389l-3.449-3.674L262.021,274.006L262.021,274.006z"/>
|
||||
<path d="M244.941,327.159l-3.657,4.599l-0.225,5.065l3.198-1.841h3.881l2.74,2.533l2.515-2.076L244.941,327.159L244.941,327.159z"/>
|
||||
<path d="M289.466,386.977l-9.136,8.748l0.916,2.074l11.186,4.141l1.599-2.758l-0.916-4.599l-3.657,0.458l-2.057-2.299l3.423-3.449L289.466,386.977L289.466,386.977z"/>
|
||||
</g>
|
||||
<path id="cd" d="M438.023,546.597l8.912-0.155l1.808,2.567l-0.069,1.893l0.665,0.605h4.426l1.271-2.499h1.808l0.734,0.742l2.479-0.069l0.735,8.714l4.287,0.139v0.675l11.521,5.195l0.536,1.012h2.411l-0.268-3.648l-4.356-2.092l0.269-2.766l1.876-4.392l4.287-0.139l-3.683-12.224l0.068-5.195l5.826-9.11l0.068-1.278l-0.874-0.477l0.035-2.472l-1.062-0.095l-1.072-1.366l-17.59-0.795l-3.226,3.138l-5.28-3.475l-1.858,1.141l-1.348,11.35l-3.337,2.575l-1.003,2.283l0.179,3.381l-6.017,4.918l-1.6-0.726l0.216,0.94L438.023,546.597L438.023,546.597z"/>
|
||||
<path id="cf" d="M443.452,519.229l4.028,4.356l1.59-2.057l2.532,0.104l0.544-2.006l2.49-1.556l5.169,3.562l2.982-2.956l11.574,0.51l-10.736-11.082l1.443-0.897l0.198-1.954l-2.438-1.149h-3.58l-5.766,5.715l-0.197,2.351l-4.573-0.146l-0.146,1.003l-2.982-0.305l-2.688,5.108L443.452,519.229L443.452,519.229z"/>
|
||||
<path id="cg" d="M439.424,526.551l-0.052,1.255l4.131,0.104l0.146,10.728l-3.777-0.104l-2.187-1.703l-1.693,0.951l-0.078,0.476l0.873,0.423l0.251,2.204l-2.334,2.006l0.501,1.057l2.585-2.006h1.244l0.396,1.2l1.644,0.7l5.271-4.46l-0.104-3.259l1.099-2.653l3.38-2.507l0.907-8.479l-2.402,0.009l-2.783,3.812L439.424,526.551L439.424,526.551z"/>
|
||||
<path id="ch" d="M423.787,402.82l-3.771,4.011l0.078,0.405l1.547-0.483l1.393,1.937l2.352-0.83l1.625,1.263l0.667-0.38l2.005-3.146l-0.511-0.484l-1.979-0.051l-0.959-1.963L423.787,402.82L423.787,402.82z"/>
|
||||
<path id="ci" d="M388.484,521.562l3.697-2.617l4.6-0.806l4.693,1.013l-2.395-3.622l-0.701-2.213l0.701-6.544l-4.192,0.198l-1.9-1.814l-3.994,0.104l-1.901,0.304l0.198,4.425l-1.002,0.406l-1.203,2.215l3.095,3.62L388.484,521.562L388.484,521.562z"/>
|
||||
<g id="cl">
|
||||
<path d="M261.391,683.51l-3.691,8.109l6.371,0.674l0.112-5.403L261.391,683.51L261.391,683.51z"/>
|
||||
<path d="M260.137,682.239l-2.775,3.068l-0.337,3.604l-5.368-3.043l-5.705-8.221l-1.677-2.932l2.351-3.043l-0.225-3.83l-2.68-1.124l-2.126-1.572l0.449-2.144l2.792-0.787l0.562-12.387l-4.356-2.48l-2.844-64.477l0.735-1.278l5.567,12.836l1.781,0.035l0.579,2.049l-2.369,2.868l-2.723,15.448l3.873,11.895l-1.79,9.007l6.31,26.485l0.666,15.49l4.521,5.229L260.137,682.239L260.137,682.239z"/>
|
||||
<path d="M241.717,649.833l-1.115,1.686l0.562,2.932l1.115,0.112l0.562-3.718L241.717,649.833L241.717,649.833z"/>
|
||||
</g>
|
||||
<path id="cm" d="M428.031,519.428l2.783,2.561l-0.199,3.959l15.266-0.354l1.245-1.399l-4.375-4.711l-0.648-1.703l2.784-5.212l-1.893-3.458l-1.591-0.855v-1.755l1.841-1.202l0.104-5.463l-1.46-0.164l-0.024,2.87l-6.414,11.972l-3.925,0.199l-2.688,1.85L428.031,519.428L428.031,519.428z"/>
|
||||
<g id="cn">
|
||||
<path class="mainland" d="M594.498,386.128l-2.99,7.521l-4.124-0.217l-4.349,9.518l3.691,4.701l-7.606,10.504l-3.907-0.658l-2.611,3.285l0.648,1.971l3.043,0.217l1.521,3.5l3.044,0.658l9.344,12.04v6.129l4.563,2.844l4.996-0.873l6.303,3.719l7.605,2.187l3.691-0.439l4.132-0.441l8.687-5.688l2.828,0.44l1.08,2.567l2.396,0.718l3.26,4.813l-2.17,4.814l1.306,3.285l3.69,1.312l0.647,3.942l4.35,0.439l0.647-1.971l6.302-3.285l3.907,0.217l4.563,5.03l3.043-1.312l1.954,0.216l0.873,2.412l1.521,0.216l2.169-3.06l8.688-3.285l7.823-9.413l2.61-8.974l-0.217-5.912l-3.259-0.656l1.953-2.188l-0.434-3.501l-8.255-8.314v-4.157l2.386-3.062l2.388-1.098l0.216-2.412h-6.085l-1.089,3.285l-2.828-0.656l-3.475-3.718l2.17-5.688l3.043-3.285l2.827,0.217l-0.434,5.031l1.521,1.313l3.691-3.717l1.306-0.216l-0.433-2.844l3.476-4.158l2.61,0.216l1.521-4.813l1.781-0.942l0.182-3l-1.729-1.815l-0.147-4.736l3.329-0.217l-0.216-12.214l-2.334,1.4L694.267,377l-3.897-0.009l-11.298-6.354l-8.16-9.837l-8.281-0.086l-2.107,1.833l2.68,6.137l-0.935,5.758l-3.335,1.383l-1.876-0.147l-0.139,5.696l1.954,0.441l3.476-1.53l4.563,2.187v2.188l-3.26,0.216l-2.611,5.688l-2.386,0.215l-8.472,11.16l-8.902,3.941l-6.086,0.441l-4.124-2.844l-5.869,3.068l-6.302-1.971l-1.521-4.158l-10.642-0.657l-5.646-9.188h-2.386l-1.92-4.262L594.498,386.128z"/>
|
||||
<path d="M671.802,472.655l-2.064,0.579l-1.487,1.833l1.236,2.411l1.814,0.163l2.066-1.832l0.492-2.411L671.802,472.655L671.802,472.655z"/>
|
||||
</g>
|
||||
<path id="co" d="M234.326,498.251l-1.781-0.182l-11.773,9.707l-1.245,3.414l-1.608,0.182l0.717,7.546l-4.105,10.07l4.46,3.776l5.714,0.363l3.924,5.757l5.705,0.183l-0.182,4.312h2.135l2.316-7.909l-2.144-2.694l0.536-5.031l4.46-0.363l-0.536-11.688l-9.993-3.232l-2.316-6.293L234.326,498.251L234.326,498.251z"/>
|
||||
<path id="cr" d="M202.905,502.745l1.202,2.352l0.977,1.297l-1.314,3.898l-2.507-1.764l-4.097-3.752v-2.48L202.905,502.745L202.905,502.745z"/>
|
||||
<path id="cu" d="M205.904,469.846v1.1l4.599,0.086l2.169-1.263l0.337,0.926l4.512,1.098l4.011,3.622l-0.917,1.262l0.165,1.436l3.345,0.839l3.345-1.514l1.504-1.513l-2.169-1.098l-11.194-6.569l-3.924-0.424L205.904,469.846L205.904,469.846z"/>
|
||||
<g id="cv">
|
||||
<path d="M350.01,490.264l-1.642,0.942l1.175,0.941l1.409-0.708L350.01,490.264L350.01,490.264z"/>
|
||||
<path d="M354.046,492.165l-1.071,0.951l0.762,1.409l1.832-0.821L354.046,492.165L354.046,492.165z"/>
|
||||
<path d="M351.704,494.836l-1.375,0.821l1.479,1.979l1.168-0.612L351.704,494.836L351.704,494.836z"/>
|
||||
</g>
|
||||
<path id="cy" d="M484.555,437.794l1.062,0.771l-3.294,3.119l-1.573-0.052l-1.167-0.821l0.156-1.529l2.386-0.155L484.555,437.794L484.555,437.794z"/>
|
||||
<path id="cz" d="M437.202,398.921h2.567h1.263l2.049,1.461l3.795-3.155l-3.683-2.627l-3.648-1.765l-2.498,0.45l-3.389,2.178L437.202,398.921L437.202,398.921z"/>
|
||||
<path id="de" d="M422.257,384.234l3.086-0.5v-2.178l2.584-0.425l1.418,1.427l1.495,0.164l2.334-1.012l2.083,0.588l1.832,1.592l0.251,5.955l1.832,2.438l-2.411,0.337l-4.003,2.515l0.338,0.839l3.579,3.354l-0.251,1.677l-3.328,1.677l-3.085,0.086l-0.753,1.59h-1.581l-0.752-1.676l-2.749-0.675l-0.088-2.767l-1.39-0.77l0.114-1.861l-0.406-1.328l-1.982-1.824l0.414-2.854l2.161-1.011L422.257,384.234L422.257,384.234z"/>
|
||||
<g id="dk">
|
||||
<path d="M427.123,370.076l-3.586,3.968l-0.13,2.584l1.635,4.263l2.559-0.484l-0.319-3.483l1.764-1.971l-0.034-1.548l-1.245-3.223L427.123,370.076L427.123,370.076z"/>
|
||||
<path d="M428.979,377.354l-1.062,0.229v1.583l1.128,0.875l0.997-0.25l-0.243-1.503L428.979,377.354z"/>
|
||||
<path d="M432.306,375.848l-0.949,0.23l-1.056,0.968l0.448,1.954l1.292,0.507l-1.334,0.535l-0.255,0.685h2.005l0.602-1.099l-0.768-0.378l0.25-0.962l0.916-1.205l-0.25-1.042L432.306,375.848z"/>
|
||||
</g>
|
||||
<path id="dj" d="M508.991,499.771l-0.493,2.903l3.424-0.052l0.052-4.271l-1.253-0.77L508.991,499.771L508.991,499.771z"/>
|
||||
<path id="dm" d="M256.23,485.371l-0.761,1.616l0.917,1.228l1.141-0.994L256.23,485.371L256.23,485.371z"/>
|
||||
<path id="do" d="M242.434,481.533l-4.573-2.991l-2.887-1.021l-0.578,5.521l0.578-0.047l0.761,1.461l0.994-1.15l2.896-0.77l2.516,0.536L242.434,481.533L242.434,481.533z"/>
|
||||
<path id="dz" d="M424.625,435.764l-3.526-1.186l-14.677,2.758l-3.198,2.429l1.953,10.088l-5.835,0.233l-3.509,5.646l-8.359,2.005l0.025,4.105l27.532,21.048l4.692,0.398l15.654-12.231l-1.565-1.971l-2.938-0.398l-1.764-2.955V453.5l-1.177-1.184l0.199-3.155l-3.13-3.155l-0.389-3.354l1.366-0.986l-0.59-3.553L424.625,435.764L424.625,435.764z"/>
|
||||
<g id="ec">
|
||||
<path class="mainland" d="M213.986,529.43l-4.088,2.541l-0.294,3.771l-0.821,1.234l2.576,2.473l-1.115,1.219l0.259,3.113l4.607,1.098l6.976-8.256l-0.017-2.878l-3.345-0.216L213.986,529.43L213.986,529.43z"/>
|
||||
<path d="M183.533,531.443l-0.536,2.378l-0.994,1.002l0.683,1.228l1.754-0.69l0.838-1.461l-0.536-1.537L183.533,531.443L183.533,531.443z"/>
|
||||
</g>
|
||||
<g id="ee">
|
||||
<path class="mainland" d="M462.562,363.299l-4.84-0.173l-3.068,1.875l-0.043,1.392l1.987,1.875l6.182,1.047L462.562,363.299L462.562,363.299z"/>
|
||||
<path d="M452.236,364.042l-1.308,0.44l1.308,0.226l0.595,0.69l0.711-0.852l-0.709-1.215L452.236,364.042z"/>
|
||||
<path d="M452.792,365.792l-1.862,0.833l-0.643,1.111l0.643,0.722l2.362-0.875l1.137-0.752L452.792,365.792z"/>
|
||||
</g>
|
||||
<path id="eg" d="M466.16,449.222l2.308,0.062l4.495,1.244l2.135,0.061l2.646-2.213h1.234l2.249,1.245h2.845l0.51-0.035l1.798,5.17l0.51,1.668l0.477,2.498l-0.85,0.622l-1.461-0.734l-1.686-5.498l-1.521-0.111l-0.111,1.867l1.012,3.232l8.1,10.027l0.173,4.305l-2.359,2.723l-22.163-0.251L466.16,449.222L466.16,449.222z"/>
|
||||
<path id="er" d="M496.224,493.859l-0.216-5.093l3.423-3.992l0.926,0.709l1.686,5.637l8.091,6.023l-1.47,1.808l-5.922-5.091L496.224,493.859L496.224,493.859z"/>
|
||||
<g id="es">
|
||||
<path class="mainland" d="M402.565,416.322h-11.014l-2.222-1.004l-1.071,0.078l-1.297,2.696l0.458,2.775l4.21,0.389l0.536,1.771l-1.833,10.33l0.078,1.851l2.981,1.617l3.439,0.232l6.881-1.694l3.363-4.233l0.077-4.313l5.965-5.395l0.302-2.386l-5.428-0.078L402.565,416.322L402.565,416.322z"/>
|
||||
<path d="M374.265,458.444l-1.513,0.873l0.7,0.709L374.265,458.444L374.265,458.444z"/>
|
||||
<path d="M369.009,458.608l-1.875,0.476l0.935,1.418h1.407L369.009,458.608L369.009,458.608z"/>
|
||||
<path d="M364.549,457.191l-1.176,1.184l1.643,1.418l0.935-2.126L364.549,457.191L364.549,457.191z"/>
|
||||
<path d="M413.578,426.877l-1.375,0.467l0.304,1.235h1.988l0.839-0.925L413.578,426.877L413.578,426.877z"/>
|
||||
</g>
|
||||
<path id="et" d="M489.982,508.606l6.292-14.005l6.25,0.035l5.541,4.814l-0.39,3.968h4.296l0.44,2.386l6.95,4.157l4.286,0.217l-8.15,8.756l-11.194,3.449h-2.773l-4.944-4.219l-1.953-0.82l-3.786-5.576l-2.499,0.035l-0.294-2.559L489.982,508.606L489.982,508.606z"/>
|
||||
<path id="fi" d="M453.072,340.202l1.79,0.786l1.104,2.074l-1.104,1.436l-5.55,6.068l-0.952,3.199l1.271,4.633l4.278,3.199l5.706-2.715l4.598-0.64l4.279-6.872l-3.173-7.512l-3.018-7.192l0.477-4.633l-1.901-0.32l-0.492-3.38l-2.559-4.175l-2.836,1.962l-1.114,4.556l-3.008-1.807l-4.185-1.021l-0.934,1.09l1.607,1.453l2.931-0.053l2.359,3.812L453.072,340.202L453.072,340.202z"/>
|
||||
<g id="fk">
|
||||
<path d="M281.194,678.393l-2.273-0.25l-2.265,1.521l1.642,1.781L281.194,678.393L281.194,678.393z"/>
|
||||
<path d="M283.459,677.252l-0.752,2.411l-2.144,1.901l0.13,0.631l3.655-1.4l1.513-1.901L283.459,677.252L283.459,677.252z"/>
|
||||
</g>
|
||||
<g id="fr">
|
||||
<path class="mainland" d="M412.973,393.588l-1.91,0.467l-3.82,4.158l-1.149,0.078l-1.53-1.081l-0.993,0.233l-0.762,2.386l-5.584,0.155l0.156,1.236l3.82,2.543l4.435,3.543l-0.077,4.236l-2.368,4.157l5.126,2.464l5.204,0.154l1.606-1.85l3.286,0.078l0.916,0.848l3.285-0.233l1.686-2.162l-2.145-2.541l-0.155-1.616l0.458-1.771l-1.071-1.539l-1.833,0.535l-0.232-1.383l4.054-4.469v-2.697l-2.348-0.767l-1.432-0.987L412.973,393.588L412.973,393.588z"/>
|
||||
<path d="M276.163,517.285l5.058,3.154l-2.646,5.255l-0.959,1.21l-2.809-1.615l0.079-5.663L276.163,517.285L276.163,517.285z"/>
|
||||
<path d="M540.023,586.93l-1.972,0.129l-0.129,1.722l1.313,0.269l1.972-0.925L540.023,586.93L540.023,586.93z"/>
|
||||
<path d="M516.857,562.666l0.656,1.461h1.055l0.526-1.857L516.857,562.666L516.857,562.666z"/>
|
||||
<path d="M258.823,489.822l-0.917,0.847l0.683,1.383l1.296-0.38L258.823,489.822L258.823,489.822z"/>
|
||||
<path d="M428.039,418.016l-1.687,1.695l-0.154,1.538l1.374,0.847l0.536-0.076l0.303-2.24L428.039,418.016L428.039,418.016z"/>
|
||||
<path d="M254.095,484.065l-1.296,0.535l0.458,1.149l1.521-0.994l-0.303-0.311L254.095,484.065L254.095,484.065z"/>
|
||||
</g>
|
||||
<path id="ga" d="M435.438,526.646l-0.104,2.151l-4.875-0.104l-2.982,5.766l7.012,7.667l1.736-1.453l-0.052-1.503l-1.192-0.554v-1.057l2.688-1.701l2.387,1.807l2.638,0.052l-0.054-9.067l-4.176-0.196l-0.052-1.903L435.438,526.646L435.438,526.646z"/>
|
||||
<g id="gb">
|
||||
<path class="mainland" d="M400.629,367.984l-1.582,2.395l0.631,0.959h3.648v1.6l-0.952,1.278l0.632,3.354l2.058,3.994l1.581,3.672l2.533,0.959l1.105,1.92l-0.155,1.755l-1.582,0.959l-0.156,0.795l1.106,0.64l-0.951,1.279l-2.221,0.959l-4.279-0.477l-6.664,3.035l-2.221-1.115l6.345-3.674l-0.796-0.477l-3.328-0.32l2.058-3.033l0.319-2.56l2.696-0.319l-0.475-4.953l-3.174-0.155l-0.95-1.115l0.155-3.674l-1.901,0.156l1.901-6.388l3.492-2.56L400.629,367.984L400.629,367.984z"/>
|
||||
<path d="M393.974,378.693l-2.853,0.32l-0.156,2.56l1.901,1.278l2.058-0.475l0.796-1.436L393.974,378.693L393.974,378.693z"/>
|
||||
</g>
|
||||
<path id="ge" d="M495.144,415.596l2.827,3.691l3.527,1.625l2.169-0.01l3.726-1.01l0.935-1.461l-11.021-4.123L495.144,415.596L495.144,415.596z"/>
|
||||
<path id="gh" d="M399.091,513.179l0.968,2.273l2.524,3.959l1.4-0.052l3.819-2.171l-0.268-12.354l-2.957-0.864l-4.141,0.112L399.091,513.179L399.091,513.179z"/>
|
||||
<g id="gl">
|
||||
<path class="mainland" d="M292.587,282.398l-1.176,1.877l2.118,2.117l-0.942,2.118l3.06,3.994l3.761-1.176l4.936-0.466l5.644,6.111l3.761,10.104l-3.051,6.345l4.227-0.708l2.354,1.409l0.232,3.06l-5.168,0.233l2.818,2.817l3.526,0.709l-7.754,10.339l-0.942,6.345l1.643,5.169l-1.176,3.06l2.118,6.578l3.993,4.469l1.176-0.232l2.584-0.709l0.233,3.76l1.642,2.352l3.052-0.234l2.353-8.695l7.053-8.697l10.581-4.227l6.578-8.229l3.05,1.408h6.345l5.17-5.168l6.346-2.584l0.708-3.994l-3.993-3.527l-3.526-1.175l-1.885-4.937l4.469-2.584l7.054,3.76l2.353-2.584l-3.761-2.117l7.996-10.813l-1.409-4.702l-3.761-0.232l1.408-4.228l4.703-2.118l9.637-8.461l-2.816-3.053l-10.814,0.941l-5.645,5.646l3.295-7.287l-3.762-0.942l-2.117,3.761l-3.051-2.585l-8.464,0.941l2.353-3.76l13.865-0.467l-3.527-4.702l-15.04-2.817l-6.11,0.941l0.233,3.061l-6.345-2.118l0.232-2.118l-4.47,0.941l-0.942,2.353l4.703,1.642l-4.936,3.527l-3.527-3.994l-4.936-1.408l-0.709,3.76h-4.937l-1.885-3.994l-7.754-1.176l-4.228,2.117l-0.233,2.818l-5.402-0.709l-3.294,1.409l0.234,3.293v1.644l-6.111,1.176l-2.818-1.877l-1.885,3.052l2.819,3.06l5.878-0.709l0.467,1.885l-4.469,2.118L292.587,282.398L292.587,282.398z"/>
|
||||
<path d="M311.396,319.066l1.409,2.119l-0.708,2.584h-1.409l-1.885-2.119l0.467-1.643L311.396,319.066L311.396,319.066z"/>
|
||||
<path d="M370.159,313.189l3.993,1.176l-0.234,3.293l-4.227-2.118l-0.942-1.176L370.159,313.189L370.159,313.189z"/>
|
||||
</g>
|
||||
<path id="gm" d="M366.719,497.006l-0.111,0.959l5.98-0.086l0.304-0.891l-0.13-0.898l-1.721,0.7L366.719,497.006L366.719,497.006z"/>
|
||||
<path id="gn" d="M369.77,505.304l2.629,4.046l3.423-2.974l3.511-0.155l2.922,3.881l2.48,1.635l0.933-1.816l0.83-0.466l-0.061-3.993l-1.65-4.737l-5.065,0.562l-6.267-0.501l-0.034,1.606L369.77,505.304L369.77,505.304z"/>
|
||||
<g id="gq">
|
||||
<path d="M427.184,522.134l-0.396,1.703l1.191,0.648l1.143-0.855l-0.397-1.755L427.184,522.134L427.184,522.134z"/>
|
||||
<path d="M430.771,526.697l-0.054,1.202l3.924,0.198l-0.052-1.356L430.771,526.697L430.771,526.697z"/>
|
||||
</g>
|
||||
<g id="gr">
|
||||
<path class="mainland" d="M453.004,427.213l-0.096,1.15l4.003,2.014l1.911,0.734l-1.003,1.055l-2.23,0.227l-0.319,1.01l0.771,1.738l2.498,1.33l1.09,0.096l0.138-2.981l1.635-1.972l-4.46-5.272l0.589-1.789l1.046-0.043l1.591,1.279l1.002-0.501l0.319-1.79l3.732,0.534l1.134-3.239l-1.953,1.375l-5.731-0.14l-3.726,1.929L453.004,427.213L453.004,427.213z"/>
|
||||
<path d="M461.69,438.442l1.408,0.043l0.589,0.873h2.05l1.363-0.501l0.459,0.553l-0.907,1.192l-4.002,0.139l-0.728-0.959l-0.77-0.458L461.69,438.442L461.69,438.442z"/>
|
||||
</g>
|
||||
<path id="gt" d="M183.456,491.11l5.126,3.752l5.169-6.423l-0.881-1.331l-1.764-0.062v-3.761l-1.322-0.804l-4.002,1.192l1.53,3.526L183.456,491.11L183.456,491.11z"/>
|
||||
<path id="gw" d="M368.196,502.356l1.211,2.395l3.396-2.922l0.035-0.899l-4.003-0.579L368.196,502.356L368.196,502.356z"/>
|
||||
<path id="gy" d="M261.399,510.654l6.241,5.652l-2.481,2.87l-0.199,1.703l3.259,3.362l-0.078,3.232l-5.67,2.16l-3.397-4.59l0.726-5.515l-1.452-4.105L261.399,510.654L261.399,510.654z"/>
|
||||
<path id="hn" d="M194.408,488.742l7.987-0.303l2.369,2.817l-1.478-0.338l-2.844,0.121l-3.717,3.492l-1.59,3.536l-1.046-0.555l-0.009-3.872l-2.299-1.539L194.408,488.742L194.408,488.742z"/>
|
||||
<path id="hr" d="M443.417,407.816l-3.051,2.515h-3.095l-0.372,2.178l1.418,0.372l0.709-1.055l1.114,0.977l0.891,3.112l6.109,2.853l0.605-0.691l-6.197-6.396l0.631-1.166l5.888-0.226l0.596-1.876l-3.838,0.111L443.417,407.816L443.417,407.816z"/>
|
||||
<path id="ht" d="M231.845,477.159l2.974,0.311l-0.354,3.648l-0.294,1.919l-3.466-0.19l-0.614,0.926l-1.063-0.077l-0.38-1.997l3.656-0.304l-0.225-2.073l-1.677-0.691L231.845,477.159L231.845,477.159z"/>
|
||||
<path id="hu" d="M444.386,403.01l-1.003,1.573l0.078,2.403l1.599,0.82l4.919,0.147l6.854-5.774l0.034-1.279l-0.742-0.371l-4.953,2.248L444.386,403.01L444.386,403.01z"/>
|
||||
<g id="id">
|
||||
<path d="M639.517,513.628l-0.241,1.971l5.869,9.862h1.711l12.23,20.461l4.894,0.492l2.445-7.148l-3.916-2.464l-0.734-3.941L639.517,513.628L639.517,513.628z"/>
|
||||
<path d="M697.475,540.891l1.954,2.396l-1.271,3.596v0.684h2.887l1.021-8.989l0.934,0.26l1.694,8.212l1.615,0.434l1.53-3.512l-1.53-5.308l-1.271-2.31l3.993-2.911l-0.935-1.288l-3.819,2.48h-1.021l-1.866-2.74l0.597-1.201l3.146-1.539l4.754,1.453l1.444-0.089l3.568-3.335l-1.442-1.451l-3.312,2.565h-2.127l-3.224-1.538l-2.29,0.086l-2.55,4.106l-1.616,7.105L697.475,540.891L697.475,540.891z"/>
|
||||
<path d="M718.791,524.805l-1.616,3.935l2.549,3.337h0.849l1.105-2.223l0.597-0.77l-1.105-1.201l-1.617-0.597L718.791,524.805L718.791,524.805z"/>
|
||||
<path d="M723.805,537.729l-3.482,0.77l-1.021,1.115l0.847,1.453l2.291-0.855l1.442-0.855l2.126,1.712l0.935-0.771l-1.693-2.057L723.805,537.729L723.805,537.729z"/>
|
||||
<path d="M666.045,548.854l-2.377,1.625l0.51,1.364l7.564,1.712l3.82,0.684l1.615,1.712l4.331,0.345l2.04,1.711l1.867-0.432l1.702-1.539l-3.146-1.452l-2.715-2.308l-7.053-1.713L666.045,548.854L666.045,548.854z"/>
|
||||
<path d="M690.768,556.295l-1.865,1.029l1.104,1.201l2.715-1.027L690.768,556.295L690.768,556.295z"/>
|
||||
<path d="M693.991,555.526l0.338,1.625l1.954,0.51l0.761-0.942l-0.848-1.288L693.991,555.526L693.991,555.526z"/>
|
||||
<path d="M698.668,559.805l-2.377,0.347l2.126,1.798h1.694L698.668,559.805L698.668,559.805z"/>
|
||||
<path d="M699.342,556.979l-0.511,1.027l3.821,0.596l2.974-1.711l-1.694-0.511l-2.715,0.771l-1.021-0.856L699.342,556.979L699.342,556.979z"/>
|
||||
<path d="M711.833,557.583l-4.416,3.683l0.423,0.942l1.866-0.345l2.205-2.059l4.331-0.597l-0.848-1.452L711.833,557.583L711.833,557.583z"/>
|
||||
<path d="M734.126,532.446l-3.604,0.406l-2.315,1.693l0.959,1.938l3.925,0.726v0.727l-2.48,2.015l1.201,4.192l1.201,0.078l1.037-4.115h1.919l0.806,4.027l9.361,7.746l0.241,6.051l3.198,3.467l1.442-0.077l0.319-21.369l-5.438-3.785l-5.125,3.467l-1.843,1.132l-3.043-1.937l-0.078-6.128L734.126,532.446L734.126,532.446z"/>
|
||||
<path d="M690.689,519.532l-1.997,7.503l-10.831,3.656l-3.241-3.804l-1.573,0.433l2.939,11.34l4.398,0.493l5.869,2.222v2.222l2.688-0.493l3.916-5.42v-4.435l2.204-4.435l2.445,0.491l-2.938-6.163l-0.449-3.968L690.689,519.532L690.689,519.532z"/>
|
||||
</g>
|
||||
<path id="ie" d="M394.915,383.085l-0.786,5.187l-6.975,2.56h-2.223l-1.582-1.115v-0.96l3.492-2.238l-0.951-1.92l0.156-2.714l3.018,0.155l1.383-3.25l-0.183,2.887l2.344,1.858L394.915,383.085L394.915,383.085z"/>
|
||||
<path id="il" d="M486.378,444.899l-1.365,4.348l1.771,5.213l2.031-7.616v-1.633L486.378,444.899L486.378,444.899z"/>
|
||||
<path id="in" d="M595,509.688l3.958-1.938l2.352-8.505l-0.104-10.441l13.468-14.54v-3.447l2.774-1.081l-0.104-3.984l-2.991-5.817l1.712-3.121l3.742,3.449l4.808,0.216v1.938l-1.495,1.616l0.318,0.863l2.567,0.104l0.536,2.904h0.752l1.928-3.449l0.958-9.042l3.207-2.265l0.104-3.12l-1.279-2.481l-2.031-0.104l-7.951,5.256l0.5,3.38l-5.584-0.019l-1.97-2.41l-1.072,0.138l0.363,3.354l-12.075-0.863l-7.485-3.337l-0.397-4.106l-4.988-3.094l-0.06-6.371l-3.423-3.916l-7.867,0.752l0.855,3.424l3.854,3.12l-6.665,13.641l-4.46,0.337l-0.734,1.644l4.392,4.062l-0.216,4.105l-4.486-0.069l-0.484,2.04l3.727-0.164l0.104,1.616l-2.671,1.4l1.711,3.232l3.312,1.08l2.031-1.504l0.959-2.688l1.177-0.535l1.392,1.398l-0.424,3.449l-0.96,1.617l0.217,2.8L595,509.688L595,509.688z"/>
|
||||
<path id="iq" d="M502.793,433.637l-1.348,6.664l-5.585,4.65l0.354,2.195l5.455,0.371l8.688,7.07l4.857-0.138l0.13-1.634l1.78-1.91l2.49,1.409l0.329-0.312l-4.815-6.405l-2.282-0.139l-3.033-3.898l0.604-2.868l0.926-0.121l0.319-1.271l-4.132-4.348L502.793,433.637L502.793,433.637z"/>
|
||||
<path id="ir" d="M507.409,427.516l-1.057,1.098l0.104,1.738l1.314,1.842l4.658,5.101l-0.709,2.04h-0.812l-0.406,2.04l2.637,3.371l2.43,0.207l4.865,6.732l2.732,0.208l2.126,1.529l0.104,3.062l8.411,4.9h3.139l1.927-1.634l2.43-0.104l1.418,3.268l9.085,1.262l0.27-3.337l3.007-1.089l0.139-1.193l-2.395-3.268l-5.334-4.287l2.801-2.55l-0.198-1.124l-3.511-0.544l-1.486-11.843l-0.173-2.723l-9.518-3.641l-4.218,0.951l-2.36,2.896l-2.092-0.139l-0.604,0.511l-4.66-0.303l-5.878-4.288l-2.188-2.394l-1.003,0.24l-1.808,2.066L507.409,427.516L507.409,427.516z"/>
|
||||
<path id="is" d="M366.261,340.521l-1.693-0.959l-2.283,1.443l-1.962,1.814l0.052,1.013l2.541,0.319l-0.156,1.815l-0.898,0.908l0.217,0.588l2.541,0.164v2.938l3.656,0.641l2.17,1.229l2.437,0.104l4.186-2.083l3.231-4.271l0.053-2.887l-1.963-1.66l-1.642-1.392l-0.743,0.536l-1.115,1.443l-1.271-0.164l-1.271-1.393l-1.642,0.156l-2.386,1.979l-1.437,1.547l-0.795-0.69l-0.053-1.712l0.795-0.536L366.261,340.521L366.261,340.521z"/>
|
||||
<g id="it">
|
||||
<path class="mainland" d="M423.233,409.391l-0.535,1.356l0.146,1.478l2.065,2.412l3.25-0.113l7.175,8.334l4.479,1.297l2.646,2.498l0.631,5.695l1.417-0.828l1.229-3.104l-0.303-2.229l2.101-0.19l0.304-1.263l-5.922-2.834l-5.619-5.523l-2.238-3.303l-0.545-3.137l2.861-0.684l-0.734-2.066l-1.755-1.478l-1.513-0.069l-2.108,0.58l-1.99,2.781l-1.201,0.795l-1.858-1.141L423.233,409.391L423.233,409.391z"/>
|
||||
<path d="M440.668,431.898l-1.253-0.674l-4.278,0.674l0.146,1.158l3.847,1.937l0.579,0.631l1.012,0.147L440.668,431.898L440.668,431.898z"/>
|
||||
<path d="M427.806,423.566l-2.289,1.158l0.303,4.469l1.833,0.311l1.374-1.312v-4.235L427.806,423.566L427.806,423.566z"/>
|
||||
</g>
|
||||
<path id="jm" d="M221.533,480.798l-3.008,0.761v0.84l1.755,1.011h1.841l1.167-1.349L221.533,480.798L221.533,480.798z"/>
|
||||
<path id="jo" d="M489.473,447.251l-2.126,7.416l-0.096,1.133h3.346l3.743-3.303l0.094-1.253l-1.529-1.564l2.739-2.272l-0.396-2.109l-0.752,0.173l-2.282,1.635L489.473,447.251L489.473,447.251z"/>
|
||||
<g id="jp">
|
||||
<path d="M709.317,426.193l-1.41,1.418l0.579,1.996l1.236,0.086l0.83,4.332l0.993,1.08l1.738-1.582l0.151-4.773l-2-2.125L709.317,426.193L709.317,426.193z"/>
|
||||
<path d="M716.688,422.188l-2.659,2.156l-0.591,2.719l1.812,1.25l2.625-2.75l0.37-3.062L716.688,422.188z"/>
|
||||
<path d="M713.613,418.033l-4.219,4.832v2.322l2.604-0.312l4.085-3.592l2.731-0.502l0.663,0.779l0.015,2.377l0.688,1.25h1.255l1.763-2.158l0.743-2.836l3.552-0.086l3.476-4.166l-1.814-6.916l-0.83-3.664l1.815-1.496l-4.133-6.241l-0.944-0.744l-1.875,0.744l-0.481,2.584v2.083l0.994,1.167l0.328,5.498l-2.56,3.164l-1.485-0.917l-1.159,2.584l-0.251,2.412l0.909,1.418l-0.579,1.08l-1.902-1.582h-1.322l-1.157,0.666L713.613,418.033L713.613,418.033z"/>
|
||||
<path d="M720.729,380.396l-1.321,1.168l0.665,2.498l1.158,1.166l-0.086,3.83l-1.487,0.578l-1.158,2.584l3.388,4.659l2.23-0.752l0.415-1.167l-2.396-2.161l1.487-1.919l1.572,0.25l3.43,2.305l0.37-2.584l1.63-2.979l2.281-2.312l-2.469-1.125l-0.944-1.801l-1.236,0.83l-1.071,1.331l-2.316-0.501l-2.396-1.582L720.729,380.396L720.729,380.396z"/>
|
||||
<path d="M733.201,377.812l-2.317,3.25l0.164,1.582l1.158-0.502l2.723-3.414L733.201,377.812L733.201,377.812z"/>
|
||||
<path d="M736.261,373.066l-0.829,2.248l0.086,1.496l1.409-0.918l1.322-2.662v-0.994L736.261,373.066L736.261,373.066z"/>
|
||||
</g>
|
||||
<path id="ke" d="M491.142,521.365l2.3,4.484l-2.759,5.783l-0.361,1.754l13.77,8.516l4.271-6.708l-2.161-1.754l-0.043-8.835l2.705-2.956l-4.313,1.435l-3.259,0.044l-5.1-4.305l-1.608-0.692l-2.98,0.276l-0.526,0.883L491.142,521.365L491.142,521.365z"/>
|
||||
<path id="kg" d="M565.463,411.316l-0.268,2.188l0.216,1.35l7.521,2.523l-6.604,2.662l-0.751-0.623l-1.427,0.917l0.068,0.501l0.761,0.346l4.635,0.121l2.351-0.709l3.018-3.803l3.776,0.656l4.557-6.311l-12.188-1.66l-1.686,4.088l-2.127-2.281L565.463,411.316L565.463,411.316z"/>
|
||||
<path id="kh" d="M655.076,497.982l3.535,3.776l6.576-4.875l0.579-7.692l-3.396,2.343l-1.764-0.985l-2.396-0.32l-1.341-0.941l-0.648,0.035l-1.754,2.878l0.285,1.332l1.781,0.994l-0.216,2.705L655.076,497.982L655.076,497.982z"/>
|
||||
<path id="km" d="M514.359,560.013l0.396,1.321l1.711,0.269l0.656-1.72L514.359,560.013L514.359,560.013z"/>
|
||||
<path id="kp" d="M687.751,407.047l1.59,0.666l0.484,5.566l3.155,0.183l2.974-3.483l-1.029-0.916l0.121-3.734l2.731-3.303l-1.392-2.506l0.908-1.039l0.501-2.592l-1.582-0.719l-1.35,0.684l-1.668,5.064l-2.697-0.232l-3.12,3.682L687.751,407.047L687.751,407.047z"/>
|
||||
<path id="kr" d="M696.446,410.443l5.342,4.356l0.909,4.22l-0.183,2.264l-2.61,2.939l-2.248,0.12l-2.551-5.506l-0.968-2.629l1.028-0.795l-0.242-1.099l-1.271-0.569L696.446,410.443L696.446,410.443z"/>
|
||||
<path id="kw" d="M519.2,452.774l-1.945-1.056l-1.349,1.356l0.146,2.715l3.139,1.201L519.2,452.774L519.2,452.774z"/>
|
||||
<path id="kz" d="M513.495,402.163l3.544-1.513l3.959-0.139l0.276,6.051h-2.317l-1.772,2.888l2.317,3.847l3.414,1.928l0.311,2.205l1.255-0.416l1.157-1.375l1.91,0.416l0.96,1.928h2.455v-2.473l-1.504-4.4l-0.684-3.569l4.364-1.929l5.869,0.959l3.684,3.709l8.323-0.821l4.644,6.597l5.455,0.274l1.504-2.472l1.91-0.416l0.274-2.748l2.862-0.139l1.503,1.789l1.505-3.57l12.957,1.789l2.179-2.887l-3.683-4.537l4.91-10.719l3.958,0.275l2.731-6.594l-5.455-0.554l-3.138-3.024l-8.644,1.002l-11.134-10.762l-3.926,3.482l-11.902-5.402l-14.601,7.148l-0.405,5.083l3.413,3.985l-6.655,3.76l-8.636-0.19l-1.807-2.653l-6.769-0.373l-6.414,4.123l-0.14,5.638L513.495,402.163L513.495,402.163z"/>
|
||||
<path id="la" d="M650.745,466.397l-2.092,1.062l-1.737,5.065l2.904,3.699l-0.485,4.09l0.485,0.196l4.832-2.342l6.482,7.243l-0.157,4.563l1.41,0.762l3.482-2.827l-0.285-2.238l-10.053-9.552l0.095-1.461l1.254-0.873l-0.874-2.438l-4.158-0.684L650.745,466.397L650.745,466.397z"/>
|
||||
<path id="lb" d="M487.139,440.041l0.053,1.686l-0.708,2.56l2.438,0.208l0.156-3.631L487.139,440.041L487.139,440.041z"/>
|
||||
<path id="lc" d="M258.746,493.28l-0.614,1.306l0.994,1.071l1.296-0.691L258.746,493.28L258.746,493.28z"/>
|
||||
<path id="lk" d="M603.264,505.399l0.217,2.351l0.216,1.712l-1.271,0.216l0.64,3.848l1.909,1.07l2.966-1.711l-0.847-4.054l0.216-1.495l-2.756-2.559L603.264,505.399L603.264,505.399z"/>
|
||||
<path id="lr" d="M378.198,515.027l9.491,6.345l-0.227-4.805l-2.869-3.38l-2.801-2.481L378.198,515.027L378.198,515.027z"/>
|
||||
<path id="ls" d="M470.896,606.829l2.637-2.032l1.245,0.053l1.503,1.875l-0.155,1.877l-2.533,0.934v0.728l-2.792-0.156l-0.674-2.031L470.896,606.829L470.896,606.829z"/>
|
||||
<path id="lt" d="M452.14,375.236l-2.146,0.363l0.173,2.025l3.355,0.25l1.27,1.042l0.333,1.81l1.034,1.443l3.069-0.13l2.938-3.743l-0.172-2.222l-5.533-0.867L452.14,375.236z"/>
|
||||
<path id="lu" d="M420.424,397.582l0.76,0.679l0.88,0.083l0.194-1.734l-0.253-0.974l-1.224,0.583L420.424,397.582z"/>
|
||||
<path id="lv" d="M462.823,369.964l-6.362-1.037l-1.086,2.823l-1.833,0.548l-0.959-1.173l-0.962-1.811l-1.038,0.762l-0.589,3.132v1.708l2.242-0.375l4.665,0.083l5.617,1.044l2.249-0.657l-0.13-2.524L462.823,369.964L462.823,369.964z"/>
|
||||
<path id="ly" d="M429.958,453.518l1.35-0.225l0.397-3.112h0.674l2.758-4.528l6.804,1.979l1.856,2.887l6.69,3.062l3.482-1.47l-0.338-1.471l-1.521-1.469l0.173-1.021l2.473-2.093h4.894l1.856,2.489l3.934,0.57l0.511,31.889l-2.922-0.112l-17.651-9.181l-1.91,1.081l-7.253-1.814l-1.971-2.603l-2.87-0.397l-1.458-2.603L429.958,453.518L429.958,453.518z"/>
|
||||
<path id="ma" d="M402.505,439.903h-9.982l-1.954,4.339l-4.504,2.17l-3.719,10.062l-7.243,4.341l-10.174,16.761l9.983-0.199l0.389-4.927h2.541v-6.708h8.81l0.196-8.679l8.42-1.972l3.526-5.723l5.48-0.198L402.505,439.903L402.505,439.903z"/>
|
||||
<path id="md" d="M465.14,401.376l2.681,4.123l-0.226,2.334l0.96,0.043l2.272-3.847l-2.731-3.389L466.549,400L465.14,401.376L465.14,401.376z"/>
|
||||
<path id="me" d="M449.68,416.677l-1.266,1.789l0.362,1.099l1.504,0.275l1.184-1.607L449.68,416.677z"/>
|
||||
<path id="mg" d="M526.988,561.474l-1.842,4.374l-3.154,5.566l-5.523,0.396l-2.369,2.783l0.397,8.488l-3.423,3.977l0.396,6.761l2.897,3.311l3.423-0.396l3.423-2.524l-0.787-3.977l7.894-13.657l-1.582-1.72l1.582-3.312l1.711,0.526l0.526-1.322l-1.582-6.76l-0.924-2.784L526.988,561.474L526.988,561.474z"/>
|
||||
<path id="mk" d="M456.643,418.924l-2.912,0.959l0.139,2.473l0.683,0.873l3.458-1.606L456.643,418.924L456.643,418.924z"/>
|
||||
<path id="ml" d="M377.584,494.845l2.662-1.823l14.799-0.087l-3.423-23.806l3.907-0.112l18.903,14.428l2.541,0.362l-0.959,8.021l-11.886,1.08l-9.171,6.847l-1.669,4.686l-6.37,0.269l-1.625-4.677l-4.884,0.346l0.189-1.53L377.584,494.845L377.584,494.845z"/>
|
||||
<path id="mm" d="M645.533,501.596l-2.396-3.838l1.737-2.438l-1.642-3.018l-1.548-0.294l-0.294-5.064l-2.316-4.486l-0.675,1.071l-1.547,2.628l-1.937,0.294l-0.968-1.271l-0.484-3.414l-1.453-2.73l-5.913-5.576l1.453-0.959l0.27-4.037l2.16-3.631l0.935-9.032l3.129-2.136l0.103-3.293l1.877,0.622l2.956,4.279l-2.194,4.701l1.479,3.691l3.655,1.435l0.666,4.021l4.91,0.761l-1.357,2.343l-6.188,2.438l-0.674,3.993l4.547,5.844l0.189,3.12l-1.062,1.072l0.094,0.977l3.389,4.971l0.096,5.16L645.533,501.596L645.533,501.596z"/>
|
||||
<path id="mn" d="M597.438,386.215l5.03-6.673l6.043,2.792l4.105,1.098l5.03-4.615l-3.414-2.517l2.248-3.172l6.707,2.369l2.325,3.812l4.199,0.112l2.196-1.634l4.521-0.182l0.985,1.678l7.512,0.38l4.754-4.849l6.578,0.69l-0.38,6.604l2.879,0.656l3.535-1.606l3.743,1.85l-0.088,0.935l-2.714,0.078l-2.827,5.93l-2.195,0.216l-8.54,11.16l-8.723,3.847l-5.455,0.424l-4.529-2.923l-5.791,3.095l-5.705-1.771l-1.617-4.141l-10.805-0.762l-5.532-9.377l-2.688-0.174L597.438,386.215L597.438,386.215z"/>
|
||||
<path id="mr" d="M364.998,478.266l1.885,2.463l-0.389,10.65l2.74-1.972l1.952-0.397l2.741,0.985l3.128,4.34l2.939-1.971l14.288-0.199l-3.526-23.866l3.786-0.018l-7.054-5.402l0.009,3.51l-8.93,0.01l-0.043,6.698l-2.567-0.009l-0.328,4.944L364.998,478.266L364.998,478.266z"/>
|
||||
<path id="mt" d="M440.815,438.339l-1.443,0.294l0.052,1.6l1.297,0.433l0.579-0.484L440.815,438.339L440.815,438.339z"/>
|
||||
<path id="mu" d="M544.89,584.008l-1.312,1.721l0.259,1.857l2.768-2.256L544.89,584.008L544.89,584.008z"/>
|
||||
<g id="mv">
|
||||
<path d="M582.396,516.386l0.26,2.256l1.442,0.527l0.26-1.989L582.396,516.386L582.396,516.386z"/>
|
||||
<path d="M584.238,521.156l-0.13,2.784l1.055,0.525l0.925-1.856L584.238,521.156L584.238,521.156z"/>
|
||||
<path d="M584.506,526.595l-0.925,0.925l1.056,0.925l1.313-0.925L584.506,526.595L584.506,526.595z"/>
|
||||
</g>
|
||||
<path id="mw" d="M487.968,567.074l2.689,2.81l-0.053,3.597l0.52,1.514l3.57-3.855l-0.414-4.899l-1.912-1.46l-1.701-8.603l-2.949-0.104l1.34,6.196L487.968,567.074L487.968,567.074z"/>
|
||||
<path id="mx" d="M133.847,433.982l4.175,13.146l-1.945,1.089l0.216,2.61l3.674,2.827v5.229l4.538,4.356l-1.945-12.847l-2.593-8.497l0.648-5.877l2.161,0.217l0.865,1.962l-0.865,5.005l11.237,21.99v7.841l9.077,10.666l9.94,4.573l4.106-2.396l5.835,4.789l3.458-3.483l-1.513-3.925l4.97-1.521l1.513,0.873l1.513-1.521h2.377l4.322-7.624l-2.161-1.962l-8.428,1.962l-1.945,5.662L182.106,480l-5.834-2.396l-2.593-8.271l1.962-10.434l-4.011-2.498l-1.91-10.02l-1.599-0.683l-2.922,2.965l-3.354-1.789l-1.313-6.682l-13.286-1.393l-6.863-5.16L133.847,433.982L133.847,433.982z"/>
|
||||
<g id="my">
|
||||
<path class="mainland" d="M648.359,511.796l1.736,3.898l0.391,5.064l2.324,3.604l5.096,3.083l1-0.791l1.464-0.288l-0.212-1.91l-1.841-4.478l-2.697-5.731l-0.227,1.003l-3.25-0.146l-2.334-3.354L648.359,511.796L648.359,511.796z"/>
|
||||
<path d="M675.527,526.896l2.61,3.018l10.012-3.467l1.979-7.643l4.46-0.319l4.08-2.956l-5.29-3.854l-1.211-2.119l-2.61,4.815l0.959,2.766l-1.591,2.31l-2.999-0.771l-7.27,5.333l0.188,3.086L675.527,526.896L675.527,526.896z"/>
|
||||
</g>
|
||||
<path id="mz" d="M482.791,596.359l2.326,1.928l5.479-3.335l0.881-4.953v-8.178l8.791-7.191l1.506,0.053l5.322-5.107l-0.828-10.529l-13.81,1.743l0.519,3.338l2.02,1.757l0.57,5.731l-4.756,4.642l-1.141-2.603l0.207-3.44l-2.74-2.973l-6.725,3.13l6.258,3.182l0.209,9.274l-4.141,6.146L482.791,596.359L482.791,596.359z"/>
|
||||
<path id="na" d="M444.221,603.863l2.897,0.208l1.702,1.72l4.037,0.052l0.984-11.462v-7.503l2.585-0.52l0.986-7.867l6.569-0.206l2.323-1.927l-3.933-0.156l-5.325,0.726l-5.739-2.082h-16.13l0.415,4.58l5.376,7.918l-0.934,4.062l0.053,2.136L444.221,603.863L444.221,603.863z"/>
|
||||
<path id="nc" d="M798.706,602.576l-0.303,1.547l3.983,5.559l2.145,0.926l0.303-2.161L798.706,602.576L798.706,602.576z"/>
|
||||
<path id="ne" d="M413.396,500.17l2.204-0.053l1.988-2.981l3.336-0.597l3.553,2.17l7.58,0.216l5.861-2.386l2.204-1.894l0.164-2.489l4.088-4.123l1.08-9.104l-2.688-5.636l-6.88-1.677l-15.923,12.413l-2.256-0.217l-0.969,8.617l-8.124,0.813L413.396,500.17L413.396,500.17z"/>
|
||||
<path id="ng" d="M413.984,515.185l3.389,0.164l4.088,4.557l1.987,0.544l1.558-0.761l2.367-0.328l0.805-3.303l3.225-2.117l3.492-0.163l6.396-11.766l-0.104-2.653l-2.955-2.273l-5.913,2.603l-7.909-0.111l-3.77-2.386l-2.688,0.596l-1.4,2.438l-0.104,6.88l-2.256,3.198L413.984,515.185L413.984,515.185z"/>
|
||||
<path id="ni" d="M203.216,491.62l1.893,0.381l0.061,3.881l-2.204,6.293l-5.938-0.588l-1.323-3.034l1.764-3.682l3.345-3.112L203.216,491.62L203.216,491.62z"/>
|
||||
<path id="nl" d="M421.349,384.572l-3.915,1.928l0.829,0.752l0.088,1.928l-0.829-0.164l-0.917-1.426l-2.188,3.467l3.363,0.699l1.253,1.323l0.666,0.017l0.44-2.99l2.117-0.891L421.349,384.572L421.349,384.572z"/>
|
||||
<g id="no">
|
||||
<path class="mainland" d="M460.567,327.409l1.747-1.279l-0.157-1.435l-1.106-0.641l0.157-1.754h0.95v-0.96l-4.123-1.114l-6.181,0.64l-0.631,2.714l-1.428-0.477l-0.951-1.599l-3.017,0.155l-0.32,3.033l-1.426,0.641l-0.795-1.6l-6.345,5.109l1.271,1.436l-2.378,1.115l-5.393,10.701l-1.901,1.279l0.155,0.959l1.9,0.959l-0.475,2.074l-3.173-0.164l-0.952-1.115l-2.057,2.395l-1.271,0.959l-0.32,2.24l-1.105,0.64l-2.854,0.64l-1.426,4.479l0.951,7.348l1.106,3.354l1.271,1.279l2.853-0.156l4.124-3.994l1.581-2.713l0.478,3.992l2.697-4.789l0.154-13.424l2.195-1.383l0.657-7.408l6.655-9.586l3.173-1.115l1.427-1.755l4.754,1.115l2.377,1.435l0.796-3.992l3.968-2.396L460.567,327.409L460.567,327.409z"/>
|
||||
<path d="M437.056,285.762l-1.426-1.435l-3.164,1.539h-5.809l-0.917,3.389l3.26,2.878l1.425-0.208l2.04-3.491l1.729,1.235l-1.229,2.464l-0.614,3.596l1.427,2.256l3.06-5.135l3.979-4.832l-1.531-1.33L437.056,285.762L437.056,285.762z"/>
|
||||
<path d="M438.784,279.6l-2.55,2.359l1.529,2.359h2.749l1.124,1.539l3.363,1.746l3.871-2.256l2.654-2.256l-0.916-1.85l-2.654-1.539l-1.938,1.746l-1.321-1.644l-1.021,0.104l-1.322,2.878l-1.936-1.954l-0.208-1.331L438.784,279.6L438.784,279.6z"/>
|
||||
<path d="M444.593,290.179l-2.04,1.851l-1.729,1.332l0.812,1.435l1.636,0.511l2.652-1.236l1.229-1.539l-1.124-1.85L444.593,290.179L444.593,290.179z"/>
|
||||
</g>
|
||||
<path id="np" d="M595.182,448.789l0.397,3.691l6.983,3.162l11.193,0.83l-0.423-2.705l-7.478-2.058l-6.346-3.777L595.182,448.789L595.182,448.789z"/>
|
||||
<g id="nz">
|
||||
<path d="M804.221,655.729l0.917,10.199l-1.228,4.634l-4.6,3.396l0.305,4.02v4.322l1.228,1.548l12.577-10.814v-2.472h-3.068l-4.298-14.521L804.221,655.729L804.221,655.729z"/>
|
||||
<path d="M795.023,677.979l2.455,4.633l-6.752,6.492l-0.613,3.396l-4.6,0.613l-7.667,7.104l-7.054-3.396l-0.613-2.474l12.879-5.558L795.023,677.979L795.023,677.979z"/>
|
||||
</g>
|
||||
<path id="om" d="M532.244,481.879l6.388-3.683l1.133-5.402l-1.399-0.804l0.579-5.792l1.219-0.709l1.306,2.049l7.771,4.062v2.258l-9.413,13.854l-4.331,0.147L532.244,481.879L532.244,481.879z"/>
|
||||
<path id="pa" d="M205.68,506.748l-1.262,3.941l4.167,1.079l2.584,0.512l0.441-3.052l2.775-1.4l2.463,1.271l0.968,1.547l1.175-0.138l0.925-2.81l-3.078-1.271l-2.334-1.271l-2.333,1.59l-2.775,1.4l-2.835-1.141L205.68,506.748L205.68,506.748z"/>
|
||||
<path id="pe" d="M209.518,541.246l-1.677,1.695l0.113,2.703l14.643,26.694l15.205,9.802l2.351-3.941l0.562-8.669l-1.228-5.402l-4.141-6.984l-2.463,0.786l-1.115,1.236l-4.918-5.636l1.228-6.647l5.705-3.717l-0.449-3.492l-5.809-0.226l-3.017-5.064l-1.677-0.562l0.113,3.044l-7.486,8.895l-5.593-1.349L209.518,541.246L209.518,541.246z"/>
|
||||
<g id="pg">
|
||||
<path class="mainland" d="M752.132,540.183l-0.319,21.126l3.044-0.164l4.002-4.676l3.361,0.164l2.161,1.937l0.718,5.964l6.882,3.631l1.763-0.648v-2.179l-5.523-4.599l-2.723-6.293l2.161-1.047l-1.601-3.466l-3.197-0.078l-0.805-3.709l-8.479-5.722L752.132,540.183L752.132,540.183z"/>
|
||||
<path d="M778.176,546.008l-0.819,0.191l-0.501,2.222l-1.573,1.021l-4.729,0.83l0.19,1.779l4.979-0.249l3.155-1.972l-0.188-3.432L778.176,546.008L778.176,546.008z"/>
|
||||
<path d="M776.093,540.797l-0.762,1.08l4.159,3.683l0.569,2.161l1.133-0.13l0.13-2.221l-1.263-1.14L776.093,540.797L776.093,540.797z"/>
|
||||
</g>
|
||||
<g id="ph">
|
||||
<path d="M697.337,496.306l-0.743,1.418l-0.414,1.746l-4.132,5.246l0.25,1.081l1.737-0.251l5.368-5.999L697.337,496.306L697.337,496.306z"/>
|
||||
<path d="M704.027,494.309l-0.088,4.331l1.573,1.582l0.578,3.077l1.574,0.337l0.742-1.919l-1.236-0.916l-0.328-5.411L704.027,494.309L704.027,494.309z"/>
|
||||
<path d="M708.496,495.978l-0.087,3.829l0.908,1.495l1.571-1.832l-0.414-3.328L708.496,495.978L708.496,495.978z"/>
|
||||
<path d="M709.481,492.641l1.572,2.083l0.743,1.997h1.409l-0.25-3.415l-1.573-1.08L709.481,492.641L709.481,492.641z"/>
|
||||
<path d="M712.542,500.472l0.328,2.498l-2.896,2.334l-2.396,0.251l-2.56,2.749l0.087,1.252l2.396-0.751l1.651-1.08l1.408,3.578l2.48,1.747l0.994-0.338l0.907-1.08l-1.979-1.997l1.159-0.916l1.322,1.08l0.906-1.495l-0.906-1.833l-0.164-4.08L712.542,500.472L712.542,500.472z"/>
|
||||
<path d="M699.074,475.076l-2.23,1.581l-0.25,4.997l3.477,6.742l1.158,0.915l1.485-1.003l2.56,0.415l0.492,2.248l1.901,0.164l0.908-1.245l-1.158-1.582l-1.409-1.331l-2.974-0.327l-1.573-2.585l1.816-2.749l0.163-2.411l-1.236-3.077L699.074,475.076L699.074,475.076z"/>
|
||||
<path d="M700.232,489.979l0.657,2.335l1.158,0.752l0.83-1.081l-1.323-1.832L700.232,489.979L700.232,489.979z"/>
|
||||
</g>
|
||||
<path id="pk" d="M553.638,455.082l2.248,3.337l-0.216,1.72l-2.991,1.186l-0.217,2.801h3.424l1.175-0.969h6.519l5.878,5.17l0.752-2.481h4.383l0.104-3.12l-4.486-4.305l0.96-2.369l4.598-0.318l6.199-12.924l-3.425-2.688l-1.278-4.521l8.333-0.752l-4.917-7l-2.62-0.709l-1.07,1.297l-0.805,0.061l-4.919,3.12l1.608,2.696l-1.815,1.937l-2.249,8.29l-5.558,3.553l-0.752,3.882L553.638,455.082L553.638,455.082z"/>
|
||||
<path id="pl" d="M457.109,390.184l0.733,1.348l0.174,1.435l-0.604,1.392l-1.383,2.664l-1.167,0.526l-1.514-0.657l-0.908,0.043l-2.204,0.83l-2.506-0.742l-4.062-2.879l-3.978-2.135l-1.6-2.438l-0.303-5.75l3.112-2.705l4.062-1.35l1.328-0.138l0.315,1.007l1.725,0.692l4.766,0.09l1.47-0.043l2.421,3.708l-0.604,1.521l0.259,1.789L457.109,390.184L457.109,390.184z"/>
|
||||
<path id="pr" d="M249.297,482.068l-2.282-0.77l-1.833,1.149l0.917,1.071l3.121,0.458L249.297,482.068L249.297,482.068z"/>
|
||||
<g id="pt">
|
||||
<path class="mainland" d="M387.499,421.716l-0.536,7.478l-1.53,1.384l0.156,0.846l1.071,1.772l-0.691,2.16l1.149,0.39l2.68-0.312l-0.155-2.16l1.756-10.02l-0.382-1.383L387.499,421.716L387.499,421.716z"/>
|
||||
<path d="M367.834,443.481l-0.934,1.185l0.934,1.185l1.408-0.709L367.834,443.481L367.834,443.481z"/>
|
||||
<path d="M337.112,426.713l-1.175,1.184l2.107,1.185l0.234-1.649L337.112,426.713L337.112,426.713z"/>
|
||||
<path d="M343.448,426.004l-1.408,0.941l1.175,0.941l1.876-0.476L343.448,426.004L343.448,426.004z"/>
|
||||
<path d="M344.382,429.314l-0.7,1.892l0.935,1.185l1.175-0.941L344.382,429.314L344.382,429.314z"/>
|
||||
<path d="M350.01,433.092l-0.467,1.184l0.701,0.709l1.875-1.184L350.01,433.092L350.01,433.092z"/>
|
||||
</g>
|
||||
<path id="py" d="M267.199,584.458l1.902,2.074l-0.225,4.392l5.48-0.338l4.141,5.299l-0.337,4.729l-2.681,4.054L270,604.893l-0.225-2.256l1.564-3.718l-5.368-3.38h-4.469l-3.354-3.604l2.438-6.968L267.199,584.458L267.199,584.458z"/>
|
||||
<path id="qa" d="M527.273,463.018l-0.449,3.467l1.331,1.012l1.209-0.112l0.45-4.365l-1.047-0.752L527.273,463.018L527.273,463.018z"/>
|
||||
<path id="ro" d="M457.731,401.281l-0.226,1.279l-5.005,4.166l4.184,6.137l2.682,1.877h4.823l1.591-1.331l2.135-0.276l1.591,0.959l2.818-3.207l-0.545-1.607l-2.861-0.734l-1.953-0.095l0.094-2.749l-2.593-4.08L457.731,401.281L457.731,401.281z"/>
|
||||
<path id="rs" d="M452.001,407.279l-1.772,1.332h-0.863l-0.588,1.832l2.092,2.43l0.139,1.928l-0.882,1.246l3.068,3.197l3.317-1.012l-0.274-4.721L452.001,407.279L452.001,407.279z"/>
|
||||
<g id="ru">
|
||||
<path class="mainland" d="M722.059,302.16l1.522,5.256l3.043,0.873l3.042-4.814l-1.737-3.285l0.648-2.845h4.563l-1.09,2.188l0.434,7.883l-6.519,16.199l0.648,3.501l-0.216,5.912l12.161,17.729l2.387,0.657l0.216-14.443l2.386-2.188l-2.61-5.688l2.171-2.412l-4.78-6.346l-2.611,0.217l-0.865-10.503l6.734-1.755l0.432-3.068l3.478-0.873l1.953,1.756l2.386-9.631l4.124-7l3.259-1.756l2.827,0.217v-3.285l-4.563-0.873l-6.302-5.256l3.044-3.5l-2.61-5.913l2.169-2.187l2.61,3.5l6.519,2.411l7.166,0.657l0.873-3.061l-3.69-3.717l4.123-5.688l-9.345-3.285l-2.386,4.814l-3.043-3.941l-17.158-5.913l-16.295,2.844l-2.387,1.314v1.313l3.476,1.756l-0.434,4.157l-6.301-2.628l-13.898,5.473l-2.388-5.031h-9.561l-4.349,4.599l-15.421-3.501l-14.115,2.844l-1.737,4.375l2.17,0.656l-0.216,3.285l-13.685,1.529l0.873,4.375l-12.604-2.188l3.044-5.688l-12.819-0.657l1.089,5.913l-4.123,1.971l-3.476-3.285l-14.116,2.412l-5.428,5.031l-0.217,3.06l-3.476,0.216l-0.433-3.5l11.082-9.629v-6.57l-7.166-1.971l-9.345,3.061l-3.907-3.942h-1.737l-2.171,4.374l1.738,1.971l-12.388,6.787l-10.642,8.1l-6.519,8.972v3.717l6.949,2.845l-3.475,2.627l-7.382-2.627l-3.044,2.627l-4.563-5.256l-0.873,1.972l4.996,15.758l1.305,0.44l3.478-1.754l1.736,1.313v2.844l-3.259-1.313l-1.955,1.529l1.308,2.844l-1.09,7.443l-6.734,0.657l-0.432-2.412l3.907-2.411l0.873-6.57l-4.349-5.688l-1.521-9.846l-6.948-1.098l-0.648,3.5l1.305,1.754l-2.825,2.412l1.089,6.57l4.124,1.755l0.873,4.814l-4.133-2.627l-10.641-1.972l-1.306,3.502l-8.473,3.06l-1.305-2.187l-11.082,6.127l-0.216,4.158l-4.348,0.657l1.306-3.06v-3.061l-4.349-1.529l-2.826,1.098l2.386,4.599l1.737,3.06v2.412l-3.26-0.656l-0.647-0.657l-3.26,3.501l1.737,3.061l-7.383-0.217l2.387,3.069l-0.647,1.313h-3.907l-2.827-1.971l-0.647-5.472l-4.563-1.755v-2.187l9.561,1.972l5.213,0.44l2.17-3.285l-1.954-3.501l-13.899-5.472l-4.798,1.192l-1.642,1.41l0.511,3.24l2.039,0.354l-0.476,5.101l6.293,14.781l-4.548,7.209l-0.312,1.625l2.309,1.625l-2.084,1.375l-1.383,0.026l0.26,6.353l1.91,2.706l0.026,2.627l2.446,0.225l3.741,1.426l3.959,5.446l0.045,1.435l-1.288,2.205l2.956-0.164l2.878,0.83l3.891,5.506l9.577,0.873l-0.415,6.552l-3.301,2.827l0.683,1.105l-3.26,3.502l-0.864,3.284l1.954,2.845l6.301,2.187l2.611-1.53l16.727,6.346l0.648-1.756l-3.476-3.283v-4.158l-2.171-0.657l0.434-3.5l3.476-4.158l-6.231-4.667l0.432-6.492l6.665-4.383l7.822,0.441l1.306,2.412l8.04,0.44l5.869-3.284l-3.044-3.285l0.647-6.129l15.205-7.442l11.695,5.272l3.907-3.5l11.514,10.942l8.688-0.873l3.044,3.06l8.255,0.873l5.429-7.441l6.949,3.068l3.69,0.658l3.692-3.285l-3.26-2.188l2.827-4.374l8.039,2.628l1.736,3.501l3.477,0.216l2.17-1.529l5.869-0.217l0.647,1.53l6.733,0.44l4.562-4.814l9.345,1.098l2.827-1.098l0.864-5.256l-2.826-6.346l2.826-2.411h8.904l8.471,10.069l10.857,6.129h3.26l0.432-2.627l3.907-2.412l0.433,14.228l-3.475,0.216v3.5l1.953,2.412l-0.363,3.129l1.443,0.598l0.874-2.188l1.306,0.441l0.864,0.873l3.907-0.873l3.906-11.385l0.434-14.229l-4.996-11.385l-6.301-7.657l-3.044,0.44v2.412l-7.382-2.845l2.826-6.128l2.387-16.199l9.992-3.06l4.779-3.06h5.213l-1.312,1.754l1.306,2.188l4.563-4.814l2.609,0.215l-0.432-2.844l-4.132-0.873l2.827-10.287L722.059,302.16L722.059,302.16z"/>
|
||||
<path d="M450.108,378.288l-1.296,2.396l4.665,0.043h0.95l-0.179-1.352l-0.728-0.854L450.108,378.288z"/>
|
||||
<path d="M741.137,353.246l-1.071,1.332l0.087,2.083l0.992-0.086l1.651-2.913L741.137,353.246L741.137,353.246z"/>
|
||||
<path d="M776.793,272.303l-2.04,1.331l-0.483,1.694l0.96,1.089l2.16-0.726l2.161,0.726l1.201,0.363l-0.121-3.994L776.793,272.303L776.793,272.303z"/>
|
||||
<path d="M488.539,272.648l1.487,0.598l-1.046,1.798v2.55l-2.23,1.35h-2.377l-1.34-1.651l0.146-1.798l1.046-1.35h2.084L488.539,272.648L488.539,272.648z"/>
|
||||
<path d="M494.192,270.998v1.798l1.486,1.202l2.083-0.146l1.788-1.651v-1.202h-1.634l-1.34,0.449l-1.046-1.201L494.192,270.998L494.192,270.998z"/>
|
||||
<path d="M502.681,271.152l1.046,2.248l2.084,0.147l1.486-0.596l-0.742-2.101l-1.937-0.451L502.681,271.152L502.681,271.152z"/>
|
||||
<path d="M511.161,268.154l-1.635-0.303l-1.487,1.504l0.744,1.349l0.449,2.101l1.937-1.496l0.449-1.65L511.161,268.154L511.161,268.154z"/>
|
||||
<path d="M520.237,284.051l-0.45,2.1l-3.424,3l-7.294,1.651l-5.957,9.897l-1.046,2.853l5.957,1.505l0.889-3.597l1.79-5.549l4.615-2.403l3.872-3l2.826-1.201h1.487v-4.046L520.237,284.051L520.237,284.051z"/>
|
||||
<path d="M501.039,305.946l4.019,0.45l1.342,4.649l3.423,3.597l-1.193,2.402h-2.083l-1.937-2.248l-4.313-0.146l-1.789-2.403v-1.651l2.682-0.752L501.039,305.946L501.039,305.946z"/>
|
||||
<path d="M563.855,254.809l-1.938-1.203h-2.23l-0.448,1.35l-2.377,1.35l-1.79,0.596l-0.294,1.798l4.167,0.303L563.855,254.809L563.855,254.809z"/>
|
||||
<path d="M568.463,255.257l-1.047,2.247l-2.083-0.146l-3.276,2.402l-0.89,3h2.083l1.193-1.953l2.826,2.101l2.681-1.202l1.937-1.65l-0.744-2.551l-1.046-1.798L568.463,255.257L568.463,255.257z"/>
|
||||
<path d="M572.784,256.908l1.046,4.201l1.634,3.897l1.79-3.146l3.423-0.752v-2.248l-2.229-1.651L572.784,256.908L572.784,256.908z"/>
|
||||
<path d="M654.453,250.184l2.326,1.953l1.649-0.683l0.484-2.74l-3.389-2.342l-2.23,1.469l-5.428,0.493v2.445l-5.724,0.096v4.002l6.69,4.979l1.747-1.271l-0.39-3.519l4.27-1.071l-0.873-1.66l-1.547-1.563L654.453,250.184L654.453,250.184z"/>
|
||||
<path d="M660.66,247.84l1.547,2.932l6.017-0.684l1.65-2.152l-0.389-1.857l-1.65-0.684l-1.548,1.176l-4.46,0.978L660.66,247.84L660.66,247.84z"/>
|
||||
<path d="M660.271,259.268l-3.01-0.777l-1.736,1.857l-0.778,2.541l4.071-0.389l3.104-1.564L660.271,259.268L660.271,259.268z"/>
|
||||
<path d="M738.231,242.369l-2.523-0.778l-2.904,1.072l-1.453,2.151l1.842,2.446l4.85-2.151l0.968-1.072L738.231,242.369L738.231,242.369z"/>
|
||||
<path d="M739.156,358.329v3.665l1.159,0.415l0.828-1.332v-2.827L739.156,358.329L739.156,358.329z"/>
|
||||
<path d="M705.35,345.086l-0.076,5.333l6.689,10.33l2.396,8.989l4.219,7.996l1.649,0.58l1.409-1.168l0.657-1.918l-6.033-6.578l0.164-3.416l1.322-0.578l0.329-1.997l-11.816-16.735L705.35,345.086L705.35,345.086z"/>
|
||||
<path d="M751.967,328.516l-1.649,0.164l0.993,1.418l2.065,1.418l0.579-0.666L751.967,328.516L751.967,328.516z"/>
|
||||
<path d="M755.183,329.52l0.251,1.416l2.56,0.752l0.251-1.002L755.183,329.52L755.183,329.52z"/>
|
||||
<path d="M769.229,334.956l1.08,1.937l1.8-0.121l0.361-1.332L769.229,334.956L769.229,334.956z"/>
|
||||
<path d="M787.356,337.98l1.442,2.662l1.08-1.209v-1.815L787.356,337.98L787.356,337.98z"/>
|
||||
</g>
|
||||
<path id="rw" d="M479.896,532.931l2.429,2.238l-0.104,2.396l-3.769,0.077v-2.646L479.896,532.931L479.896,532.931z"/>
|
||||
<path id="sa" d="M519.812,458.021l6.061,8.443l1.953,1.558l0.874,3.785l9.327,0.734l1.055,0.554l-1.046,4.667l-6.129,3.613l-8.964,2.715l-4.78,4.668l-5.679-3.312l-3.439,3.009l-4.791-7.823l-3.284-1.504l-1.192-1.807v-3.916l-11.954-14.452l-0.451-2.561h3.44l4.184-3.611l0.146-1.808l-1.192-1.201l2.395-1.953l5.084,0.303l8.669,7.226l5.117-0.232l0.329,1.263L519.812,458.021L519.812,458.021z"/>
|
||||
<g id="sb">
|
||||
<path d="M783.786,549.882l1.072,2.981l1.892,1.842l0.57-0.51l-0.189-1.972l-2.145-2.603L783.786,549.882L783.786,549.882z"/>
|
||||
<path d="M789.016,554.324l0.131,1.971l1.2,1.141l1.134-0.7l-1.012-2.102L789.016,554.324L789.016,554.324z"/>
|
||||
<path d="M790.528,559.218l-1.012,1.081l1.071,1.97l1.262,0.381l-0.06-1.331L790.528,559.218L790.528,559.218z"/>
|
||||
<path d="M792.992,558.076l0.882,2.161l1.702,2.03l0.943-1.521l-1.263-2.161L792.992,558.076L792.992,558.076z"/>
|
||||
<path d="M797.409,561.317l0.501,2.671l1.203,1.65l1.011-2.092L797.409,561.317L797.409,561.317z"/>
|
||||
<path d="M798.792,567.291l-0.44,0.76l1.452,1.911l1.012,0.062l-0.632-2.482L798.792,567.291L798.792,567.291z"/>
|
||||
<path d="M795.576,571.094l-1.514,0.7l1.323,1.843l1.133-0.64L795.576,571.094L795.576,571.094z"/>
|
||||
</g>
|
||||
<g id="sc">
|
||||
<path d="M535.676,548.87l-0.525,1.062l1.442,1.192l1.056-1.192L535.676,548.87L535.676,548.87z"/>
|
||||
<path d="M543.049,540.919l-1.582,1.062l1.186,1.857h1.582L543.049,540.919L543.049,540.919z"/>
|
||||
<path d="M543.706,545.56l-1.055,1.193l0.786,1.192l1.442,0.269l0.13-2.522L543.706,545.56L543.706,545.56z"/>
|
||||
</g>
|
||||
<path id="sd" d="M466.144,505.035l-2.55-1.504l-2.325-4.59l0.13-4.271l3.224-2.772l0,0l0.155-10.228l2.127,0.062l-0.242-5.68l22.302,0.198l3.189-3.215l6.881,11.004l-3.77,4.442v6.785l-4.599,9.891l-1.04,2.3l-3.709-5.316l-2.708,3.441l-3.059,0.834l-9.941-1l-4.333,1.541L466.144,505.035z"/>
|
||||
<g id="se">
|
||||
<path class="mainland" d="M445.232,329.52l1.693,1.563h3.173l1.746,3.354l0.477,5.748l-4.278,3.035v3.033l-3.017,4.158l-1.746,0.154l-2.378,3.994l0.155,3.838l4.124,3.035l-0.319,1.754l-1.582,2.396l-2.377,2.074l0.155,6.872l-3.647,1.279l-1.271,2.713h-1.747l-0.949-4.789l-3.969-6.084l3.26-5.455l0.225-13.477l2.248-1.236l0.545-7.709l6.405-9.172L445.232,329.52L445.232,329.52z"/>
|
||||
<path d="M445.898,368.927l-1.824,1.443l0.917,2.118l1.616-1.573L445.898,368.927L445.898,368.927z"/>
|
||||
</g>
|
||||
<path id="sg" d="M658.314,527.705l0.686,0.389l1.548-0.126l-0.13-1.167L659.156,527L658.314,527.705z"/>
|
||||
<path id="si" d="M442.708,405.076l-2.195,1.314l-4.097,0.898l0.82,2.368l2.87,0.034l2.646-2.213L442.708,405.076L442.708,405.076z"/>
|
||||
<path id="sk" d="M443.607,400.875l0.597,0.527l0.077,0.898l6.596-0.146l4.875-2.102l-0.077-2.135l-0.934,0.415l-1.34-0.718l-0.821-0.035l-2.161,0.865l-2.938-0.709L443.607,400.875L443.607,400.875z"/>
|
||||
<path id="sl" d="M372.804,509.79l4.884,4.72l3.483-4.227l-2.179-3.415l-3,0.303L372.804,509.79L372.804,509.79z"/>
|
||||
<path id="sn" d="M372.424,498.771l-5.792-0.141l0,0l1.072,2.603l0.596-1.607l7.271,0.761l0.806-0.028l3.405,0.12l0.121-1.504l-3.111-3.733l-3.467-4.693l-2.152-0.898l-1.661,0.424l0,0l-3.405,2.472l-0.776,1.384l-0.242,1.383l1.253,0.899l4.185-0.061l2.688-0.728l0.303,1.322l-0.241,1.746L372.424,498.771z"/>
|
||||
<path id="so" d="M526.703,501.941l3.777-1.452l1.34,0.804l-0.147,3.354l-3.482,9.923l-18.854,20.19l-2.187-1.503l-0.147-8.521l2.835-3.26l6.018-1.857l8.824-9.318l2.31-2.058l0.647-3.008L526.703,501.941L526.703,501.941z"/>
|
||||
<path id="sr" d="M268.384,516.715l1.763,1.616l2.731-1.694l2.49,0.078l-0.32,0.968l-1.046,2.179l-0.164,5.421l-4.97,2.022l0.242-3.476l-3.207-2.991l0.164-1.538L268.384,516.715L268.384,516.715z"/>
|
||||
<path id="ss" d="M489.336,508.019l-2.04,0.898l0.647,3.553h2.542l3.448,5.004l-2.767,0.354l-0.709,1.288l-0.069,1.857l-8.298-0.146l-0.848-1.288l-5.8-0.328l-10.649-10.962l1.063-0.64l4.517-1.364l9.897,0.754l3.366-0.754l2.235-2.996L489.336,508.019z"/>
|
||||
<g id="st">
|
||||
<path d="M421.911,530.554l0.993-0.502l0.743,0.604l-0.743,1.148l-0.899-0.354L421.911,530.554L421.911,530.554z"/>
|
||||
<path d="M423.907,527.398l1.496-0.251l0.501,0.951l-0.743,0.805l-0.743-0.104L423.907,527.398L423.907,527.398z"/>
|
||||
</g>
|
||||
<path id="sv" d="M189.308,495.217l4.062,2.022l-0.061-3.207l-2.083-1.271L189.308,495.217L189.308,495.217z"/>
|
||||
<path id="sy" d="M487.545,437.18l-0.302,2.196l2.437,1.021l-0.104,6.086l2.438-0.053l2.438-1.842l0.916-0.155l5.532-4.398l1.114-6.39l-11.056,1.125l-1.167,2.559L487.545,437.18L487.545,437.18z"/>
|
||||
<path id="sz" d="M482.531,596.983l-2.169,0.361l-0.935,2.552l1.659,1.513h2.015l1.702-2.446L482.531,596.983L482.531,596.983z"/>
|
||||
<path id="td" d="M440.971,494.983l0.112-2.552l4.098-3.983l1.099-9.785l-2.731-5.221l1.911-0.979l18.498,9.639l-0.113,9.456l-3.259,2.775v4.875l2.136,4.132h-3.77l-6.24,6.173l-0.165,1.867l-4.605-0.061l-0.061,0.846l-2.628-0.345l-1.798-3.397l-1.351-0.666l0.174-1.037l1.693-1.297v-6.066l-2.343-0.363l-2.826-2.102L440.971,494.983L440.971,494.983L440.971,494.983z"/>
|
||||
<path id="tg" d="M408.495,516.81l2.316-1.356l-0.053-8.946l-1.504-2.438l-0.967,0.812L408.495,516.81L408.495,516.81z"/>
|
||||
<path id="th" d="M646.043,472.915l2.8,3.604v4.384l0.968,0.482l4.452-2.144l0.873,0.294l5.316,6.138l-0.19,4.192l-1.737-0.294l-1.548-0.979l-1.158,0.097l-2.031,3.404l0.39,1.851l1.642,0.873l-0.095,2.049l-1.157,0.588l-3.97-2.731v-2.438l-1.642-0.095l-0.674,1.07l-0.347,10.909l2.567,4.686l4.547,4.383l-0.188,1.271l-2.423-0.094l-2.221-3.311h-2.325l-2.902-2.345l-0.874-2.437l1.254-2.049l0.432-1.851l1.366-2.421l-0.061-5.565l-3.337-4.823l-0.139-0.588l1.081-1.089l-0.251-3.83l-4.441-5.627l0.519-3.241L646.043,472.915L646.043,472.915z"/>
|
||||
<path id="tj" d="M559.74,422.234l3.552-4.408h1.34l0.467,0.984l-1.642,1.192v0.985l1.081,0.777l5.195,0.312l1.693-0.727l0.77,0.154l0.52,1.66l3.085,0.312l1.548,3.267l-0.467,0.985l-0.614,0.053l-0.612-1.245l-1.341-0.104l-2.315,0.312l-0.156,2.18l-2.316-0.155l0.104-2.75l-1.694-1.658l-2.575,2.125l0.053,1.4l-2.265,0.778h-1.341l0.104-4.824L559.74,422.234L559.74,422.234z"/>
|
||||
<path id="tm" d="M528.328,418.561l-0.535,2.273h-3.588v3.078l3.854,2.541l-1.191,3.482v1.608l1.599,0.269l2.127-2.811l4.789-1.07l10.234,3.881l0.129,2.809l5.714,0.536l6.38-6.698l-0.796-2.145l-4.253-0.935l-11.964-7.771l-0.535-2.81h-4.521l-1.997,3.753h-1.997L528.328,418.561L528.328,418.561z"/>
|
||||
<path id="tn" d="M425.516,435.624l4.78-1.927l1.572,1.02l0.061,1.244l-0.734,0.959l0.111,1.703l0.735,0.396v3.061l-0.847,1.418l0.111,0.908l3.207,1.132l-2.584,4.02l-1.012-0.061l-0.173,3.232l-1.124,0.174l-0.959-0.849l0.225-3.284l-3.146-3.061l-0.397-2.662l1.521-1.192L425.516,435.624L425.516,435.624z"/>
|
||||
<path id="tr" d="M472.812,421.906l-2.305-1.426l-1.271-1.013l-2.138,0.916l0,0l-1.477,3.74l2.219-0.5l1.562-1.188l3.438,0.938l-1.946,1.877L465.719,425l-1.91,2.093v1.021l1.22,1.021v1.123l-0.511,1.332l0.511,1.123l1.625-0.812l1.625,1.737l-0.406,1.228l-0.604,0.82l0.907,1.021l4.461,0.916l3.139-1.331v-1.937l1.521,0.303l3.648,2.144l3.948-0.614l1.721-1.633l1.114,0.406v1.841h1.521l1.313-2.55l11.549-1.229l5.04-0.613l-1.331-1.746l-0.025-2.359l1.011-1.21l-3.682-2.956l0.197-2.551h-2.022l-3.354-1.643l0,0l-1.929,2.041l-7.088-0.209l-4.253-2.549l-4.082,0.366l-4.544,2.729L472.812,421.906z"/>
|
||||
<path id="tt" d="M258.97,502.572l-0.917,0.847l-0.994,0.155v1.228l1.833,1.687l0.76-1.229l0.458-1.383l-0.156-1.149L258.97,502.572L258.97,502.572z"/>
|
||||
<path id="tw" d="M695.686,453.76l-3.06,2.334l-0.163,4.494l2.646,3.078l0.656-0.58L695.686,453.76L695.686,453.76z"/>
|
||||
<path id="tz" d="M492.22,560.017l13.797-1.69l-3.395-6.569l-0.182-6.292l1.098-3.009l-14.367-9.023l-4.502,0.743l-1.564,1.158l-0.139,2.637l-1.012,3.656l-1.055,1.253l-1.514,0.141l2.809,9.418l4.816,2.838l4.195,0.1L492.22,560.017z"/>
|
||||
<path id="ua" d="M460.662,388.791l-2.507,1.409l0.622,2.663l-2.316,4.884l0.02,2.151l1.089,0.691l6.983,0.346l1.954-1.615l2.092,0.699l2.999,4.002l-2.194,3.942l2.61,0.761l3.414-3.933l1.954,0.354l1.815,1.262l-1.601,2.109l2.161,3.371h2.299l1.185-2.248l2.438-0.494l0.069-1.823l-4.529-0.7l0.14-1.963h4.391l4.737-3.794l2.092-1.824l0.345-5.757l-9.336-0.838l-3.829-5.402l-2.646-0.908l-3.207,0.139l-1.443,3.569l-6.57,0.087l-2.135-0.986L460.662,388.791L460.662,388.791z"/>
|
||||
<path id="ug" d="M480.311,532.23l2.619,2.454l1.643-1.045l4.442-0.728l0.762,0.079l0.284-1.688l2.508-5.272l-2.109-4.392l-6.837,0.044l-0.043,1.808l0.917,0.882l-0.139,1.807L480.311,532.23L480.311,532.23z"/>
|
||||
<g id="us">
|
||||
<path class="mainland" d="M143.589,375.989l-0.865,3.475l-3.017-1.954h-1.504l-0.865,3.691l-10.554,23.65l2.801,20.606l3.449,1.737l0.648,5.645h7.105l6.889,5.204l13.562,1.305l1.504,6.941l2.152,1.521l3.017-3.033l2.369,1.08l2.152,9.976l3.656,2.386l3.017-5.645l9.258-6.726l6.025,2.817l5.169,0.433l0.216-3.25l10.762,0.217l2.152,2.386l0.432,5.42l-1.288,3.034l1.504,5.203h3.233l3.232-4.987l-1.288-2.386l-1.288-5.204l1.936-5.86l8.826-7.59l6.673-1.953l-0.864-6.293l9.258-9.983l9.258-1.521l-1.504-5.193l9.042-5.205v-6.94l-0.865-0.433l-3.233,1.082l-0.432,4.252l-10.745,0.129l-8.419,5.594l-13.217,4.322l-2.109-2.586l5.999-9.076l-2.965-2.826l-2.014-3.838l-4.175-3.354l-4.538-0.38l-8.575-5.852L143.589,375.989L143.589,375.989z"/>
|
||||
<path d="M74.791,285.234l2.991,5.594l1.919-0.432v-1.938L74.791,285.234L74.791,285.234z"/>
|
||||
<path d="M57.926,334.428l-0.147,2.602l1.867-0.432v-1.158L57.926,334.428L57.926,334.428z"/>
|
||||
<path d="M55.057,335.586l-3.734,1.885l0.579,2.022l1.435-1.158l2.87-1.306L55.057,335.586L55.057,335.586z"/>
|
||||
<path d="M39.541,338.042l-2.584-0.579l-0.432,1.158l0.285,2.169L39.541,338.042L39.541,338.042z"/>
|
||||
<path d="M34.078,337.902l-2.446-1.01l-0.865,1.59l1.582,1.591L34.078,337.902L34.078,337.902z"/>
|
||||
<path d="M95.485,277.922l-7.252,1.721l1.496,8.168l7.892,2.151l0.423,1.72l-11.73,3.657l-6.613,10.96l2.343,11.609l3.838,2.576l2.991-2.793l0.856,1.721l-3.63,4.296l-14.082,6.449l-8.964,2.151l-0.216,3.225l20.694-6.016l8.532-2.369l7.892-9.674l8.748-5.8l-4.478,7.521l4.91,0.647l8.324-3.656l1.496,6.017l5.757,1.288l5.973,5.8l0.423,4.296l-0.855,1.072l1.063,4.08h1.496l0.216-6.881h1.703l0.423,16.977l4.271-3.657l-2.991-17.625h-4.478l-4.91-6.231l24.108-40.844l-23.892-18.696l-26.667,5.161l-1.063,8.168l5.757,3.439l-2.135,5.594L95.485,277.922L95.485,277.922z"/>
|
||||
</g>
|
||||
<path id="uy" d="M274.633,612.481l-1.773,1.894l0.735,10.183l5.566,1.615l7.08-7.097L274.633,612.481L274.633,612.481z"/>
|
||||
<path id="uz" d="M558.643,428.477l2.662,0.138v-4.556l-2.522-1.47l4.253-5.358h1.729l1.729,2.015l4.521-1.738l-6.25-2.144l-0.242-1.297l-1.485,0.363l-1.461,2.541l-6.302-0.207l-4.625-6.543l-8.125,0.803l-3.872-3.838l-5.358-0.906l-3.891,1.582l2.257,7.502l0.024,2.524l1.643,0.035l2.014-3.839l5.359,0.068l0.796,2.947l11.487,7.625l4.442,1.021L558.643,428.477L558.643,428.477z"/>
|
||||
<g id="vc">
|
||||
<path d="M258.823,496.582l-1.063,0.77l0.839,1.539l1.374-0.77L258.823,496.582L258.823,496.582z"/>
|
||||
<path d="M257.526,499.573l-0.994,0.994l0.38,0.612h1.219l0.38-1.003L257.526,499.573L257.526,499.573z"/>
|
||||
</g>
|
||||
<path id="ve" d="M231.5,503.558l0.38,2.239l2.809,0.892l0.64-4.124l2.965-3.068l2.965,3.475l6.82,1.859l5.774-1.211l3.933,4.849l2.965,1.858l-3.25,4.953l1.089,3.752l-1.858,2.299l-1.928,1.616l-4.175-2.102l-0.959,0.969v2.99l3.052,1.452l-2.248,2.43l-2.248,2.43l-2.965-0.242l-2.982-3.275l-0.631-12.326l-10.183-3.476l-1.85-5.42L231.5,503.558L231.5,503.558z"/>
|
||||
<path id="vn" d="M659.035,502.287l1.027,1.616l0.19,1.851l2.705,0.294l3.286-4.383l3.095-0.873l1.643-4.478l-0.771-7.209l-3.189-4.384l-3.362-2.688l-4.278-7.349l3.068-5.135l-4.393-5.039l-3.517-0.154l-3.164,1.702l0.942,4.071l4.219,0.743l1.133,3.138l-1.487,0.969l0.096,0.777l9.896,9.683l0.388,2.844l-0.595,8.989L659.035,502.287L659.035,502.287z"/>
|
||||
<g id="vu">
|
||||
<path d="M811.006,582.479l-1.071,1.435l0.45,1.616l0.535,0.362l0.979-1.262L811.006,582.479L811.006,582.479z"/>
|
||||
<path d="M811.542,586.879l0.087,1.167l1.158,0.363l0.805-0.449l-0.805-1.264L811.542,586.879L811.542,586.879z"/>
|
||||
<path d="M813.236,597.303l-0.536,0.812l0.804,0.897l1.34-0.448L813.236,597.303L813.236,597.303z"/>
|
||||
</g>
|
||||
<g id="ye">
|
||||
<path class="mainland" d="M509.432,489.131l1.244,3.7v3.613l2.991,2.714l21.074-8.584l0.198-2.359l-3.381-6.067l-8.479,2.706l-4.866,4.787l-5.645-3.336L509.432,489.131L509.432,489.131z"/>
|
||||
<path d="M533.315,498.138l1.842,2.059l2.489-1.504l0.897-0.304l-1.141-1.105l-2.188,0.647L533.315,498.138L533.315,498.138z"/>
|
||||
</g>
|
||||
<path id="za" d="M476.731,588.02l-6.829,6.311l-1.625,3.898l-5.411-0.674l-4.503,4.002l-2.991-0.294l0.241-5.531l-1.062-0.372l-0.743,11.314l-5.308-0.052l-1.6-1.886l-2.344-0.024l2.137,6.129l3.812,3.604l-2.723,3.172l1.764,3.977l4.08,1.558l3.25-2.767l9.311,0.053l0.667-0.83l4.132-0.728l13.978-13.917l-0.053-4.382l-1.495,1.937h-2.238l-2.723-2.282l1.383-3.44l2.378-0.482l-0.217-7.071L476.731,588.02L476.731,588.02z M473.455,604.011l1.306-0.052l2.118,2.299l-0.061,2.662l-2.481,1.253l-0.155,0.883l-3.785,0.043l-1.186-2.853l1.081-2.092L473.455,604.011L473.455,604.011z"/>
|
||||
<path id="zm" d="M459.78,571.656l2.739,3.802l4.244,0.261l1.504,0.829l4.443,0.053l3.829-5.367l10.702-4.789l0.934-4.219l-1.244-6.043l-5.584-3.181l-3.727,0.26l-1.857,4.114l0.053,1.876l4.391,2.136l0.26,4.642l-3.775,0.208l-0.935-1.564l-10.495-4.479l-0.311,3.44l-4.962,0.155L459.78,571.656L459.78,571.656z"/>
|
||||
<path id="zw" d="M468.52,578.226l7.755,8.757l5.946,1.513l3.984-6.248l-0.312-8.281l-6.465-3.337l-2.431,1.098l-3.62,5.524l-5.014-0.053L468.52,578.226L468.52,578.226z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 72 KiB |
@@ -1,12 +1,46 @@
|
||||
<?php
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlApiController;
|
||||
use Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlRedirectController;
|
||||
use Bjanczak\FilamentShortUrl\Http\Middleware\AuthenticateShortUrlApi;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get(
|
||||
Route::get('/.well-known/apple-app-site-association', [ShortUrlRedirectController::class, 'serveAasa'])
|
||||
->middleware(['web']);
|
||||
Route::get('/apple-app-site-association', [ShortUrlRedirectController::class, 'serveAasa'])
|
||||
->middleware(['web']);
|
||||
Route::get('/.well-known/assetlinks.json', [ShortUrlRedirectController::class, 'serveAssetLinks'])
|
||||
->middleware(['web']);
|
||||
|
||||
Route::match(
|
||||
['GET', 'POST'],
|
||||
config('filament-short-url.route_prefix', 's').'/{key}',
|
||||
ShortUrlRedirectController::class
|
||||
)
|
||||
->name('short-url.redirect')
|
||||
->where('key', '[a-zA-Z0-9_-]+')
|
||||
->middleware('throttle:120,1');
|
||||
->middleware(config('filament-short-url.middleware', ['web', 'throttle:120,1']));
|
||||
|
||||
|
||||
Route::prefix('api/short-url')
|
||||
->middleware([AuthenticateShortUrlApi::class])
|
||||
->group(function () {
|
||||
Route::get('links', [ShortUrlApiController::class, 'index']);
|
||||
Route::post('links', [ShortUrlApiController::class, 'store']);
|
||||
Route::delete('links/{id}', [ShortUrlApiController::class, 'destroy']);
|
||||
});
|
||||
|
||||
Route::post('admin/short-url/upload-logo', [ShortUrlApiController::class, 'uploadLogo'])
|
||||
->name('short-url.upload-logo')
|
||||
->middleware(['web']);
|
||||
|
||||
Route::post('admin/short-url/log-debug', function (Request $request) {
|
||||
Log::info('QR DESIGNER JS DEBUG: '.json_encode($request->all()));
|
||||
|
||||
return response()->json(['status' => 'ok']);
|
||||
})->middleware(['web']);
|
||||
|
||||
Route::get('short-url/logo/{filename}', [ShortUrlApiController::class, 'serveLogo'])
|
||||
->name('short-url.logo');
|
||||
|
||||
187
src/Console/Commands/AggregateAndPruneVisitsCommand.php
Normal file
187
src/Console/Commands/AggregateAndPruneVisitsCommand.php
Normal file
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Console\Commands;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlDailyStats;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class AggregateAndPruneVisitsCommand extends Command
|
||||
{
|
||||
/** @var string */
|
||||
protected $signature = 'short-url:aggregate-and-prune';
|
||||
|
||||
/** @var string */
|
||||
protected $description = 'Aggregate past days visits into daily stats and prune old raw visit logs';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$today = Carbon::today()->toDateString();
|
||||
$driver = DB::connection()->getDriverName();
|
||||
$dateExpression = match ($driver) {
|
||||
'pgsql' => 'visited_at::date',
|
||||
'sqlsrv' => 'CAST(visited_at AS DATE)',
|
||||
default => 'DATE(visited_at)',
|
||||
};
|
||||
|
||||
// 1. Find all unique dates before today that have visits (optimized range and compatible DATE extract)
|
||||
$dates = ShortUrlVisit::where('visited_at', '<', $today)
|
||||
->selectRaw("{$dateExpression} as visit_date")
|
||||
->distinct()
|
||||
->pluck('visit_date')
|
||||
->toArray();
|
||||
|
||||
if (empty($dates)) {
|
||||
$this->info('No historical visits to aggregate.');
|
||||
} else {
|
||||
$this->info('Found '.count($dates).' days to aggregate.');
|
||||
|
||||
foreach ($dates as $date) {
|
||||
// Wrap date aggregation in a database transaction for data integrity
|
||||
DB::transaction(function () use ($date): void {
|
||||
// Accumulate stats per short_url_id using chunked reads — avoids loading
|
||||
// potentially millions of rows into PHP memory at once.
|
||||
$statsByUrl = [];
|
||||
$nextDate = Carbon::parse($date)->addDay()->toDateString();
|
||||
|
||||
DB::table('short_url_visits')
|
||||
->where('visited_at', '>=', $date.' 00:00:00')
|
||||
->where('visited_at', '<', $nextDate.' 00:00:00')
|
||||
->select([
|
||||
'short_url_id',
|
||||
'is_qr_scan',
|
||||
'ip_hash',
|
||||
'device_type',
|
||||
'browser',
|
||||
'operating_system',
|
||||
'country',
|
||||
'country_code',
|
||||
'utm_source',
|
||||
'utm_medium',
|
||||
'utm_campaign',
|
||||
'browser_language',
|
||||
'city',
|
||||
'referer_host',
|
||||
])
|
||||
->orderBy('id')
|
||||
->chunk(1000, function ($chunk) use (&$statsByUrl): void {
|
||||
foreach ($chunk as $visit) {
|
||||
$urlId = $visit->short_url_id;
|
||||
|
||||
if (! isset($statsByUrl[$urlId])) {
|
||||
$statsByUrl[$urlId] = [
|
||||
'total' => 0,
|
||||
'ip_hashes' => [],
|
||||
'device_stats' => [],
|
||||
'browser_stats' => [],
|
||||
'os_stats' => [],
|
||||
'country_stats' => [],
|
||||
'city_stats' => [],
|
||||
'referer_stats' => [],
|
||||
'utm_source_stats' => [],
|
||||
'utm_medium_stats' => [],
|
||||
'utm_campaign_stats' => [],
|
||||
'qr_scans' => 0,
|
||||
'language_stats' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$s = &$statsByUrl[$urlId];
|
||||
$s['total']++;
|
||||
|
||||
if ($visit->is_qr_scan) {
|
||||
$s['qr_scans']++;
|
||||
}
|
||||
|
||||
if ($visit->ip_hash) {
|
||||
$s['ip_hashes'][$visit->ip_hash] = true;
|
||||
}
|
||||
|
||||
$inc = function (?string $value, string $key) use (&$s): void {
|
||||
if ($value) {
|
||||
$s[$key][$value] = ($s[$key][$value] ?? 0) + 1;
|
||||
}
|
||||
};
|
||||
|
||||
$inc($visit->device_type, 'device_stats');
|
||||
$inc($visit->browser, 'browser_stats');
|
||||
$inc($visit->operating_system, 'os_stats');
|
||||
$inc($visit->country, 'country_stats');
|
||||
$inc($visit->utm_source, 'utm_source_stats');
|
||||
$inc($visit->utm_medium, 'utm_medium_stats');
|
||||
$inc($visit->utm_campaign, 'utm_campaign_stats');
|
||||
$inc($visit->browser_language, 'language_stats');
|
||||
|
||||
if ($visit->city) {
|
||||
$cityKey = "{$visit->city} ({$visit->country_code})";
|
||||
$s['city_stats'][$cityKey] = ($s['city_stats'][$cityKey] ?? 0) + 1;
|
||||
}
|
||||
|
||||
if ($visit->referer_host) {
|
||||
$s['referer_stats'][$visit->referer_host] = ($s['referer_stats'][$visit->referer_host] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
foreach ($statsByUrl as $urlId => $s) {
|
||||
ShortUrlDailyStats::updateOrCreate([
|
||||
'short_url_id' => $urlId,
|
||||
'date' => $date,
|
||||
], [
|
||||
'visits_count' => $s['total'],
|
||||
'unique_visits_count' => count($s['ip_hashes']),
|
||||
'device_stats' => $s['device_stats'],
|
||||
'browser_stats' => $s['browser_stats'],
|
||||
'os_stats' => $s['os_stats'],
|
||||
'country_stats' => $s['country_stats'],
|
||||
'city_stats' => $s['city_stats'],
|
||||
'referer_stats' => $s['referer_stats'],
|
||||
'utm_source_stats' => $s['utm_source_stats'],
|
||||
'utm_medium_stats' => $s['utm_medium_stats'],
|
||||
'utm_campaign_stats' => $s['utm_campaign_stats'],
|
||||
'qr_visits_count' => $s['qr_scans'],
|
||||
'language_stats' => $s['language_stats'],
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
$this->info("Aggregated stats for {$date}.");
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Prune old visits if enabled
|
||||
if (config('filament-short-url.pruning.enabled', true)) {
|
||||
$retentionDays = (int) config('filament-short-url.pruning.retention_days', 90);
|
||||
$cutoff = Carbon::now()->subDays($retentionDays)->toDateTimeString();
|
||||
|
||||
$deleted = ShortUrlVisit::where('visited_at', '<', $cutoff)->delete();
|
||||
|
||||
$this->info("Successfully pruned {$deleted} raw visit records older than {$retentionDays} days.");
|
||||
}
|
||||
|
||||
// 3. Prune old temporary logo files (older than 24 hours)
|
||||
$disk = Storage::disk('public');
|
||||
if ($disk->exists('short-urls/tmp')) {
|
||||
$files = $disk->files('short-urls/tmp');
|
||||
$now = time();
|
||||
$prunedCount = 0;
|
||||
|
||||
foreach ($files as $file) {
|
||||
$lastModified = $disk->lastModified($file);
|
||||
if (($now - $lastModified) > 86400) { // 86400 seconds = 24 hours
|
||||
$disk->delete($file);
|
||||
$prunedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($prunedCount > 0) {
|
||||
$this->info("Successfully pruned {$prunedCount} temporary logo files older than 24 hours.");
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
134
src/Console/Commands/SyncBufferedCountersCommand.php
Normal file
134
src/Console/Commands/SyncBufferedCountersCommand.php
Normal file
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Console\Commands;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
class SyncBufferedCountersCommand extends Command
|
||||
{
|
||||
/** @var string */
|
||||
protected $signature = 'short-url:sync-counters';
|
||||
|
||||
/** @var string */
|
||||
protected $description = 'Sync buffered short URL visit counters from cache to the database';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$prefix = config('filament-short-url.counter_buffering.cache_key_prefix', 'filament-short-url:buffer:');
|
||||
$dirtyKey = "{$prefix}dirty_ids";
|
||||
|
||||
// Pull the list atomically to avoid race conditions with incoming clicks
|
||||
if (Cache::getDefaultDriver() === 'redis' && class_exists(Redis::class)) {
|
||||
$tempKey = "{$dirtyKey}:temp:".time();
|
||||
try {
|
||||
if (Redis::exists($dirtyKey)) {
|
||||
Redis::rename($dirtyKey, $tempKey);
|
||||
$dirtyIds = Redis::smembers($tempKey);
|
||||
Redis::del($tempKey);
|
||||
} else {
|
||||
$dirtyIds = [];
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// If key does not exist or rename fails, fallback
|
||||
$dirtyIds = [];
|
||||
}
|
||||
} else {
|
||||
$dirtyIds = Cache::pull($dirtyKey, []);
|
||||
}
|
||||
|
||||
if (empty($dirtyIds)) {
|
||||
$this->info('No buffered counters to synchronize.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
$dirtyIds = array_unique(array_filter($dirtyIds));
|
||||
$processed = 0;
|
||||
$updatesToMake = [];
|
||||
|
||||
foreach ($dirtyIds as $id) {
|
||||
$totalKey = "{$prefix}total:{$id}";
|
||||
$uniqueKey = "{$prefix}unique:{$id}";
|
||||
$qrKey = "{$prefix}qr:{$id}";
|
||||
|
||||
$totalDelta = (int) Cache::pull($totalKey, 0);
|
||||
$uniqueDelta = (int) Cache::pull($uniqueKey, 0);
|
||||
$qrDelta = (int) Cache::pull($qrKey, 0);
|
||||
|
||||
if ($totalDelta > 0 || $uniqueDelta > 0 || $qrDelta > 0) {
|
||||
$updatesToMake[$id] = [
|
||||
'total' => $totalDelta,
|
||||
'unique' => $uniqueDelta,
|
||||
'qr' => $qrDelta,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($updatesToMake)) {
|
||||
$this->info('No buffered counters to synchronize.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($updatesToMake, &$processed) {
|
||||
$shortUrls = ShortUrl::whereIn('id', array_keys($updatesToMake))->get(['id', 'url_key']);
|
||||
|
||||
foreach ($updatesToMake as $id => $deltas) {
|
||||
ShortUrl::where('id', $id)->update([
|
||||
'total_visits' => DB::raw("total_visits + {$deltas['total']}"),
|
||||
'unique_visits' => DB::raw("unique_visits + {$deltas['unique']}"),
|
||||
'qr_scans' => DB::raw("qr_scans + {$deltas['qr']}"),
|
||||
]);
|
||||
$processed++;
|
||||
}
|
||||
|
||||
foreach ($shortUrls as $url) {
|
||||
Cache::forget("filament-short-url:{$url->url_key}");
|
||||
}
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
// Restore pulled values in cache so no clicks are lost
|
||||
foreach ($updatesToMake as $id => $deltas) {
|
||||
$totalKey = "{$prefix}total:{$id}";
|
||||
$uniqueKey = "{$prefix}unique:{$id}";
|
||||
$qrKey = "{$prefix}qr:{$id}";
|
||||
|
||||
if ($deltas['total'] > 0) {
|
||||
Cache::increment($totalKey, $deltas['total']);
|
||||
}
|
||||
if ($deltas['unique'] > 0) {
|
||||
Cache::increment($uniqueKey, $deltas['unique']);
|
||||
}
|
||||
if ($deltas['qr'] > 0) {
|
||||
Cache::increment($qrKey, $deltas['qr']);
|
||||
}
|
||||
}
|
||||
|
||||
// Put the IDs back into the dirty list
|
||||
if (Cache::getDefaultDriver() === 'redis' && class_exists(Redis::class)) {
|
||||
Redis::sadd($dirtyKey, ...array_keys($updatesToMake));
|
||||
} else {
|
||||
$lock = Cache::lock("{$prefix}dirty_ids_lock", 2);
|
||||
$lock->get(function () use ($prefix, $updatesToMake) {
|
||||
$cachedDirty = Cache::get("{$prefix}dirty_ids", []);
|
||||
if (! is_array($cachedDirty)) {
|
||||
$cachedDirty = [];
|
||||
}
|
||||
$merged = array_unique(array_merge($cachedDirty, array_keys($updatesToMake)));
|
||||
Cache::forever("{$prefix}dirty_ids", $merged);
|
||||
});
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$this->info("Successfully synchronized counters for {$processed} short URLs.");
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
161
src/Filament/Resources/ShortUrlPixelResource.php
Normal file
161
src/Filament/Resources/ShortUrlPixelResource.php
Normal file
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlPixelResource\Pages\ListShortUrlPixels;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlPixel;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Columns\ToggleColumn;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class ShortUrlPixelResource extends Resource
|
||||
{
|
||||
protected static ?string $model = ShortUrlPixel::class;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-funnel';
|
||||
|
||||
protected static ?int $navigationSort = 51;
|
||||
|
||||
protected static ?string $recordTitleAttribute = 'name';
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('filament-short-url::default.pixels_navigation_label') ?? 'Retargeting Pixels';
|
||||
}
|
||||
|
||||
public static function getModelLabel(): string
|
||||
{
|
||||
return __('filament-short-url::default.pixel_resource_title') ?? 'Retargeting Pixel';
|
||||
}
|
||||
|
||||
public static function getPluralModelLabel(): string
|
||||
{
|
||||
return __('filament-short-url::default.pixels_navigation_label') ?? 'Retargeting Pixels';
|
||||
}
|
||||
|
||||
public static function getNavigationGroup(): string|\UnitEnum|null
|
||||
{
|
||||
try {
|
||||
return ShortUrlResource::getNavigationGroup();
|
||||
} catch (\Throwable) {
|
||||
return __('filament-short-url::default.navigation_group');
|
||||
}
|
||||
}
|
||||
|
||||
public static function getNavigationSort(): ?int
|
||||
{
|
||||
try {
|
||||
return ShortUrlResource::getNavigationSort() + 1;
|
||||
} catch (\Throwable) {
|
||||
return static::$navigationSort;
|
||||
}
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
TextInput::make('name')
|
||||
->label(__('filament-short-url::default.pixel_name') ?? 'Pixel Name')
|
||||
->required()
|
||||
->maxLength(150)
|
||||
->placeholder('e.g. Meta Ads - Yacht Promo'),
|
||||
|
||||
Select::make('type')
|
||||
->label(__('filament-short-url::default.pixel_type') ?? 'Provider')
|
||||
->options([
|
||||
'meta' => 'Meta / Facebook Pixel',
|
||||
'google' => 'Google Tag (GA4 / GTM)',
|
||||
'linkedin' => 'LinkedIn Insight Tag',
|
||||
'tiktok' => 'TikTok Pixel',
|
||||
'pinterest' => 'Pinterest Tag',
|
||||
])
|
||||
->required()
|
||||
->native(false),
|
||||
|
||||
TextInput::make('pixel_id')
|
||||
->label(__('filament-short-url::default.pixel_id_label') ?? 'Pixel ID / Tag ID')
|
||||
->required()
|
||||
->maxLength(100)
|
||||
->placeholder('e.g. 1234567890 or G-XXXXXXXXXX'),
|
||||
|
||||
Toggle::make('is_active')
|
||||
->label(__('filament-short-url::default.pixel_status_active') ?? 'Active')
|
||||
->default(true),
|
||||
])
|
||||
->columns(1);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->label(__('filament-short-url::default.pixel_name') ?? 'Pixel Name')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->weight('bold'),
|
||||
|
||||
TextColumn::make('type')
|
||||
->label(__('filament-short-url::default.pixel_type') ?? 'Provider')
|
||||
->badge()
|
||||
->formatStateUsing(fn (string $state): string => match ($state) {
|
||||
'meta' => 'Meta',
|
||||
'google' => 'Google',
|
||||
'linkedin' => 'LinkedIn',
|
||||
'tiktok' => 'TikTok',
|
||||
'pinterest' => 'Pinterest',
|
||||
default => ucfirst($state),
|
||||
})
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'meta' => 'info',
|
||||
'google' => 'warning',
|
||||
'linkedin' => 'primary',
|
||||
'tiktok' => 'gray',
|
||||
'pinterest' => 'danger',
|
||||
default => 'gray',
|
||||
}),
|
||||
|
||||
TextColumn::make('pixel_id')
|
||||
->label(__('filament-short-url::default.pixel_id_label') ?? 'ID')
|
||||
->searchable()
|
||||
->copyable()
|
||||
->fontFamily('mono')
|
||||
->color('gray'),
|
||||
|
||||
ToggleColumn::make('is_active')
|
||||
->label(__('filament-short-url::default.pixel_status_active') ?? 'Active')
|
||||
->sortable(),
|
||||
])
|
||||
->filters([])
|
||||
->actions([
|
||||
EditAction::make()
|
||||
->icon('heroicon-o-pencil-square')
|
||||
->color('gray')
|
||||
->iconButton()
|
||||
->tooltip(__('filament-short-url::default.action_edit'))
|
||||
->modalWidth('md'),
|
||||
DeleteAction::make()
|
||||
->icon('heroicon-o-trash')
|
||||
->color('gray')
|
||||
->iconButton()
|
||||
->tooltip(__('filament-short-url::default.action_delete')),
|
||||
])
|
||||
->bulkActions([]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListShortUrlPixels::route('/'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlPixelResource\Pages;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlPixelResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ManageRecords;
|
||||
|
||||
class ListShortUrlPixels extends ManageRecords
|
||||
{
|
||||
protected static string $resource = ShortUrlPixelResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make()
|
||||
->icon('heroicon-o-plus')
|
||||
->size('sm')
|
||||
->color('primary')
|
||||
->modalWidth('md'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
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;
|
||||
@@ -26,6 +25,15 @@ class ShortUrlResource extends Resource
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
try {
|
||||
$label = FilamentShortUrlPlugin::get()->getNavigationLabel();
|
||||
if ($label) {
|
||||
return $label;
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
return __('filament-short-url::default.navigation_label');
|
||||
}
|
||||
|
||||
@@ -41,6 +49,20 @@ class ShortUrlResource extends Resource
|
||||
|
||||
// ─── Plugin-aware navigation overrides ───────────────────────────────────
|
||||
|
||||
public static function getNavigationIcon(): string|\BackedEnum|null
|
||||
{
|
||||
try {
|
||||
$icon = FilamentShortUrlPlugin::get()->getNavigationIcon();
|
||||
if ($icon) {
|
||||
return $icon;
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
return static::$navigationIcon;
|
||||
}
|
||||
|
||||
public static function getNavigationGroup(): string|\UnitEnum|null
|
||||
{
|
||||
try {
|
||||
@@ -90,7 +112,6 @@ class ShortUrlResource extends Resource
|
||||
'index' => ListShortUrls::route('/'),
|
||||
'stats' => ViewShortUrlStats::route('/{record}/stats'),
|
||||
'stats.logs' => ViewShortUrlLogs::route('/{record}/stats/logs'),
|
||||
'settings' => ShortUrlSettingsPage::route('/settings'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,14 @@
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlGlobalOverview;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Forms;
|
||||
use Filament\Resources\Pages\ManageRecords;
|
||||
use Illuminate\Support\HtmlString;
|
||||
|
||||
class ListShortUrls extends ManageRecords
|
||||
{
|
||||
@@ -15,31 +19,377 @@ class ListShortUrls extends ManageRecords
|
||||
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;
|
||||
})
|
||||
->after(function (ShortUrl $record): void {
|
||||
$id = json_encode($record->id);
|
||||
$shortUrl = json_encode($record->getShortUrl());
|
||||
$urlKey = json_encode($record->url_key);
|
||||
$destHost = json_encode(parse_url($record->destination_url, PHP_URL_HOST) ?? '');
|
||||
|
||||
$this->js("
|
||||
setTimeout(() => {
|
||||
if (localStorage.getItem('fsu:hide-share-modal') !== '1') {
|
||||
\$wire.mountAction('shareAfterCreate', {
|
||||
id: {$id},
|
||||
shortUrl: {$shortUrl},
|
||||
urlKey: {$urlKey},
|
||||
destHost: {$destHost}
|
||||
});
|
||||
}
|
||||
}, 200);
|
||||
");
|
||||
}),
|
||||
|
||||
// Hidden action — opened programmatically after create
|
||||
Action::make('shareAfterCreate')
|
||||
->label('')
|
||||
->extraAttributes(['class' => 'hidden'])
|
||||
->modalHeading('')
|
||||
->modalWidth('lg')
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelAction(false)
|
||||
->form(function (array $arguments): array {
|
||||
$recordId = $arguments['id'] ?? null;
|
||||
$shortUrl = $arguments['shortUrl'] ?? '';
|
||||
$qrTargetUrl = $shortUrl ? ($shortUrl.'?source=qr') : '';
|
||||
$destHost = $arguments['destHost'] ?? '';
|
||||
$urlKey = $arguments['urlKey'] ?? 'qr-code';
|
||||
$encoded = urlencode($shortUrl);
|
||||
$eid = 'fsu_'.substr(md5($shortUrl), 0, 8);
|
||||
|
||||
$successTitle = __('filament-short-url::default.success_modal_title') ?? 'Your link & QR code are ready!';
|
||||
$successSubtitle = __('filament-short-url::default.success_modal_subtitle') ?? 'Time to get some clicks 🎉';
|
||||
$successHelper = __('filament-short-url::default.success_modal_helper') ?? 'Copy and share manually or choose a platform.';
|
||||
$downloadSvgText = __('filament-short-url::default.qr_download_svg') ?? 'Download SVG';
|
||||
$downloadPngText = __('filament-short-url::default.qr_download_png') ?? 'Download PNG';
|
||||
$closeButtonText = __('filament-short-url::default.close_button') ?? 'Close';
|
||||
$copyLinkText = __('filament-short-url::default.action_copy') ?? 'Copy link';
|
||||
$qrCodeText = __('filament-short-url::default.action_qr') ?? 'QR Code';
|
||||
$openLinkText = __('filament-short-url::default.open_link') ?? 'Open link';
|
||||
$dontShowAgainText = __('filament-short-url::default.dont_show_again') ?? "Don't show sharing options after creating a link";
|
||||
|
||||
$record = $recordId ? ShortUrl::find($recordId) : null;
|
||||
$qrDefaults = $record ? $record->getQrOptions() : config('filament-short-url.qr_defaults', []);
|
||||
$isGrad = ($qrDefaults['gradient_enabled'] ?? false) || (($qrDefaults['color_mode'] ?? '') === 'gradient');
|
||||
$dotStyle = $qrDefaults['dot_style'] ?? 'square';
|
||||
$fgColor = $qrDefaults['foreground_color'] ?? '#000000';
|
||||
$bgColor = ($qrDefaults['bg_transparent'] ?? false) ? 'rgba(0,0,0,0)' : ($qrDefaults['background_color'] ?? '#ffffff');
|
||||
|
||||
$dotsOptions = $isGrad ? [
|
||||
'type' => $dotStyle,
|
||||
'gradient' => [
|
||||
'type' => $qrDefaults['gradient_type'] ?? 'linear',
|
||||
'colorStops' => [
|
||||
['offset' => 0, 'color' => $qrDefaults['gradient_from'] ?? '#4f46e5'],
|
||||
['offset' => 1, 'color' => $qrDefaults['gradient_to'] ?? '#06b6d4'],
|
||||
],
|
||||
],
|
||||
] : [
|
||||
'type' => $dotStyle,
|
||||
'color' => $fgColor,
|
||||
];
|
||||
|
||||
$mainColor = $isGrad ? ($qrDefaults['gradient_from'] ?? '#4f46e5') : $fgColor;
|
||||
|
||||
$eyeConfigEnabled = $qrDefaults['eye_config_enabled'] ?? false;
|
||||
$eyeSquareStyle = $qrDefaults['eye_square_style'] ?? ($dotStyle === 'dots' ? 'dot' : 'square');
|
||||
$eyeDotStyle = $qrDefaults['eye_dot_style'] ?? ($dotStyle === 'dots' ? 'dot' : 'square');
|
||||
$eyeColor = $qrDefaults['eye_color'] ?? $mainColor;
|
||||
|
||||
$cornersSquareOptions = $eyeConfigEnabled ? [
|
||||
'type' => $eyeSquareStyle,
|
||||
'color' => $eyeColor,
|
||||
] : [
|
||||
'type' => $dotStyle === 'dots' ? 'dot' : 'square',
|
||||
'color' => $mainColor,
|
||||
];
|
||||
|
||||
$cornersDotOptions = $eyeConfigEnabled ? [
|
||||
'type' => $eyeDotStyle,
|
||||
'color' => $eyeColor,
|
||||
] : [
|
||||
'type' => $dotStyle === 'dots' ? 'dot' : 'square',
|
||||
'color' => $mainColor,
|
||||
];
|
||||
|
||||
$logo = $qrDefaults['logo'] ?? null;
|
||||
$logoSize = $qrDefaults['logo_size'] ?? 0.3;
|
||||
$logoMargin = $qrDefaults['logo_margin'] ?? 9;
|
||||
$logoHideBackground = $qrDefaults['logo_hide_background'] ?? true;
|
||||
$logoShape = $qrDefaults['logo_shape'] ?? 'square';
|
||||
|
||||
$qrOptionsJson = json_encode([
|
||||
'type' => 'svg',
|
||||
'width' => 200,
|
||||
'height' => 200,
|
||||
'margin' => $qrDefaults['margin'] ?? 1,
|
||||
'dotsOptions' => $dotsOptions,
|
||||
'backgroundOptions' => ['color' => $bgColor],
|
||||
'cornersSquareOptions' => $cornersSquareOptions,
|
||||
'cornersDotOptions' => $cornersDotOptions,
|
||||
'image' => $logo ?: null,
|
||||
'imageOptions' => [
|
||||
'crossOrigin' => 'anonymous',
|
||||
'hideBackgroundDots' => $logoHideBackground,
|
||||
'imageSize' => $logoSize,
|
||||
'margin' => $logoMargin,
|
||||
'logoShape' => $logoShape,
|
||||
],
|
||||
'qrOptions' => ['errorCorrectionLevel' => $logo ? 'H' : 'M'],
|
||||
]);
|
||||
|
||||
$escapedQrOptions = e($qrOptionsJson);
|
||||
|
||||
$html = <<<HTML
|
||||
<div x-data x-init="if(localStorage.getItem('fsu:hide-share-modal')==='1'){ \$nextTick(()=>\$wire.unmountAction()) }">
|
||||
|
||||
<!-- Close Button (x) -->
|
||||
<button type="button" x-on:click="event.preventDefault(); event.stopPropagation(); const f=\$el.closest('.fi-modal-window'); const m=f?(f.getAttribute('x-on:keydown.window.escape')||'').match(/'(fi-[^']*)'/):null; if(m){\$dispatch('close-modal',{id:m[1]})}else{\$wire.unmountAction()}"
|
||||
style="position:absolute;top:16px;right:16px;z-index:50;width:28px;height:28px;border-radius:50%;border:none;background:#f3f4f6;cursor:pointer;display:flex;align-items:center;justify-content:center;color:#6b7280;transition:color .15s,background-color .15s"
|
||||
onmouseover="this.style.color='#374151';this.style.backgroundColor='#e5e7eb'"
|
||||
onmouseout="this.style.color='#6b7280';this.style.backgroundColor='#f3f4f6'"
|
||||
title="{$closeButtonText}">
|
||||
<svg style="width:16px;height:16px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div style="text-align:center;padding:4px 0 8px">
|
||||
<p style="font-size:13px;color:#6b7280;margin:0 0 6px">{$successSubtitle}</p>
|
||||
<h2 style="font-size:23px;font-weight:700;color:#111827;margin:0 0 6px;line-height:1.25">{$successTitle}</h2>
|
||||
<p style="font-size:13px;color:#9ca3af;margin:0">{$successHelper}</p>
|
||||
</div>
|
||||
|
||||
<!-- URL pill -->
|
||||
<div style="display:flex;align-items:center;gap:10px;background:#EFF6FF;border-radius:999px;padding:10px 14px;margin:18px 0 0">
|
||||
<div style="width:30px;height:30px;border-radius:50%;background:#fff;display:flex;align-items:center;justify-content:center;flex-shrink:0;border:1px solid #e5e7eb">
|
||||
<img src="https://icons.duckduckgo.com/ip2/{$destHost}.ico" style="width:16px;height:16px;object-fit:contain" onerror="this.style.display='none'">
|
||||
</div>
|
||||
<span style="flex:1;font-size:14px;font-weight:600;color:#1d4ed8;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{$shortUrl}</span>
|
||||
<div style="display:flex;align-items:center;gap:4px;flex-shrink:0">
|
||||
<button id="{$eid}_copy" type="button"
|
||||
onclick="
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const u='{$shortUrl}';
|
||||
if(navigator.clipboard&&window.isSecureContext){navigator.clipboard.writeText(u);}
|
||||
else{const t=document.createElement('textarea');t.value=u;t.style.cssText='position:fixed;left:-9999px';document.body.appendChild(t);t.select();document.execCommand('copy');t.remove();}
|
||||
const b=document.getElementById('{$eid}_copy');
|
||||
const prev=b.innerHTML;
|
||||
b.innerHTML='<svg style=\'width:16px;height:16px;color:#16a34a\' fill=\'none\' viewBox=\'0 0 24 24\' stroke=\'currentColor\' stroke-width=\'2\'><path stroke-linecap=\'round\' stroke-linejoin=\'round\' d=\'M5 13l4 4L19 7\'/></svg>';
|
||||
setTimeout(()=>b.innerHTML=prev,1800);
|
||||
"
|
||||
style="width:30px;height:30px;border-radius:7px;border:1px solid #e5e7eb;background:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .15s"
|
||||
onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'" title="{$copyLinkText}">
|
||||
<svg style="width:16px;height:16px;color:#6b7280" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path stroke-linecap="round" stroke-linejoin="round" d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
|
||||
</button>
|
||||
<button id="{$eid}_qr_btn" type="button" data-qr-options="{$escapedQrOptions}"
|
||||
onclick="
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const container = document.getElementById('{$eid}_qr_container');
|
||||
const canvas = document.getElementById('{$eid}_qr_canvas');
|
||||
if (container.style.display === 'none') {
|
||||
container.style.display = 'block';
|
||||
const loadScript = () => {
|
||||
return new Promise((resolve) => {
|
||||
if (window.QRCodeStyling) return resolve();
|
||||
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 = () => {
|
||||
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();
|
||||
document.head.appendChild(s2);
|
||||
};
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
};
|
||||
loadScript().then(() => {
|
||||
if (!canvas.innerHTML) {
|
||||
const opts = JSON.parse(this.getAttribute('data-qr-options'));
|
||||
opts.data = '{$qrTargetUrl}';
|
||||
const fixSvg = () => {
|
||||
const svg = canvas.querySelector('svg');
|
||||
if (svg) {
|
||||
const w = svg.getAttribute('width') || opts.width || 200;
|
||||
const h = svg.getAttribute('height') || opts.height || 200;
|
||||
svg.setAttribute('viewBox', '0 0 ' + w + ' ' + h);
|
||||
svg.style.width = '100%';
|
||||
svg.style.height = '100%';
|
||||
}
|
||||
};
|
||||
|
||||
if (opts.image && opts.imageOptions) {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = 1200;
|
||||
cv.height = 1200;
|
||||
const cx = cv.getContext('2d');
|
||||
cx.imageSmoothingEnabled = true;
|
||||
cx.imageSmoothingQuality = 'high';
|
||||
|
||||
const isCircle = opts.imageOptions.logoShape === 'circle';
|
||||
const targetDim = 1200 - (parseFloat(opts.imageOptions.margin || 0) * 20);
|
||||
|
||||
cx.save();
|
||||
if (isCircle) {
|
||||
cx.beginPath();
|
||||
cx.arc(600, 600, targetDim / 2, 0, 2 * Math.PI);
|
||||
cx.clip();
|
||||
|
||||
const scale = Math.max(targetDim / img.width, targetDim / img.height);
|
||||
const w = img.width * scale;
|
||||
const h = img.height * scale;
|
||||
const x = (1200 - w) / 2;
|
||||
const y = (1200 - h) / 2;
|
||||
|
||||
cx.drawImage(img, x, y, w, h);
|
||||
} else {
|
||||
cx.beginPath();
|
||||
const offset = (1200 - targetDim) / 2;
|
||||
const radius = 144 * (targetDim / 1200);
|
||||
|
||||
if (typeof cx.roundRect === 'function') {
|
||||
cx.roundRect(offset, offset, targetDim, targetDim, radius);
|
||||
} else {
|
||||
cx.moveTo(offset + radius, offset);
|
||||
cx.lineTo(offset + targetDim - radius, offset);
|
||||
cx.quadraticCurveTo(offset + targetDim, offset, offset + targetDim, offset + radius);
|
||||
cx.lineTo(offset + targetDim, offset + targetDim - radius);
|
||||
cx.quadraticCurveTo(offset + targetDim, offset + targetDim, offset + targetDim - radius, offset + targetDim);
|
||||
cx.lineTo(offset + radius, offset + targetDim);
|
||||
cx.quadraticCurveTo(offset, offset + targetDim, offset, offset + targetDim - radius);
|
||||
cx.lineTo(offset, offset + radius);
|
||||
cx.quadraticCurveTo(offset, offset, offset + radius, offset);
|
||||
cx.closePath();
|
||||
}
|
||||
cx.clip();
|
||||
|
||||
const scale = Math.max(targetDim / img.width, targetDim / img.height);
|
||||
const w = img.width * scale;
|
||||
const h = img.height * scale;
|
||||
const x = (1200 - w) / 2;
|
||||
const y = (1200 - h) / 2;
|
||||
|
||||
cx.drawImage(img, x, y, w, h);
|
||||
}
|
||||
cx.restore();
|
||||
|
||||
opts.image = cv.toDataURL('image/png');
|
||||
opts.imageOptions.margin = 0;
|
||||
|
||||
|
||||
window['qr_{$eid}'] = new window.QRCodeStyling(opts);
|
||||
window['qr_{$eid}'].append(canvas);
|
||||
fixSvg();
|
||||
};
|
||||
img.src = opts.image;
|
||||
} else {
|
||||
window['qr_{$eid}'] = new window.QRCodeStyling(opts);
|
||||
window['qr_{$eid}'].append(canvas);
|
||||
fixSvg();
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
container.style.display = 'none';
|
||||
}
|
||||
"
|
||||
style="width:30px;height:30px;border-radius:7px;border:1px solid #e5e7eb;background:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .15s"
|
||||
onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'" title="{$qrCodeText}">
|
||||
<svg style="width:16px;height:16px;color:#6b7280" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0 1 3.75 9.375v-4.5ZM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 0 1-1.125-1.125v-4.5ZM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0 1 13.5 9.375v-4.5Z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12h.008v.008H15V12Zm0 3h.008v.008H15V15Zm0 3h.008v.008H15V18Zm3-3h.008v.008H18V15Zm0 3h.008v.008H18V18Zm3-3h.008v.008H21V15Zm0 3h.008v.008H21V18Zm0-6h.008v.008H21V12Zm-3 0h.008v.008H18V12Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<a href="{$shortUrl}" target="_blank" rel="noopener noreferrer"
|
||||
style="width:30px;height:30px;border-radius:7px;border:1px solid #e5e7eb;background:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;text-decoration:none;transition:background .15s"
|
||||
onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'" title="{$openLinkText}">
|
||||
<svg style="width:15px;height:15px;color:#6b7280" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QR Code Container (toggled) -->
|
||||
<div id="{$eid}_qr_container" style="display:none;margin:16px 0 0;text-align:center;background:#f9fafb;border:1px solid #e5e7eb;border-radius:12px;padding:16px">
|
||||
<div style="display:flex;justify-content:center;margin-bottom:12px">
|
||||
<div id="{$eid}_qr_canvas" style="background:#fff;padding:8px;border-radius:8px;box-shadow:0 1px 3px rgba(0,0,0,0.05)"></div>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:center;gap:8px">
|
||||
<button type="button" onclick="event.preventDefault();event.stopPropagation();window['qr_{$eid}']?.download({ name: '{$urlKey}-qr', extension: 'svg' })" style="font-size:12px;font-weight:600;color:#374151;border:1.5px solid #e5e7eb;background:#fff;padding:6px 12px;border-radius:8px;cursor:pointer;transition:background .15s" onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'">{$downloadSvgText}</button>
|
||||
<button type="button" onclick="event.preventDefault();event.stopPropagation();window['qr_{$eid}']?.download({ name: '{$urlKey}-qr', extension: 'png' })" style="font-size:12px;font-weight:600;color:#374151;border:1.5px solid #e5e7eb;background:#fff;padding:6px 12px;border-radius:8px;cursor:pointer;transition:background .15s" onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'">{$downloadPngText}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Social sharing -->
|
||||
<div style="display:flex;align-items:center;justify-content:center;gap:8px;margin:18px 0 0;flex-wrap:wrap">
|
||||
<a href="mailto:?body={$encoded}" target="_blank" rel="noopener" title="Email"
|
||||
style="width:52px;height:52px;border-radius:12px;border:1.5px solid #e5e7eb;background:#fff;display:flex;align-items:center;justify-content:center;text-decoration:none;transition:border-color .15s,box-shadow .15s" onmouseover="this.style.borderColor='#d1d5db';this.style.boxShadow='0 2px 8px rgba(0,0,0,0.07)'" onmouseout="this.style.borderColor='#e5e7eb';this.style.boxShadow='none'">
|
||||
<svg style="width:22px;height:22px;color:#374151" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
|
||||
</a>
|
||||
<a href="https://www.linkedin.com/sharing/share-offsite/?url={$encoded}" target="_blank" rel="noopener" title="LinkedIn"
|
||||
style="width:52px;height:52px;border-radius:12px;border:1.5px solid #e5e7eb;background:#fff;display:flex;align-items:center;justify-content:center;text-decoration:none;transition:border-color .15s,box-shadow .15s" onmouseover="this.style.borderColor='#d1d5db';this.style.boxShadow='0 2px 8px rgba(0,0,0,0.07)'" onmouseout="this.style.borderColor='#e5e7eb';this.style.boxShadow='none'">
|
||||
<svg style="width:22px;height:22px;color:#0077b5" fill="currentColor" viewBox="0 0 24 24"><path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.779-1.75-1.75s.784-1.75 1.75-1.75 1.75.779 1.75 1.75-.784 1.75-1.75 1.75zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z"/></svg>
|
||||
</a>
|
||||
<a href="https://www.facebook.com/sharer/sharer.php?u={$encoded}" target="_blank" rel="noopener" title="Facebook"
|
||||
style="width:52px;height:52px;border-radius:12px;border:1.5px solid #e5e7eb;background:#fff;display:flex;align-items:center;justify-content:center;text-decoration:none;transition:border-color .15s,box-shadow .15s" onmouseover="this.style.borderColor='#d1d5db';this.style.boxShadow='0 2px 8px rgba(0,0,0,0.07)'" onmouseout="this.style.borderColor='#e5e7eb';this.style.boxShadow='none'">
|
||||
<svg style="width:22px;height:22px;color:#1877f2" fill="currentColor" viewBox="0 0 24 24"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
|
||||
</a>
|
||||
<a href="https://twitter.com/intent/tweet?url={$encoded}" target="_blank" rel="noopener" title="X (Twitter)"
|
||||
style="width:52px;height:52px;border-radius:12px;border:1.5px solid #e5e7eb;background:#fff;display:flex;align-items:center;justify-content:center;text-decoration:none;transition:border-color .15s,box-shadow .15s" onmouseover="this.style.borderColor='#d1d5db';this.style.boxShadow='0 2px 8px rgba(0,0,0,0.07)'" onmouseout="this.style.borderColor='#e5e7eb';this.style.boxShadow='none'">
|
||||
<svg style="width:19px;height:19px;color:#0f1419" fill="currentColor" viewBox="0 0 24 24"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
|
||||
</a>
|
||||
<a href="https://api.whatsapp.com/send?text={$encoded}" target="_blank" rel="noopener" title="WhatsApp"
|
||||
style="width:52px;height:52px;border-radius:12px;border:1.5px solid #e5e7eb;background:#fff;display:flex;align-items:center;justify-content:center;text-decoration:none;transition:border-color .15s,box-shadow .15s" onmouseover="this.style.borderColor='#d1d5db';this.style.boxShadow='0 2px 8px rgba(0,0,0,0.07)'" onmouseout="this.style.borderColor='#e5e7eb';this.style.boxShadow='none'">
|
||||
<svg style="width:22px;height:22px;color:#25d366" fill="currentColor" viewBox="0 0 24 24"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L0 24l6.335-1.662c1.746.953 3.71 1.458 5.704 1.459h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/></svg>
|
||||
</a>
|
||||
<a href="https://t.me/share/url?url={$encoded}" target="_blank" rel="noopener" title="Telegram"
|
||||
style="width:52px;height:52px;border-radius:12px;border:1.5px solid #e5e7eb;background:#fff;display:flex;align-items:center;justify-content:center;text-decoration:none;transition:border-color .15s,box-shadow .15s" onmouseover="this.style.borderColor='#d1d5db';this.style.boxShadow='0 2px 8px rgba(0,0,0,0.07)'" onmouseout="this.style.borderColor='#e5e7eb';this.style.boxShadow='none'">
|
||||
<svg style="width:22px;height:22px;color:#229ED9" fill="currentColor" viewBox="0 0 24 24"><path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Don't show again -->
|
||||
<div style="display:flex;justify-content:center;margin:20px 0 2px">
|
||||
<button type="button"
|
||||
x-on:click="localStorage.setItem('fsu:hide-share-modal', '1'); const f=\$el.closest('.fi-modal-window'); const m=f?(f.getAttribute('x-on:keydown.window.escape')||'').match(/'(fi-[^']*)'/):null; if(m){\$dispatch('close-modal',{id:m[1]})}else{\$wire.unmountAction()}"
|
||||
style="font-size:12px;color:#9ca3af;background:none;border:none;cursor:pointer;text-decoration:underline;transition:color .15s"
|
||||
onmouseover="this.style.color='#4b5563'"
|
||||
onmouseout="this.style.color='#9ca3af'">
|
||||
{$dontShowAgainText}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
HTML;
|
||||
|
||||
return [
|
||||
Forms\Components\Placeholder::make('share_modal_content')
|
||||
->hiddenLabel()
|
||||
->content(new HtmlString($html)),
|
||||
];
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getHeaderWidgets(): array
|
||||
{
|
||||
return [];
|
||||
return [
|
||||
ShortUrlGlobalOverview::class,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,35 +3,109 @@
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
|
||||
use Bjanczak\FilamentShortUrl\FilamentShortUrlPlugin;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Services\SafeBrowsingService;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlSettingsManager;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\ColorPicker;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Forms\Components\Repeater;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
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\Pages\Page;
|
||||
use Filament\Schemas\Components\Actions;
|
||||
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;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\HtmlString;
|
||||
|
||||
class ShortUrlSettingsPage extends Page implements HasForms
|
||||
{
|
||||
use InteractsWithForms;
|
||||
|
||||
protected static string $resource = ShortUrlResource::class;
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-cog-6-tooth';
|
||||
|
||||
protected static ?string $slug = 'short-url-settings';
|
||||
|
||||
protected string $view = 'filament-short-url::settings';
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('filament-short-url::default.settings_nav_label') ?? 'Settings';
|
||||
}
|
||||
|
||||
public static function getNavigationGroup(): string|\UnitEnum|null
|
||||
{
|
||||
try {
|
||||
return ShortUrlResource::getNavigationGroup();
|
||||
} catch (\Throwable) {
|
||||
return __('filament-short-url::default.navigation_group');
|
||||
}
|
||||
}
|
||||
|
||||
public static function getNavigationSort(): ?int
|
||||
{
|
||||
try {
|
||||
return ShortUrlResource::getNavigationSort() + 2;
|
||||
} catch (\Throwable) {
|
||||
return 52;
|
||||
}
|
||||
}
|
||||
|
||||
public static function canAccess(array $parameters = []): bool
|
||||
{
|
||||
try {
|
||||
$plugin = FilamentShortUrlPlugin::get();
|
||||
|
||||
if ($callback = $plugin->getAuthorizeSettingsUsing()) {
|
||||
return (bool) app()->call($callback);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Ignore if plugin is not registered yet in some contexts
|
||||
}
|
||||
|
||||
// Fallback: Check if there's a Model Policy with `manageSettings` method
|
||||
if (Gate::getPolicyFor(ShortUrl::class) &&
|
||||
method_exists(Gate::getPolicyFor(ShortUrl::class), 'manageSettings')) {
|
||||
return Gate::allows('manageSettings', ShortUrl::class);
|
||||
}
|
||||
|
||||
// Default fallback: Check if the user is authorized to view the resource in general
|
||||
return ShortUrlResource::canViewAny();
|
||||
}
|
||||
|
||||
public ?array $data = [];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$mgr = app(ShortUrlSettingsManager::class);
|
||||
|
||||
$aasa = $mgr->get('aasa_json');
|
||||
if (! empty($aasa)) {
|
||||
$decoded = json_decode($aasa, true);
|
||||
if (json_last_error() === JSON_ERROR_NONE) {
|
||||
$aasa = json_encode($decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
}
|
||||
|
||||
$assetlinks = $mgr->get('assetlinks_json');
|
||||
if (! empty($assetlinks)) {
|
||||
$decoded = json_decode($assetlinks, true);
|
||||
if (json_last_error() === JSON_ERROR_NONE) {
|
||||
$assetlinks = json_encode($decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
}
|
||||
|
||||
$this->form->fill([
|
||||
'route_prefix' => $mgr->get('route_prefix', 's'),
|
||||
'redirect_status_code' => $mgr->get('redirect_status_code', 302),
|
||||
@@ -41,10 +115,54 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
'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')),
|
||||
'maxmind_database_path' => $mgr->get('maxmind_database_path', storage_path('geoip/GeoLite2-Country.mmdb')),
|
||||
'geo_ip_stats_cache_ttl' => $mgr->get('geo_ip_stats_cache_ttl', 300),
|
||||
'queue_connection' => $mgr->get('queue_connection', 'sync'),
|
||||
'queue_name' => $mgr->get('queue_name', 'default'),
|
||||
'ga4_api_secret' => $mgr->get('ga4_api_secret'),
|
||||
'ga4_firebase_app_id' => $mgr->get('ga4_firebase_app_id'),
|
||||
'counter_buffering_enabled' => $mgr->get('counter_buffering_enabled', false),
|
||||
'trust_cdn_headers' => $mgr->get('trust_cdn_headers', false),
|
||||
'pruning_enabled' => $mgr->get('pruning_enabled', true),
|
||||
'pruning_retention_days' => $mgr->get('pruning_retention_days', 90),
|
||||
'rate_limiting_enabled' => $mgr->get('rate_limiting_enabled', false),
|
||||
'rate_limiting_max_attempts' => $mgr->get('rate_limiting_max_attempts', 60),
|
||||
'rate_limiting_decay_seconds' => $mgr->get('rate_limiting_decay_seconds', 60),
|
||||
'tracking_enabled' => $mgr->get('tracking_enabled', true),
|
||||
'tracking_fields_ip_address' => $mgr->get('tracking_fields_ip_address', true),
|
||||
'tracking_fields_browser' => $mgr->get('tracking_fields_browser', true),
|
||||
'tracking_fields_browser_version' => $mgr->get('tracking_fields_browser_version', true),
|
||||
'tracking_fields_operating_system' => $mgr->get('tracking_fields_operating_system', true),
|
||||
'tracking_fields_operating_system_version' => $mgr->get('tracking_fields_operating_system_version', true),
|
||||
'tracking_fields_referer_url' => $mgr->get('tracking_fields_referer_url', true),
|
||||
'tracking_fields_device_type' => $mgr->get('tracking_fields_device_type', true),
|
||||
'tracking_fields_browser_language' => $mgr->get('tracking_fields_browser_language', true),
|
||||
'qr_size' => $mgr->get('qr_size', 300),
|
||||
'qr_margin' => $mgr->get('qr_margin', 1),
|
||||
'qr_dot_style' => $mgr->get('qr_dot_style', 'square'),
|
||||
'qr_foreground_color' => $mgr->get('qr_foreground_color', '#000000'),
|
||||
'qr_background_color' => $mgr->get('qr_background_color', '#ffffff'),
|
||||
'qr_gradient_enabled' => $mgr->get('qr_gradient_enabled', false),
|
||||
'qr_gradient_from' => $mgr->get('qr_gradient_from', '#4f46e5'),
|
||||
'qr_gradient_to' => $mgr->get('qr_gradient_to', '#06b6d4'),
|
||||
'qr_gradient_type' => $mgr->get('qr_gradient_type', 'linear'),
|
||||
'global_webhook_url' => $mgr->get('global_webhook_url'),
|
||||
'webhook_events' => $mgr->get('webhook_events', ['visited']),
|
||||
'global_webhook_enabled' => $mgr->get('global_webhook_enabled', false),
|
||||
'api_keys' => $mgr->get('api_keys', []),
|
||||
'api_enabled' => $mgr->get('api_enabled', false),
|
||||
'site_name' => $mgr->get('site_name'),
|
||||
// Security v2.0
|
||||
'vpn_detection_enabled' => $mgr->get('vpn_detection_enabled', false),
|
||||
'vpn_detection_driver' => $mgr->get('vpn_detection_driver', 'ip-api'),
|
||||
'vpnapi_key' => $mgr->get('vpnapi_key'),
|
||||
'vpn_block_action' => $mgr->get('vpn_block_action', 'flag_only'),
|
||||
'safe_browsing_enabled' => $mgr->get('safe_browsing_enabled', false),
|
||||
'google_safe_browsing_api_key' => $mgr->get('google_safe_browsing_api_key'),
|
||||
// Deep Linking v2.1
|
||||
'deep_linking_enabled' => $mgr->get('deep_linking_enabled', false),
|
||||
'aasa_json' => $aasa,
|
||||
'assetlinks_json' => $assetlinks,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -63,15 +181,24 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
Section::make(__('filament-short-url::default.settings_section_routing'))
|
||||
->columns(2)
|
||||
->schema([
|
||||
TextInput::make('site_name')
|
||||
->label(__('filament-short-url::default.settings_site_name'))
|
||||
->helperText(__('filament-short-url::default.settings_site_name_helper'))
|
||||
->nullable()
|
||||
->maxLength(100)
|
||||
->columnSpanFull(),
|
||||
|
||||
TextInput::make('route_prefix')
|
||||
->label(__('filament-short-url::default.settings_route_prefix'))
|
||||
->helperText(__('filament-short-url::default.settings_route_prefix_helper'))
|
||||
->required()
|
||||
->prefix(new HtmlString('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" style="display: inline-block; vertical-align: middle; margin-right: 6px; margin-top: -3px; width: 15px; height: 15px;" class="text-emerald-600 dark:text-emerald-500"><path fill-rule="evenodd" d="M10 1a4.5 4.5 0 00-4.5 4.5V9H5a2 2 0 00-2 2v7a2 2 0 00 2 2h10a2 2 0 00 2-2v-7a2 2 0 00-2-2h-.5V5.5A4.5 4.5 0 00 10 1zm3 8V5.5a3 3 0 10-6 0V9h6z" clip-rule="evenodd" /></svg>https://'.request()->getHost().'/'))
|
||||
->nullable()
|
||||
->alphaDash()
|
||||
->maxLength(20),
|
||||
|
||||
Select::make('redirect_status_code')
|
||||
->label(__('filament-short-url::default.redirect_code'))
|
||||
->helperText(__('filament-short-url::default.settings_redirect_code_helper'))
|
||||
->options([
|
||||
302 => __('filament-short-url::default.redirect_code_302'),
|
||||
301 => __('filament-short-url::default.redirect_code_301'),
|
||||
@@ -81,21 +208,42 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
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)
|
||||
->rules(['required', 'integer', 'between:3,20'])
|
||||
->extraInputAttributes([
|
||||
'maxlength' => 2,
|
||||
'oninput' => "this.value = this.value.replace(/[^0-9]/g, ''); if(this.value !== '') { let val = parseInt(this.value); if(val > 20) this.value = 20; }",
|
||||
'onblur' => "if(this.value !== '') { let val = parseInt(this.value); if(val < 3) this.value = 3; if(val > 20) this.value = 20; }",
|
||||
])
|
||||
->required(),
|
||||
|
||||
TextInput::make('cache_ttl')
|
||||
->label(__('filament-short-url::default.settings_cache_ttl'))
|
||||
->helperText(__('filament-short-url::default.settings_cache_ttl_helper'))
|
||||
->numeric()
|
||||
->integer()
|
||||
->minValue(0)
|
||||
->suffix('s')
|
||||
->required(),
|
||||
|
||||
Toggle::make('trust_cdn_headers')
|
||||
->label(__('filament-short-url::default.settings_trust_cdn_headers'))
|
||||
->helperText(__('filament-short-url::default.settings_trust_cdn_headers_helper'))
|
||||
->columnSpanFull()
|
||||
->inline(false)
|
||||
->live(),
|
||||
|
||||
Placeholder::make('trust_cdn_headers_info')
|
||||
->content(function () {
|
||||
$html = __('filament-short-url::default.settings_trust_cdn_headers_info_callout');
|
||||
|
||||
return new HtmlString($html);
|
||||
})
|
||||
->visible(fn (Get $get): bool => (bool) $get('trust_cdn_headers'))
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
|
||||
Section::make(__('filament-short-url::default.settings_section_queue'))
|
||||
->columns(2)
|
||||
->schema([
|
||||
Select::make('queue_connection')
|
||||
->label(__('filament-short-url::default.settings_queue_connection'))
|
||||
@@ -109,7 +257,43 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
'redis' => 'redis',
|
||||
];
|
||||
})
|
||||
->required(),
|
||||
->required()
|
||||
->live(),
|
||||
|
||||
TextInput::make('queue_name')
|
||||
->label(__('filament-short-url::default.settings_queue_name'))
|
||||
->helperText(__('filament-short-url::default.settings_queue_name_helper'))
|
||||
->default('default')
|
||||
->required(fn (Get $get): bool => $get('queue_connection') !== 'sync')
|
||||
->visible(fn (Get $get): bool => $get('queue_connection') !== 'sync'),
|
||||
|
||||
Placeholder::make('queue_worker_info')
|
||||
->content(function (Get $get) {
|
||||
$queueName = $get('queue_name') ?: 'default';
|
||||
$html = __('filament-short-url::default.settings_queue_worker_info', ['queue' => $queueName]);
|
||||
|
||||
return new HtmlString($html);
|
||||
})
|
||||
->visible(fn (Get $get): bool => $get('queue_connection') !== 'sync')
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
|
||||
Section::make(__('filament-short-url::default.settings_section_buffering'))
|
||||
->schema([
|
||||
Toggle::make('counter_buffering_enabled')
|
||||
->label(__('filament-short-url::default.settings_buffering_enabled'))
|
||||
->helperText(__('filament-short-url::default.settings_buffering_helper'))
|
||||
->inline(false)
|
||||
->live(),
|
||||
|
||||
Placeholder::make('counter_buffering_info')
|
||||
->content(function () {
|
||||
$html = __('filament-short-url::default.settings_buffering_worker_info');
|
||||
|
||||
return new HtmlString($html);
|
||||
})
|
||||
->visible(fn (Get $get): bool => (bool) $get('counter_buffering_enabled'))
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
]),
|
||||
|
||||
@@ -140,12 +324,38 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
->live()
|
||||
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled')),
|
||||
|
||||
Placeholder::make('geoip_headers_warning')
|
||||
->content(function () {
|
||||
$html = __('filament-short-url::default.settings_geoip_headers_warning');
|
||||
|
||||
return new HtmlString($html);
|
||||
})
|
||||
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled') &&
|
||||
$get('geo_ip_driver') === 'headers' &&
|
||||
! (bool) $get('trust_cdn_headers')
|
||||
)
|
||||
->columnSpanFull(),
|
||||
|
||||
// ── 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()
|
||||
->integer()
|
||||
->minValue(0)
|
||||
->maxValue(31536000)
|
||||
->suffix('s')
|
||||
->required()
|
||||
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled')),
|
||||
|
||||
// ── Stats Cache TTL (only when geo-ip is on) ──
|
||||
TextInput::make('geo_ip_stats_cache_ttl')
|
||||
->label(__('filament-short-url::default.settings_geoip_stats_cache_ttl'))
|
||||
->helperText(__('filament-short-url::default.settings_geoip_stats_cache_ttl_helper'))
|
||||
->numeric()
|
||||
->integer()
|
||||
->minValue(0)
|
||||
->maxValue(86400)
|
||||
->suffix('s')
|
||||
->required()
|
||||
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled')),
|
||||
@@ -155,6 +365,7 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
->label(__('filament-short-url::default.settings_geoip_timeout'))
|
||||
->helperText(__('filament-short-url::default.settings_geoip_timeout_helper'))
|
||||
->numeric()
|
||||
->integer()
|
||||
->minValue(1)
|
||||
->maxValue(30)
|
||||
->suffix('s')
|
||||
@@ -162,11 +373,55 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled') && $get('geo_ip_driver') === 'ip-api'),
|
||||
|
||||
// ── MaxMind path (only for maxmind driver) ──
|
||||
Placeholder::make('maxmind_info')
|
||||
->content(function () {
|
||||
$html = __('filament-short-url::default.settings_maxmind_info_callout');
|
||||
|
||||
return new HtmlString($html);
|
||||
})
|
||||
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled') && $get('geo_ip_driver') === 'maxmind')
|
||||
->columnSpanFull(),
|
||||
|
||||
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'))
|
||||
->placeholder('/var/www/html/database/geoip/GeoLite2-Country.mmdb')
|
||||
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled') && $get('geo_ip_driver') === 'maxmind'),
|
||||
|
||||
Actions::make([
|
||||
Action::make('verifyMaxmindPath')
|
||||
->label(__('filament-short-url::default.settings_maxmind_verify'))
|
||||
->icon('heroicon-o-check-circle')
|
||||
->color('gray')
|
||||
->action(function (Get $get): void {
|
||||
$path = trim($get('maxmind_database_path') ?? '');
|
||||
|
||||
if (empty($path)) {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_maxmind_verify_empty'))
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (file_exists($path) && is_readable($path) && str_ends_with($path, '.mmdb')) {
|
||||
$sizeKb = round(filesize($path) / 1024);
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_maxmind_verify_ok'))
|
||||
->body("{$path} ({$sizeKb} KB)")
|
||||
->success()
|
||||
->send();
|
||||
} else {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_maxmind_verify_fail'))
|
||||
->body($path)
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
}),
|
||||
])
|
||||
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled') && $get('geo_ip_driver') === 'maxmind'),
|
||||
]),
|
||||
]),
|
||||
@@ -183,15 +438,494 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
->helperText(__('filament-short-url::default.settings_ga4_api_secret_helper'))
|
||||
->password()
|
||||
->revealable()
|
||||
->live()
|
||||
->placeholder('••••••••••••••••••••'),
|
||||
|
||||
// Firebase App ID only when API Secret is set
|
||||
// Firebase App ID — always visible
|
||||
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'))),
|
||||
->placeholder('1:1234567890:android:abcdef123456'),
|
||||
|
||||
// GA4 connection verify button
|
||||
Actions::make([
|
||||
Action::make('verifyGa4ApiSecret')
|
||||
->label(__('filament-short-url::default.settings_ga4_verify'))
|
||||
->icon('heroicon-o-signal')
|
||||
->color('gray')
|
||||
->action(function (Get $get): void {
|
||||
$secret = trim($get('ga4_api_secret') ?? '');
|
||||
|
||||
if (empty($secret)) {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_ga4_verify_empty'))
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// GA4 debug endpoint — a valid api_secret returns event-level
|
||||
// validation messages; an invalid one returns auth errors.
|
||||
$response = Http::timeout(5)
|
||||
->withHeaders(['Content-Type' => 'application/json'])
|
||||
->post(
|
||||
'https://www.google-analytics.com/debug/mp/collect?measurement_id=G-XXXXXXXXXX&api_secret='.urlencode($secret),
|
||||
[
|
||||
'client_id' => 'short-url-plugin-verify',
|
||||
'events' => [
|
||||
['name' => 'page_view', 'params' => []],
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
$body = $response->json();
|
||||
$messages = $body['validationMessages'] ?? [];
|
||||
|
||||
// A valid secret returns event-level messages, not auth errors
|
||||
$hasAuthError = collect($messages)->contains(fn ($m) => str_contains(
|
||||
strtolower($m['description'] ?? ''),
|
||||
'api_secret'
|
||||
));
|
||||
|
||||
if ($hasAuthError || $response->status() === 401) {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_ga4_verify_fail'))
|
||||
->danger()
|
||||
->send();
|
||||
} else {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_ga4_verify_ok'))
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_ga4_verify_error'))
|
||||
->body($e->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
}),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
|
||||
// ── Advanced & Security ──────────────────────────────
|
||||
Tab::make(__('filament-short-url::default.settings_tab_advanced'))
|
||||
->icon('heroicon-o-adjustments-horizontal')
|
||||
->schema([
|
||||
Section::make(__('filament-short-url::default.settings_section_aggregation'))
|
||||
->columns(2)
|
||||
->schema([
|
||||
Toggle::make('pruning_enabled')
|
||||
->label(__('filament-short-url::default.settings_aggregation_enabled'))
|
||||
->helperText(__('filament-short-url::default.settings_aggregation_enabled_helper'))
|
||||
->default(true)
|
||||
->live()
|
||||
->inline(false),
|
||||
|
||||
Select::make('pruning_retention_days')
|
||||
->label(__('filament-short-url::default.settings_retention_days'))
|
||||
->helperText(__('filament-short-url::default.settings_retention_days_helper'))
|
||||
->options([
|
||||
30 => __('filament-short-url::default.retention_30_days'),
|
||||
60 => __('filament-short-url::default.retention_60_days'),
|
||||
90 => __('filament-short-url::default.retention_90_days'),
|
||||
180 => __('filament-short-url::default.retention_180_days'),
|
||||
365 => __('filament-short-url::default.retention_365_days'),
|
||||
730 => __('filament-short-url::default.retention_730_days'),
|
||||
])
|
||||
->required()
|
||||
->visible(fn (Get $get): bool => (bool) $get('pruning_enabled')),
|
||||
]),
|
||||
|
||||
Section::make(__('filament-short-url::default.settings_section_rate_limiting'))
|
||||
->columns(3)
|
||||
->schema([
|
||||
Toggle::make('rate_limiting_enabled')
|
||||
->label(__('filament-short-url::default.settings_rate_limiting_enabled'))
|
||||
->helperText(__('filament-short-url::default.settings_rate_limiting_enabled_helper'))
|
||||
->default(false)
|
||||
->live()
|
||||
->inline(false),
|
||||
|
||||
TextInput::make('rate_limiting_max_attempts')
|
||||
->label(__('filament-short-url::default.settings_rate_limiting_max_attempts'))
|
||||
->helperText(__('filament-short-url::default.settings_rate_limiting_max_attempts_helper'))
|
||||
->numeric()
|
||||
->integer()
|
||||
->minValue(1)
|
||||
->required()
|
||||
->visible(fn (Get $get): bool => (bool) $get('rate_limiting_enabled')),
|
||||
|
||||
TextInput::make('rate_limiting_decay_seconds')
|
||||
->label(__('filament-short-url::default.settings_rate_limiting_decay_seconds'))
|
||||
->helperText(__('filament-short-url::default.settings_rate_limiting_decay_seconds_helper'))
|
||||
->numeric()
|
||||
->integer()
|
||||
->minValue(1)
|
||||
->suffix('s')
|
||||
->required()
|
||||
->visible(fn (Get $get): bool => (bool) $get('rate_limiting_enabled')),
|
||||
]),
|
||||
|
||||
Section::make(__('filament-short-url::default.settings_section_security_v2'))
|
||||
->description(__('filament-short-url::default.settings_section_security_v2_desc'))
|
||||
->columns(2)
|
||||
->schema([
|
||||
Toggle::make('vpn_detection_enabled')
|
||||
->label(__('filament-short-url::default.settings_vpn_detection_enabled'))
|
||||
->helperText(__('filament-short-url::default.settings_vpn_detection_enabled_helper'))
|
||||
->default(false)
|
||||
->inline(false)
|
||||
->live()
|
||||
->columnSpanFull(),
|
||||
|
||||
Select::make('vpn_detection_driver')
|
||||
->label(__('filament-short-url::default.settings_vpn_driver'))
|
||||
->helperText(__('filament-short-url::default.settings_vpn_driver_helper'))
|
||||
->options([
|
||||
'ip-api' => __('filament-short-url::default.settings_vpn_driver_ipapi'),
|
||||
'vpnapi' => __('filament-short-url::default.settings_vpn_driver_vpnapi'),
|
||||
])
|
||||
->default('ip-api')
|
||||
->live()
|
||||
->required()
|
||||
->visible(fn (Get $get): bool => (bool) $get('vpn_detection_enabled')),
|
||||
|
||||
TextInput::make('vpnapi_key')
|
||||
->label(__('filament-short-url::default.settings_vpnapi_key'))
|
||||
->helperText(__('filament-short-url::default.settings_vpnapi_key_helper'))
|
||||
->password()
|
||||
->revealable()
|
||||
->placeholder('••••••••••••••••••••')
|
||||
->visible(fn (Get $get): bool => (bool) $get('vpn_detection_enabled') && $get('vpn_detection_driver') === 'vpnapi'),
|
||||
|
||||
Select::make('vpn_block_action')
|
||||
->label(__('filament-short-url::default.settings_vpn_block_action'))
|
||||
->helperText(__('filament-short-url::default.settings_vpn_block_action_helper'))
|
||||
->options([
|
||||
'flag_only' => __('filament-short-url::default.settings_vpn_block_flag_only'),
|
||||
'block_with_403' => __('filament-short-url::default.settings_vpn_block_block_403'),
|
||||
])
|
||||
->default('flag_only')
|
||||
->required()
|
||||
->visible(fn (Get $get): bool => (bool) $get('vpn_detection_enabled')),
|
||||
|
||||
Toggle::make('safe_browsing_enabled')
|
||||
->label(__('filament-short-url::default.settings_safe_browsing_enabled'))
|
||||
->helperText(__('filament-short-url::default.settings_safe_browsing_enabled_helper'))
|
||||
->default(false)
|
||||
->inline(false)
|
||||
->live()
|
||||
->columnSpanFull(),
|
||||
|
||||
TextInput::make('google_safe_browsing_api_key')
|
||||
->label(__('filament-short-url::default.settings_safe_browsing_api_key'))
|
||||
->helperText(__('filament-short-url::default.settings_safe_browsing_api_key_helper'))
|
||||
->password()
|
||||
->revealable()
|
||||
->placeholder('AIza••••••••••••••••••')
|
||||
->columnSpanFull()
|
||||
->suffixAction(
|
||||
Action::make('testSafeBrowsing')
|
||||
->label(__('filament-short-url::default.settings_safe_browsing_test'))
|
||||
->icon('heroicon-o-signal')
|
||||
->color('gray')
|
||||
->action(function (Get $get): void {
|
||||
$key = trim($get('google_safe_browsing_api_key') ?? '');
|
||||
if (empty($key)) {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_safe_browsing_test_empty'))
|
||||
->warning()->send();
|
||||
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$svc = app(SafeBrowsingService::class);
|
||||
$safe = $svc->isSafeWithKey('https://google.com', $key);
|
||||
Notification::make()
|
||||
->title($safe
|
||||
? __('filament-short-url::default.settings_safe_browsing_test_ok')
|
||||
: __('filament-short-url::default.settings_safe_browsing_test_fail'))
|
||||
->color($safe ? 'success' : 'danger')
|
||||
->send();
|
||||
} catch (\Throwable $e) {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_safe_browsing_test_error'))
|
||||
->body($e->getMessage())
|
||||
->danger()->send();
|
||||
}
|
||||
})
|
||||
)
|
||||
->visible(fn (Get $get): bool => (bool) $get('safe_browsing_enabled')),
|
||||
]),
|
||||
]),
|
||||
|
||||
// ── Tracking Defaults ─────────────────────────────────
|
||||
Tab::make(__('filament-short-url::default.settings_tab_tracking_defaults'))
|
||||
->icon('heroicon-o-chart-bar')
|
||||
->schema([
|
||||
Section::make(__('filament-short-url::default.settings_section_tracking_defaults'))
|
||||
->description(__('filament-short-url::default.settings_section_tracking_defaults_helper'))
|
||||
->schema([
|
||||
Toggle::make('tracking_enabled')
|
||||
->label(__('filament-short-url::default.settings_track_visits_default'))
|
||||
->live()
|
||||
->inline(false)
|
||||
->columnSpanFull(),
|
||||
|
||||
Toggle::make('tracking_fields_ip_address')
|
||||
->label(__('filament-short-url::default.settings_track_ip_default'))
|
||||
->inline(false)
|
||||
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
|
||||
|
||||
Toggle::make('tracking_fields_browser')
|
||||
->label(__('filament-short-url::default.settings_track_browser_default'))
|
||||
->inline(false)
|
||||
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
|
||||
|
||||
Toggle::make('tracking_fields_browser_version')
|
||||
->label(__('filament-short-url::default.settings_track_browser_version_default'))
|
||||
->inline(false)
|
||||
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
|
||||
|
||||
Toggle::make('tracking_fields_operating_system')
|
||||
->label(__('filament-short-url::default.settings_track_os_default'))
|
||||
->inline(false)
|
||||
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
|
||||
|
||||
Toggle::make('tracking_fields_operating_system_version')
|
||||
->label(__('filament-short-url::default.settings_track_os_version_default'))
|
||||
->inline(false)
|
||||
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
|
||||
|
||||
Toggle::make('tracking_fields_browser_language')
|
||||
->label(__('filament-short-url::default.settings_track_browser_language_default'))
|
||||
->inline(false)
|
||||
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
|
||||
|
||||
Toggle::make('tracking_fields_referer_url')
|
||||
->label(__('filament-short-url::default.settings_track_referer_default'))
|
||||
->inline(false)
|
||||
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
|
||||
|
||||
Toggle::make('tracking_fields_device_type')
|
||||
->label(__('filament-short-url::default.settings_track_device_type_default'))
|
||||
->inline(false)
|
||||
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
|
||||
])
|
||||
->columns(4),
|
||||
]),
|
||||
|
||||
// ── QR Defaults ──────────────────────────────────────
|
||||
Tab::make(__('filament-short-url::default.settings_tab_qr'))
|
||||
->icon('heroicon-o-qr-code')
|
||||
->schema([
|
||||
Section::make(__('filament-short-url::default.settings_section_qr_defaults'))
|
||||
->description(__('filament-short-url::default.settings_section_qr_defaults_helper'))
|
||||
->columns(2)
|
||||
->schema([
|
||||
TextInput::make('qr_size')
|
||||
->label(__('filament-short-url::default.settings_qr_size'))
|
||||
->numeric()
|
||||
->integer()
|
||||
->minValue(100)
|
||||
->maxValue(2000)
|
||||
->required(),
|
||||
|
||||
Select::make('qr_margin')
|
||||
->label(__('filament-short-url::default.settings_qr_margin'))
|
||||
->options(array_combine(range(0, 10), range(0, 10)))
|
||||
->required(),
|
||||
|
||||
Select::make('qr_dot_style')
|
||||
->label(__('filament-short-url::default.settings_qr_dot_style'))
|
||||
->options([
|
||||
'square' => __('filament-short-url::default.qr_option_square'),
|
||||
'dots' => __('filament-short-url::default.qr_option_dots'),
|
||||
'rounded' => __('filament-short-url::default.qr_option_rounded'),
|
||||
'classy' => __('filament-short-url::default.qr_option_classy'),
|
||||
'classy-rounded' => __('filament-short-url::default.qr_option_classy_rounded'),
|
||||
'extra-rounded' => __('filament-short-url::default.qr_option_extra_rounded'),
|
||||
])
|
||||
->required(),
|
||||
|
||||
ColorPicker::make('qr_foreground_color')
|
||||
->label(__('filament-short-url::default.settings_qr_foreground_color'))
|
||||
->regex('/^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})\b$/')
|
||||
->placeholder('#000000')
|
||||
->required(),
|
||||
|
||||
ColorPicker::make('qr_background_color')
|
||||
->label(__('filament-short-url::default.settings_qr_background_color'))
|
||||
->regex('/^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})\b$/')
|
||||
->placeholder('#ffffff')
|
||||
->required(),
|
||||
|
||||
Toggle::make('qr_gradient_enabled')
|
||||
->label(__('filament-short-url::default.settings_qr_gradient_enabled'))
|
||||
->live()
|
||||
->inline(false),
|
||||
|
||||
ColorPicker::make('qr_gradient_from')
|
||||
->label(__('filament-short-url::default.settings_qr_gradient_from'))
|
||||
->regex('/^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})\b$/')
|
||||
->placeholder('#4f46e5')
|
||||
->required(fn (Get $get): bool => (bool) $get('qr_gradient_enabled'))
|
||||
->visible(fn (Get $get): bool => (bool) $get('qr_gradient_enabled')),
|
||||
|
||||
ColorPicker::make('qr_gradient_to')
|
||||
->label(__('filament-short-url::default.settings_qr_gradient_to'))
|
||||
->regex('/^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})\b$/')
|
||||
->placeholder('#06b6d4')
|
||||
->required(fn (Get $get): bool => (bool) $get('qr_gradient_enabled'))
|
||||
->visible(fn (Get $get): bool => (bool) $get('qr_gradient_enabled')),
|
||||
|
||||
Select::make('qr_gradient_type')
|
||||
->label(__('filament-short-url::default.settings_qr_gradient_type'))
|
||||
->options([
|
||||
'linear' => __('filament-short-url::default.qr_gradient_linear'),
|
||||
'radial' => __('filament-short-url::default.qr_gradient_radial'),
|
||||
])
|
||||
->required(fn (Get $get): bool => (bool) $get('qr_gradient_enabled'))
|
||||
->visible(fn (Get $get): bool => (bool) $get('qr_gradient_enabled')),
|
||||
]),
|
||||
]),
|
||||
|
||||
// ── Developer API & Webhooks ────────────────────────
|
||||
Tab::make(__('filament-short-url::default.settings_tab_developer'))
|
||||
->icon('heroicon-o-cpu-chip')
|
||||
->schema([
|
||||
Section::make(__('filament-short-url::default.settings_section_rest_api'))
|
||||
->schema([
|
||||
Toggle::make('api_enabled')
|
||||
->label(__('filament-short-url::default.settings_api_enabled'))
|
||||
->helperText(__('filament-short-url::default.settings_api_enabled_helper'))
|
||||
->default(false)
|
||||
->inline(false)
|
||||
->columnSpanFull()
|
||||
->live(),
|
||||
]),
|
||||
|
||||
Section::make(__('filament-short-url::default.settings_section_api_keys'))
|
||||
->description(__('filament-short-url::default.settings_api_keys_description'))
|
||||
->visible(fn (Get $get): bool => (bool) $get('api_enabled'))
|
||||
->schema([
|
||||
Repeater::make('api_keys')
|
||||
->label(__('filament-short-url::default.settings_api_keys'))
|
||||
->schema([
|
||||
TextInput::make('name')
|
||||
->label(__('filament-short-url::default.api_key_name'))
|
||||
->required(),
|
||||
TextInput::make('key')
|
||||
->label(__('filament-short-url::default.api_key'))
|
||||
->disabled()
|
||||
->dehydrated()
|
||||
->default(fn () => 'sh_key_'.bin2hex(random_bytes(16))),
|
||||
Toggle::make('is_active')
|
||||
->label(__('filament-short-url::default.active'))
|
||||
->default(true),
|
||||
])
|
||||
->columns(3)
|
||||
->default([]),
|
||||
]),
|
||||
|
||||
Section::make(__('filament-short-url::default.settings_section_global_webhook'))
|
||||
->schema([
|
||||
Toggle::make('global_webhook_enabled')
|
||||
->label(__('filament-short-url::default.settings_global_webhook_enabled'))
|
||||
->helperText(__('filament-short-url::default.settings_global_webhook_enabled_helper'))
|
||||
->live()
|
||||
->inline(false)
|
||||
->afterStateUpdated(function (bool $state, $set) {
|
||||
if (! $state) {
|
||||
$set('global_webhook_url', null);
|
||||
$set('webhook_events', ['visited']);
|
||||
}
|
||||
}),
|
||||
|
||||
TextInput::make('global_webhook_url')
|
||||
->label(__('filament-short-url::default.settings_global_webhook_url'))
|
||||
->helperText(__('filament-short-url::default.settings_global_webhook_url_helper'))
|
||||
->url()
|
||||
->nullable()
|
||||
->columnSpanFull()
|
||||
->visible(fn (Get $get): bool => (bool) $get('global_webhook_enabled')),
|
||||
|
||||
Select::make('webhook_events')
|
||||
->label(__('filament-short-url::default.settings_webhook_events'))
|
||||
->helperText(__('filament-short-url::default.settings_webhook_events_helper'))
|
||||
->multiple()
|
||||
->options([
|
||||
'visited' => __('filament-short-url::default.webhook_event_visited'),
|
||||
'created' => __('filament-short-url::default.webhook_event_created'),
|
||||
'expired' => __('filament-short-url::default.webhook_event_expired'),
|
||||
'limit_reached' => __('filament-short-url::default.webhook_event_limit_reached'),
|
||||
])
|
||||
->default(['visited'])
|
||||
->columnSpanFull()
|
||||
->visible(fn (Get $get): bool => (bool) $get('global_webhook_enabled')),
|
||||
]),
|
||||
]),
|
||||
|
||||
// ── Deep Linking ─────────────────────────────────────
|
||||
Tab::make(__('filament-short-url::default.settings_tab_deep_linking'))
|
||||
->icon('heroicon-o-device-phone-mobile')
|
||||
->schema([
|
||||
Section::make(__('filament-short-url::default.settings_section_deep_linking'))
|
||||
->schema([
|
||||
Toggle::make('deep_linking_enabled')
|
||||
->label(__('filament-short-url::default.settings_deep_linking_enabled'))
|
||||
->helperText(__('filament-short-url::default.settings_deep_linking_enabled_helper'))
|
||||
->default(false)
|
||||
->inline(false)
|
||||
->live(),
|
||||
|
||||
Textarea::make('aasa_json')
|
||||
->label(__('filament-short-url::default.settings_aasa_json'))
|
||||
->helperText(__('filament-short-url::default.settings_aasa_json_helper'))
|
||||
->nullable()
|
||||
->visible(fn (Get $get): bool => (bool) $get('deep_linking_enabled'))
|
||||
->columnSpanFull()
|
||||
->extraInputAttributes(['style' => 'font-family: monospace;'])
|
||||
->rows(8)
|
||||
->rules([
|
||||
function () {
|
||||
return function (string $attribute, $value, \Closure $fail) {
|
||||
if (empty($value)) {
|
||||
return;
|
||||
}
|
||||
json_decode($value);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
$fail(__('filament-short-url::default.validation_invalid_json'));
|
||||
}
|
||||
};
|
||||
},
|
||||
]),
|
||||
|
||||
Textarea::make('assetlinks_json')
|
||||
->label(__('filament-short-url::default.settings_assetlinks_json'))
|
||||
->helperText(__('filament-short-url::default.settings_assetlinks_json_helper'))
|
||||
->nullable()
|
||||
->visible(fn (Get $get): bool => (bool) $get('deep_linking_enabled'))
|
||||
->columnSpanFull()
|
||||
->extraInputAttributes(['style' => 'font-family: monospace;'])
|
||||
->rows(8)
|
||||
->rules([
|
||||
function () {
|
||||
return function (string $attribute, $value, \Closure $fail) {
|
||||
if (empty($value)) {
|
||||
return;
|
||||
}
|
||||
json_decode($value);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
$fail(__('filament-short-url::default.validation_invalid_json'));
|
||||
}
|
||||
};
|
||||
},
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
@@ -205,6 +939,10 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
|
||||
app(ShortUrlSettingsManager::class)->set($data);
|
||||
|
||||
// Clear deep linking cache when settings are saved!
|
||||
cache()->forget('fsu:deep-linking:aasa');
|
||||
cache()->forget('fsu:deep-linking:assetlinks');
|
||||
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_saved'))
|
||||
->success()
|
||||
@@ -219,7 +957,7 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
->icon('heroicon-o-arrow-left')
|
||||
->color('gray')
|
||||
->size('sm')
|
||||
->url(static::getResource()::getUrl()),
|
||||
->url(ShortUrlResource::getUrl()),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -112,31 +112,38 @@ class ViewShortUrlLogs extends Page implements HasForms, HasTable
|
||||
Tables\Filters\Filter::make('visited_at')
|
||||
->form([
|
||||
DatePicker::make('visited_from')
|
||||
->label('Visited From'),
|
||||
->label(__('filament-short-url::default.stats_filter_visited_from')),
|
||||
DatePicker::make('visited_until')
|
||||
->label('Visited Until'),
|
||||
->label(__('filament-short-url::default.stats_filter_visited_until')),
|
||||
])
|
||||
->query(function ($query, array $data) {
|
||||
return $query
|
||||
->when($data['visited_from'], fn ($q, $date) => $q->whereDate('visited_at', '>=', $date))
|
||||
->when($data['visited_until'], fn ($q, $date) => $q->whereDate('visited_at', '<=', $date));
|
||||
->when($data['visited_from'], fn ($q, $date) => $q->where('visited_at', '>=', $date.' 00:00:00'))
|
||||
->when($data['visited_until'], fn ($q, $date) => $q->where('visited_at', '<=', $date.' 23:59:59'));
|
||||
}),
|
||||
])
|
||||
->headerActions([
|
||||
Action::make('export_csv')
|
||||
->label('Export CSV')
|
||||
->label(__('filament-short-url::default.stats_action_export'))
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->color('gray')
|
||||
->action(function () {
|
||||
return response()->streamDownload(function () {
|
||||
->action(function (HasTable $livewire) {
|
||||
return response()->streamDownload(function () use ($livewire) {
|
||||
$handle = fopen('php://output', 'w');
|
||||
// Add UTF-8 BOM for Microsoft Excel
|
||||
fprintf($handle, chr(0xEF).chr(0xBB).chr(0xBF));
|
||||
|
||||
fputcsv($handle, ['Time', 'IP Address', 'Country', 'Device', 'Browser', 'OS', 'Referer']);
|
||||
fputcsv($handle, [
|
||||
__('filament-short-url::default.stats_csv_time'),
|
||||
__('filament-short-url::default.stats_csv_ip'),
|
||||
__('filament-short-url::default.stats_csv_country'),
|
||||
__('filament-short-url::default.stats_csv_device'),
|
||||
__('filament-short-url::default.stats_csv_browser'),
|
||||
__('filament-short-url::default.stats_csv_os'),
|
||||
__('filament-short-url::default.stats_csv_referer'),
|
||||
]);
|
||||
|
||||
ShortUrlVisit::query()
|
||||
->where('short_url_id', $this->record->id)
|
||||
$livewire->getFilteredTableQuery()
|
||||
->orderBy('visited_at', 'desc')
|
||||
->chunk(200, function ($visits) use ($handle) {
|
||||
foreach ($visits as $visit) {
|
||||
|
||||
@@ -5,9 +5,12 @@ namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\DatePicker;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Forms\Contracts\HasForms;
|
||||
use Filament\Resources\Pages\Page;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class ViewShortUrlStats extends Page implements HasForms
|
||||
{
|
||||
@@ -23,10 +26,80 @@ class ViewShortUrlStats extends Page implements HasForms
|
||||
|
||||
public int $totalVisits = 0;
|
||||
|
||||
public ?array $filterData = [];
|
||||
|
||||
public function mount(ShortUrl $record): void
|
||||
{
|
||||
$this->record = $record;
|
||||
$this->totalVisits = $record->getCachedStats()['totalVisits'] ?? 0;
|
||||
|
||||
$this->form->fill([
|
||||
'preset' => '30_days',
|
||||
'date_from' => now()->subDays(29)->format('Y-m-d'),
|
||||
'date_to' => now()->format('Y-m-d'),
|
||||
]);
|
||||
|
||||
if (empty($this->filterData)) {
|
||||
$this->filterData = [
|
||||
'preset' => '30_days',
|
||||
'date_from' => now()->subDays(29)->format('Y-m-d'),
|
||||
'date_to' => now()->format('Y-m-d'),
|
||||
];
|
||||
}
|
||||
|
||||
$this->totalVisits = $record->getCachedStats($this->filterData['date_from'], $this->filterData['date_to'])['totalVisits'] ?? 0;
|
||||
}
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Select::make('preset')
|
||||
->hiddenLabel()
|
||||
->options([
|
||||
'24_hours' => __('filament-short-url::default.stats_preset_24_hours'),
|
||||
'7_days' => __('filament-short-url::default.stats_preset_7_days'),
|
||||
'30_days' => __('filament-short-url::default.stats_preset_30_days'),
|
||||
'90_days' => __('filament-short-url::default.stats_preset_90_days'),
|
||||
'custom' => __('filament-short-url::default.stats_preset_custom'),
|
||||
])
|
||||
->live()
|
||||
->columnSpan(fn ($get) => $get('preset') === 'custom' ? 1 : 'full')
|
||||
->afterStateUpdated(function ($state, $set) {
|
||||
$to = now()->format('Y-m-d');
|
||||
$from = match ($state) {
|
||||
'24_hours' => now()->subDay()->format('Y-m-d'),
|
||||
'7_days' => now()->subDays(6)->format('Y-m-d'),
|
||||
'30_days' => now()->subDays(29)->format('Y-m-d'),
|
||||
'90_days' => now()->subDays(89)->format('Y-m-d'),
|
||||
'custom' => now()->subDays(29)->format('Y-m-d'),
|
||||
default => null,
|
||||
};
|
||||
if ($from) {
|
||||
$set('date_from', $from);
|
||||
$set('date_to', $to);
|
||||
}
|
||||
}),
|
||||
|
||||
DatePicker::make('date_from')
|
||||
->hiddenLabel()
|
||||
->placeholder(__('filament-short-url::default.stats_filter_visited_from'))
|
||||
->native(false)
|
||||
->live()
|
||||
->columnSpan(1)
|
||||
->visible(fn ($get) => $get('preset') === 'custom')
|
||||
->required(),
|
||||
|
||||
DatePicker::make('date_to')
|
||||
->hiddenLabel()
|
||||
->placeholder(__('filament-short-url::default.stats_filter_visited_until'))
|
||||
->native(false)
|
||||
->live()
|
||||
->columnSpan(1)
|
||||
->visible(fn ($get) => $get('preset') === 'custom')
|
||||
->required(),
|
||||
])
|
||||
->columns(3)
|
||||
->statePath('filterData');
|
||||
}
|
||||
|
||||
protected function getHeaderWidgets(): array
|
||||
|
||||
@@ -2,14 +2,17 @@
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Services\SafeBrowsingService;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Forms\Components\Repeater;
|
||||
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\Group;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Tabs;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
@@ -24,7 +27,10 @@ class ShortUrlForm
|
||||
return $schema->components([
|
||||
Tabs::make()->tabs([
|
||||
static::linkTab(),
|
||||
static::targetingTab(),
|
||||
static::appLinkingTab(),
|
||||
static::trackingTab(),
|
||||
static::marketingTab(),
|
||||
static::qrDesignTab(),
|
||||
])->columnSpanFull(),
|
||||
]);
|
||||
@@ -42,6 +48,14 @@ class ShortUrlForm
|
||||
->required()
|
||||
->url()
|
||||
->maxLength(2048)
|
||||
->rules([
|
||||
fn (): \Closure => function (string $attribute, $value, \Closure $fail) {
|
||||
$safeBrowsing = app(SafeBrowsingService::class);
|
||||
if (! $safeBrowsing->isSafe($value)) {
|
||||
$fail(__('filament-short-url::default.safe_browsing_error') ?? 'This URL has been flagged by Google Safe Browsing as unsafe.');
|
||||
}
|
||||
},
|
||||
])
|
||||
->live(onBlur: true)
|
||||
->afterStateHydrated(function (TextInput $component, $state, Set $set) {
|
||||
if (! $state) {
|
||||
@@ -101,35 +115,90 @@ class ShortUrlForm
|
||||
302 => __('filament-short-url::default.redirect_code_302'),
|
||||
301 => __('filament-short-url::default.redirect_code_301'),
|
||||
])
|
||||
->default(302)
|
||||
->default(fn () => config('filament-short-url.redirect_status_code', 302))
|
||||
->required(),
|
||||
])->columns(2),
|
||||
|
||||
Section::make('Options')->schema([
|
||||
Section::make(__('filament-short-url::default.form_section_options'))->schema([
|
||||
Toggle::make('is_enabled')
|
||||
->label(__('filament-short-url::default.status'))
|
||||
->default(true)
|
||||
->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([
|
||||
Section::make(__('filament-short-url::default.form_section_validity'))
|
||||
->schema([
|
||||
Toggle::make('use_date_validity')
|
||||
->label(__('filament-short-url::default.use_date_validity'))
|
||||
->dehydrated(false)
|
||||
->live()
|
||||
->afterStateHydrated(function (Toggle $component, $state, Get $get, Set $set) {
|
||||
$set('use_date_validity', $get('activated_at') !== null || $get('expires_at') !== null);
|
||||
})
|
||||
->afterStateUpdated(function ($state, Set $set) {
|
||||
if ($state) {
|
||||
$set('activated_at', now()->startOfMinute());
|
||||
} else {
|
||||
$set('activated_at', null);
|
||||
$set('expires_at', null);
|
||||
$set('expiration_redirect_url', null);
|
||||
}
|
||||
})
|
||||
->columnSpanFull(),
|
||||
|
||||
DateTimePicker::make('activated_at')
|
||||
->label(__('filament-short-url::default.activated_at'))
|
||||
->nullable()
|
||||
->native(false)
|
||||
->withoutSeconds()
|
||||
->live(onBlur: true)
|
||||
->required(fn (Get $get): bool => (bool) $get('use_date_validity'))
|
||||
->visible(fn (Get $get): bool => (bool) $get('use_date_validity'))
|
||||
->minDate(now()->startOfDay())
|
||||
->maxDate(fn (Get $get) => $get('expires_at')),
|
||||
|
||||
DateTimePicker::make('expires_at')
|
||||
->label(__('filament-short-url::default.expires_at'))
|
||||
->nullable()
|
||||
->native(false)
|
||||
->withoutSeconds()
|
||||
->live(onBlur: true)
|
||||
->visible(fn (Get $get): bool => (bool) $get('use_date_validity'))
|
||||
->minDate(fn (Get $get) => $get('activated_at') ?: now()->startOfDay()),
|
||||
|
||||
TextInput::make('expiration_redirect_url')
|
||||
->label(__('filament-short-url::default.expiration_redirect_url'))
|
||||
->helperText(__('filament-short-url::default.expiration_redirect_url_helper'))
|
||||
->url()
|
||||
->maxLength(2048)
|
||||
->nullable()
|
||||
->visible(fn (Get $get): bool => (bool) $get('use_date_validity'))
|
||||
->columnSpanFull(),
|
||||
|
||||
Toggle::make('single_use')
|
||||
->label(__('filament-short-url::default.single_use'))
|
||||
->helperText(__('filament-short-url::default.single_use_helper'))
|
||||
->default(false)
|
||||
->inline(false)
|
||||
->live(),
|
||||
|
||||
TextInput::make('max_visits')
|
||||
->label(__('filament-short-url::default.max_visits'))
|
||||
->helperText(__('filament-short-url::default.max_visits_helper'))
|
||||
->numeric()
|
||||
->integer()
|
||||
->minValue(1)
|
||||
->nullable()
|
||||
->hidden(fn (Get $get): bool => (bool) $get('single_use')),
|
||||
])->columns(2),
|
||||
|
||||
Section::make(__('filament-short-url::default.form_section_notes'))->schema([
|
||||
Textarea::make('notes')
|
||||
->label(__('filament-short-url::default.notes'))
|
||||
->rows(3)
|
||||
@@ -177,57 +246,63 @@ class ShortUrlForm
|
||||
return Tab::make(__('filament-short-url::default.tab_tracking'))
|
||||
->icon('heroicon-o-chart-bar')
|
||||
->schema([
|
||||
Section::make('Visit Tracking')
|
||||
Section::make(__('filament-short-url::default.form_section_tracking'))
|
||||
->schema([
|
||||
Toggle::make('track_visits')
|
||||
->label(__('filament-short-url::default.track_visits'))
|
||||
->default(true)
|
||||
->default(fn () => config('filament-short-url.tracking.enabled', true))
|
||||
->live()
|
||||
->inline(false)
|
||||
->columnSpanFull(),
|
||||
])->columns(1),
|
||||
|
||||
Section::make('Tracked Fields')
|
||||
Section::make(__('filament-short-url::default.form_section_tracked_fields'))
|
||||
->schema([
|
||||
Toggle::make('track_ip_address')
|
||||
->label(__('filament-short-url::default.track_ip'))
|
||||
->default(true)
|
||||
->default(fn () => config('filament-short-url.tracking.fields.ip_address', true))
|
||||
->inline(false)
|
||||
->disabled(fn (Get $get): bool => ! $get('track_visits')),
|
||||
|
||||
Toggle::make('track_browser')
|
||||
->label(__('filament-short-url::default.track_browser'))
|
||||
->default(true)
|
||||
->default(fn () => config('filament-short-url.tracking.fields.browser', 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)
|
||||
->default(fn () => config('filament-short-url.tracking.fields.browser_version', 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)
|
||||
->default(fn () => config('filament-short-url.tracking.fields.operating_system', 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)
|
||||
->default(fn () => config('filament-short-url.tracking.fields.operating_system_version', 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)
|
||||
->default(fn () => config('filament-short-url.tracking.fields.device_type', 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)
|
||||
->default(fn () => config('filament-short-url.tracking.fields.referer_url', true))
|
||||
->inline(false)
|
||||
->disabled(fn (Get $get): bool => ! $get('track_visits')),
|
||||
|
||||
Toggle::make('track_browser_language')
|
||||
->label(__('filament-short-url::default.track_browser_language'))
|
||||
->default(fn () => config('filament-short-url.tracking.fields.browser_language', true))
|
||||
->inline(false)
|
||||
->disabled(fn (Get $get): bool => ! $get('track_visits')),
|
||||
])
|
||||
@@ -277,7 +352,7 @@ class ShortUrlForm
|
||||
])
|
||||
->columns(2),
|
||||
|
||||
Section::make('Third-Party Analytics')
|
||||
Section::make(__('filament-short-url::default.form_section_analytics'))
|
||||
->schema([
|
||||
TextInput::make('ga_tracking_id')
|
||||
->label(__('filament-short-url::default.ga_tracking_id'))
|
||||
@@ -294,18 +369,32 @@ class ShortUrlForm
|
||||
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(''),
|
||||
Group::make([
|
||||
TextInput::make('qr_options')
|
||||
->extraAttributes([
|
||||
'style' => 'display: none !important;',
|
||||
])
|
||||
->extraInputAttributes([
|
||||
'id' => 'qr-options-json-input',
|
||||
])
|
||||
->hiddenLabel()
|
||||
->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 ?? '{}'));
|
||||
}),
|
||||
|
||||
TextInput::make('qr_logo')
|
||||
->extraAttributes([
|
||||
'style' => 'display: none !important;',
|
||||
])
|
||||
->extraInputAttributes([
|
||||
'id' => 'qr-logo-path-input',
|
||||
])
|
||||
->hiddenLabel()
|
||||
->nullable(),
|
||||
])->extraAttributes([
|
||||
'style' => 'display: none !important;',
|
||||
]),
|
||||
|
||||
ViewField::make('qr_designer')
|
||||
->view('filament-short-url::qr-designer')
|
||||
@@ -313,4 +402,198 @@ class ShortUrlForm
|
||||
->dehydrated(false),
|
||||
]);
|
||||
}
|
||||
|
||||
private static function targetingTab(): Tab
|
||||
{
|
||||
return Tab::make(__('filament-short-url::default.tab_targeting'))
|
||||
->icon('heroicon-o-shield-check')
|
||||
->schema([
|
||||
Section::make(__('filament-short-url::default.security_section_title'))
|
||||
->schema([
|
||||
TextInput::make('password')
|
||||
->label(__('filament-short-url::default.password'))
|
||||
->helperText(__('filament-short-url::default.password_helper'))
|
||||
->password()
|
||||
->revealable()
|
||||
->nullable()
|
||||
->maxLength(255),
|
||||
|
||||
Toggle::make('show_warning_page')
|
||||
->label(__('filament-short-url::default.show_warning_page'))
|
||||
->helperText(__('filament-short-url::default.show_warning_page_helper'))
|
||||
->default(false)
|
||||
->inline(false),
|
||||
])->columns(2),
|
||||
|
||||
Section::make(__('filament-short-url::default.targeting_type'))
|
||||
->schema([
|
||||
Select::make('targeting_rules.type')
|
||||
->label(__('filament-short-url::default.targeting_type'))
|
||||
->options([
|
||||
'none' => __('filament-short-url::default.targeting_type_none'),
|
||||
'device' => __('filament-short-url::default.targeting_type_device'),
|
||||
'geo' => __('filament-short-url::default.targeting_type_country'),
|
||||
'language' => __('filament-short-url::default.targeting_type_language'),
|
||||
'rotation' => __('filament-short-url::default.targeting_type_rotation'),
|
||||
])
|
||||
->default('none')
|
||||
->live()
|
||||
->required(),
|
||||
|
||||
Section::make(__('filament-short-url::default.device_targeting_rules'))
|
||||
->schema([
|
||||
TextInput::make('targeting_rules.device.mobile')
|
||||
->label(__('filament-short-url::default.device_mobile'))
|
||||
->url()
|
||||
->nullable()
|
||||
->maxLength(2048),
|
||||
TextInput::make('targeting_rules.device.tablet')
|
||||
->label(__('filament-short-url::default.device_tablet'))
|
||||
->url()
|
||||
->nullable()
|
||||
->maxLength(2048),
|
||||
TextInput::make('targeting_rules.device.desktop')
|
||||
->label(__('filament-short-url::default.device_desktop'))
|
||||
->url()
|
||||
->nullable()
|
||||
->maxLength(2048),
|
||||
])
|
||||
->columns(1)
|
||||
->visible(fn (Get $get): bool => $get('targeting_rules.type') === 'device'),
|
||||
|
||||
Repeater::make('targeting_rules.geo')
|
||||
->label(__('filament-short-url::default.country_targeting_rules'))
|
||||
->schema([
|
||||
Select::make('country_code')
|
||||
->label(__('filament-short-url::default.country_code'))
|
||||
->options(function (): array {
|
||||
$countries = __('filament-short-url::countries');
|
||||
if (is_array($countries)) {
|
||||
asort($countries, SORT_LOCALE_STRING);
|
||||
|
||||
return $countries;
|
||||
}
|
||||
|
||||
return [];
|
||||
})
|
||||
->searchable()
|
||||
->optionsLimit(300)
|
||||
->disableOptionsWhenSelectedInSiblingRepeaterItems()
|
||||
->required(),
|
||||
TextInput::make('url')
|
||||
->label(__('filament-short-url::default.destination_url'))
|
||||
->url()
|
||||
->required()
|
||||
->maxLength(2048),
|
||||
])
|
||||
->columns(2)
|
||||
->visible(fn (Get $get): bool => $get('targeting_rules.type') === 'geo'),
|
||||
|
||||
Repeater::make('targeting_rules.language')
|
||||
->label(__('filament-short-url::default.language_targeting_rules'))
|
||||
->schema([
|
||||
Select::make('language_code')
|
||||
->label(__('filament-short-url::default.language_code'))
|
||||
->options(function (): array {
|
||||
$languages = __('filament-short-url::languages');
|
||||
if (is_array($languages)) {
|
||||
asort($languages, SORT_LOCALE_STRING);
|
||||
|
||||
return $languages;
|
||||
}
|
||||
|
||||
return [];
|
||||
})
|
||||
->searchable()
|
||||
->disableOptionsWhenSelectedInSiblingRepeaterItems()
|
||||
->required(),
|
||||
TextInput::make('url')
|
||||
->label(__('filament-short-url::default.destination_url'))
|
||||
->url()
|
||||
->required()
|
||||
->maxLength(2048),
|
||||
])
|
||||
->columns(2)
|
||||
->visible(fn (Get $get): bool => $get('targeting_rules.type') === 'language'),
|
||||
|
||||
Repeater::make('targeting_rules.rotation')
|
||||
->label(__('filament-short-url::default.rotation_targeting_rules'))
|
||||
->schema([
|
||||
TextInput::make('url')
|
||||
->label(__('filament-short-url::default.rotation_url'))
|
||||
->url()
|
||||
->required()
|
||||
->maxLength(2048),
|
||||
TextInput::make('weight')
|
||||
->label(__('filament-short-url::default.rotation_weight'))
|
||||
->helperText(__('filament-short-url::default.rotation_weight_helper'))
|
||||
->numeric()
|
||||
->integer()
|
||||
->minValue(1)
|
||||
->maxValue(1000)
|
||||
->required()
|
||||
->default(50),
|
||||
])
|
||||
->columns(2)
|
||||
->visible(fn (Get $get): bool => $get('targeting_rules.type') === 'rotation'),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
private static function marketingTab(): Tab
|
||||
{
|
||||
return Tab::make(__('filament-short-url::default.tab_marketing'))
|
||||
->icon('heroicon-o-megaphone')
|
||||
->schema([
|
||||
Section::make(__('filament-short-url::default.marketing_pixels_title'))
|
||||
->description(__('filament-short-url::default.marketing_pixels_desc'))
|
||||
->schema([
|
||||
Select::make('pixels')
|
||||
->label(__('filament-short-url::default.pixels_navigation_label') ?? 'Retargeting Pixels')
|
||||
->multiple()
|
||||
->relationship('pixels', 'name', modifyQueryUsing: fn ($query) => $query->where('is_active', true))
|
||||
->preload()
|
||||
->searchable()
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
|
||||
Section::make(__('filament-short-url::default.marketing_webhooks_title'))
|
||||
->description(__('filament-short-url::default.marketing_webhooks_desc'))
|
||||
->schema([
|
||||
TextInput::make('webhook_url')
|
||||
->label(__('filament-short-url::default.webhook_url'))
|
||||
->placeholder('https://api.yourcrm.com/webhooks/clicks')
|
||||
->url()
|
||||
->maxLength(2048)
|
||||
->nullable()
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
private static function appLinkingTab(): Tab
|
||||
{
|
||||
return Tab::make(__('filament-short-url::default.tab_app_linking') ?? 'App Linking')
|
||||
->icon('heroicon-o-device-phone-mobile')
|
||||
->schema([
|
||||
Section::make(__('filament-short-url::default.form_section_app_linking') ?? 'App Linking / Deep Links')
|
||||
->schema([
|
||||
Toggle::make('auto_open_app_mobile')
|
||||
->label(__('filament-short-url::default.auto_open_app_mobile') ?? 'Auto open app on mobile')
|
||||
->helperText(__('filament-short-url::default.auto_open_app_mobile_helper') ?? 'Enable this if you want your link to automatically open as an app when accessed on mobile.')
|
||||
->default(false)
|
||||
->inline(false)
|
||||
->live(),
|
||||
|
||||
ViewField::make('app_linking_preview')
|
||||
->view('filament-short-url::app-linking-preview')
|
||||
->viewData(fn (Get $get) => [
|
||||
'destinationUrl' => $get('destination_url'),
|
||||
])
|
||||
->columnSpanFull()
|
||||
->visible(fn (Get $get): bool => (bool) $get('auto_open_app_mobile')),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,19 +2,21 @@
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Tables;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\BulkAction;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Support\Enums\FontWeight;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Forms;
|
||||
use Filament\Tables\Columns\Layout\Stack;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Columns\ToggleColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Filters\TernaryFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Support\HtmlString;
|
||||
|
||||
class ShortUrlsTable
|
||||
{
|
||||
@@ -22,60 +24,156 @@ class ShortUrlsTable
|
||||
{
|
||||
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}%")),
|
||||
Stack::make([
|
||||
// ROW 1: Favicon + Short URL with copy capability on click of text or icon (vertically centered!)
|
||||
TextColumn::make('url_key')
|
||||
->label(__('filament-short-url::default.col_short_url'))
|
||||
->getStateUsing(static function (ShortUrl $record): HtmlString {
|
||||
$shortUrl = e($record->getShortUrl());
|
||||
$copiedMsg = e(__('filament-short-url::default.qr_copied'));
|
||||
$tooltipCopy = e(__('filament-short-url::default.action_copy'));
|
||||
$destHost = e(parse_url($record->destination_url, PHP_URL_HOST));
|
||||
|
||||
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(),
|
||||
return new HtmlString(<<<HTML
|
||||
<div onclick="
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const text = '{$shortUrl}';
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
const textCopyArea = document.createElement('textarea');
|
||||
textCopyArea.value = text;
|
||||
textCopyArea.style.position = 'fixed';
|
||||
textCopyArea.style.left = '-999999px';
|
||||
textCopyArea.style.top = '-999999px';
|
||||
document.body.appendChild(textCopyArea);
|
||||
textCopyArea.focus();
|
||||
textCopyArea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
} catch (err) {}
|
||||
textCopyArea.remove();
|
||||
}
|
||||
if (typeof FilamentNotification !== 'undefined') {
|
||||
new FilamentNotification()
|
||||
.title('{$copiedMsg}')
|
||||
.success()
|
||||
.send();
|
||||
} else if (window.Alpine) {
|
||||
window.Alpine.store('filament-notifications')?.send({
|
||||
status: 'success',
|
||||
title: '{$copiedMsg}'
|
||||
});
|
||||
}
|
||||
" class="flex items-center gap-3 cursor-pointer w-fit">
|
||||
<div class="flex items-center justify-center rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-1.5 flex-shrink-0 w-8 h-8">
|
||||
<img src="https://icons.duckduckgo.com/ip2/{$destHost}.ico"
|
||||
class="w-full h-full object-contain"
|
||||
onerror="this.src='https://heroicons.com/24/outline/link.svg'" />
|
||||
</div>
|
||||
<span class="text-[#2a5bd7] text-[16px] font-bold leading-6 break-all line-clamp-1">
|
||||
{$shortUrl}
|
||||
</span>
|
||||
<span title="{$tooltipCopy}" class="w-8 h-8 rounded-full flex items-center justify-center bg-[#f4f4f5] hover:bg-[#e4e4e7] dark:bg-gray-800 dark:hover:bg-gray-700 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors flex-shrink-0 focus:outline-none">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
HTML
|
||||
);
|
||||
})
|
||||
->searchable(query: fn ($query, string $search) => $query->where('url_key', 'like', "%{$search}%")),
|
||||
|
||||
TextColumn::make('total_visits')
|
||||
->label(__('filament-short-url::default.col_total_visits'))
|
||||
->numeric()
|
||||
->badge()
|
||||
->color('gray')
|
||||
->sortable(),
|
||||
// ROW 2: Sub-arrow + Destination URL
|
||||
TextColumn::make('destination_url')
|
||||
->label(__('filament-short-url::default.col_destination_url'))
|
||||
->getStateUsing(fn ($record): HtmlString => new HtmlString('
|
||||
<div class="flex items-center gap-1.5 text-gray-500 dark:text-gray-400 text-xs mt-2">
|
||||
<svg class="w-3.5 h-3.5 flex-shrink-0 text-gray-400 dark:text-gray-500"
|
||||
style="transform: scaleY(-1);"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2.5"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 15l6-6m0 0l-6-6m6 6H9a6 6 0 00-6 6v3" />
|
||||
</svg>
|
||||
<a href="'.e($record->destination_url).'"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="truncate max-w-[50ch] hover:underline text-[#273144] text-[15px] leading-[16px] font-medium"
|
||||
title="'.e($record->destination_url).'">
|
||||
'.e($record->destination_url).'
|
||||
</a>
|
||||
</div>
|
||||
'))
|
||||
->searchable(),
|
||||
|
||||
TextColumn::make('unique_visits')
|
||||
->label(__('filament-short-url::default.stats_card_unique'))
|
||||
->numeric()
|
||||
->badge()
|
||||
->color('info')
|
||||
->sortable(),
|
||||
// ROW 3: Bottom Metadata (Total clicks / Unique clicks / Date added / Expiry / Redirect code)
|
||||
TextColumn::make('metadata_badges')
|
||||
->getStateUsing(fn ($record): HtmlString => new HtmlString('
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs text-gray-500 dark:text-gray-400 mt-3">
|
||||
<!-- Clicks Badge -->
|
||||
<span class="inline-flex items-center gap-1 bg-[#f4f4f5] dark:bg-gray-800 px-2 py-1 rounded text-[11px] font-medium text-gray-600 dark:text-gray-300">
|
||||
<svg class="w-3.5 h-3.5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
||||
</svg>
|
||||
'.$record->total_visits.' '.__('filament-short-url::default.badge_clicks').'
|
||||
</span>
|
||||
|
||||
ToggleColumn::make('is_enabled')
|
||||
->label(__('filament-short-url::default.col_status'))
|
||||
->sortable(),
|
||||
<!-- Unique Clicks Badge -->
|
||||
<span class="inline-flex items-center gap-1 bg-[#f4f4f5] dark:bg-gray-800 px-2 py-1 rounded text-[11px] font-medium text-gray-600 dark:text-gray-300">
|
||||
<svg class="w-3.5 h-3.5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
'.$record->unique_visits.' '.__('filament-short-url::default.badge_unique').'
|
||||
</span>
|
||||
|
||||
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'),
|
||||
<!-- QR Scans Badge -->
|
||||
<span class="inline-flex items-center gap-1 bg-[#f4f4f5] dark:bg-gray-800 px-2 py-1 rounded text-[11px] font-medium text-gray-600 dark:text-gray-300">
|
||||
<svg class="w-3.5 h-3.5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0 1 3.75 9.375v-4.5ZM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 0 1-1.125-1.125v-4.5ZM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0 1 13.5 9.375v-4.5Z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12h.008v.008H15V12Zm0 3h.008v.008H15V15Zm0 3h.008v.008H15V18Zm3-3h.008v.008H18V15Zm0 3h.008v.008H18V18Zm3-3h.008v.008H21V15Zm0 3h.008v.008H21V18Zm0-6h.008v.008H21V12Zm-3 0h.008v.008H18V12Z" />
|
||||
</svg>
|
||||
'.$record->qr_scans.' '.__('filament-short-url::default.badge_qr_scans').'
|
||||
</span>
|
||||
|
||||
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(),
|
||||
<!-- Date Added Badge -->
|
||||
<span class="inline-flex items-center gap-1 bg-[#f4f4f5] dark:bg-gray-800 px-2 py-1 rounded text-[11px] font-medium text-gray-600 dark:text-gray-300">
|
||||
<svg class="w-3.5 h-3.5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
'.$record->created_at->translatedFormat('M d, Y').'
|
||||
</span>
|
||||
|
||||
TextColumn::make('created_at')
|
||||
->label(__('filament-short-url::default.col_created_at'))
|
||||
->since()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
<!-- Expiry / Single Use Badge -->
|
||||
<span class="inline-flex items-center gap-1 bg-[#f4f4f5] dark:bg-gray-800 px-2 py-1 rounded text-[11px] font-medium text-gray-600 dark:text-gray-300">
|
||||
'.($record->expires_at ? '
|
||||
<svg class="w-3.5 h-3.5 text-yellow-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
'.__('filament-short-url::default.badge_expires', ['date' => $record->expires_at->translatedFormat('M d, Y')]).'
|
||||
' : '
|
||||
<svg class="w-3.5 h-3.5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
'.__('filament-short-url::default.badge_no_expiry').'
|
||||
').'
|
||||
</span>
|
||||
|
||||
<!-- Redirect Type Badge -->
|
||||
<span class="inline-flex items-center gap-1 bg-[#f4f4f5] dark:bg-gray-800 px-2 py-1 rounded text-[11px] font-medium text-gray-600 dark:text-gray-300">
|
||||
<svg class="w-3.5 h-3.5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
||||
</svg>
|
||||
'.__('filament-short-url::default.badge_redirect', ['code' => $record->redirect_status_code]).'
|
||||
</span>
|
||||
</div>
|
||||
')),
|
||||
]),
|
||||
])
|
||||
->filters([
|
||||
TernaryFilter::make('is_enabled')
|
||||
@@ -95,43 +193,428 @@ class ShortUrlsTable
|
||||
'1' => 'Single-use',
|
||||
]),
|
||||
])
|
||||
->recordActions([
|
||||
->recordClasses(fn (): string => 'short-url-card')
|
||||
->actions([
|
||||
Action::make('share')
|
||||
->label(__('filament-short-url::default.action_share'))
|
||||
->icon('heroicon-o-share')
|
||||
->color('gray')
|
||||
->iconButton()
|
||||
->tooltip(__('filament-short-url::default.action_share'))
|
||||
->modalHeading(__('filament-short-url::default.share_title'))
|
||||
->modalWidth('md')
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelAction(false)
|
||||
->form([
|
||||
Forms\Components\Placeholder::make('platforms')
|
||||
->label(__('filament-short-url::default.share_description'))
|
||||
->content(fn (ShortUrl $record): HtmlString => new HtmlString('
|
||||
<div class="flex items-center gap-6 overflow-x-auto pb-4 pt-1 scroll-smooth" style="scrollbar-width: thin; -ms-overflow-style: none;">
|
||||
<!-- Messenger -->
|
||||
<a href="fb-messenger://share/?link='.urlencode($record->getShortUrl()).'" target="_blank" rel="noopener noreferrer" class="flex flex-col items-center gap-2 group flex-shrink-0 cursor-pointer">
|
||||
<div class="w-11 h-11 rounded-full bg-gradient-to-tr from-[#006aff] via-[#00b2ff] to-[#00d6ff] text-white flex items-center justify-center shadow-sm group-hover:scale-105 transition-transform duration-200">
|
||||
<svg class="w-5.5 h-5.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.48 2 2 6.14 2 11.25c0 2.9 1.45 5.48 3.73 7.08v3.67c0 .24.23.4.43.27l4.07-2.3c.57.16 1.17.25 1.77.25 5.52 0 10-4.14 10-9.25S17.52 2 12 2zm1.09 11.95l-2.43-2.6-4.73 2.6 5.19-5.52 2.47 2.63 4.7-2.63-5.2 5.52z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-[11px] font-medium text-gray-500 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white transition-colors">Messenger</span>
|
||||
</a>
|
||||
<!-- Facebook -->
|
||||
<a href="https://www.facebook.com/sharer/sharer.php?u='.urlencode($record->getShortUrl()).'" target="_blank" rel="noopener noreferrer" class="flex flex-col items-center gap-2 group flex-shrink-0 cursor-pointer">
|
||||
<div class="w-11 h-11 rounded-full bg-[#1877f2] text-white flex items-center justify-center shadow-sm group-hover:scale-105 transition-transform duration-200">
|
||||
<svg class="w-5.5 h-5.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-[11px] font-medium text-gray-550 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-white transition-colors">Facebook</span>
|
||||
</a>
|
||||
<!-- WhatsApp -->
|
||||
<a href="https://api.whatsapp.com/send?text='.urlencode($record->getShortUrl()).'" target="_blank" rel="noopener noreferrer" class="flex flex-col items-center gap-2 group flex-shrink-0 cursor-pointer">
|
||||
<div class="w-11 h-11 rounded-full bg-[#25d366] text-white flex items-center justify-center shadow-sm group-hover:scale-105 transition-transform duration-200">
|
||||
<svg class="w-5.5 h-5.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L0 24l6.335-1.662c1.746.953 3.71 1.458 5.704 1.459h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-[11px] font-medium text-gray-500 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white transition-colors">WhatsApp</span>
|
||||
</a>
|
||||
<!-- Twitter/X -->
|
||||
<a href="https://twitter.com/intent/tweet?url='.urlencode($record->getShortUrl()).'" target="_blank" rel="noopener noreferrer" class="flex flex-col items-center gap-2 group flex-shrink-0 cursor-pointer">
|
||||
<div class="w-11 h-11 rounded-full bg-[#0f1419] text-white flex items-center justify-center shadow-sm group-hover:scale-105 transition-transform duration-200">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-[11px] font-medium text-gray-500 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white transition-colors">Twitter (X)</span>
|
||||
</a>
|
||||
<!-- LinkedIn -->
|
||||
<a href="https://www.linkedin.com/sharing/share-offsite/?url='.urlencode($record->getShortUrl()).'" target="_blank" rel="noopener noreferrer" class="flex flex-col items-center gap-2 group flex-shrink-0 cursor-pointer">
|
||||
<div class="w-11 h-11 rounded-full bg-[#0077b5] text-white flex items-center justify-center shadow-sm group-hover:scale-105 transition-transform duration-200">
|
||||
<svg class="w-5.5 h-5.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.779-1.75-1.75s.784-1.75 1.75-1.75 1.75.779 1.75 1.75-.784 1.75-1.75 1.75zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-[11px] font-medium text-gray-500 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white transition-colors">LinkedIn</span>
|
||||
</a>
|
||||
<!-- Email -->
|
||||
<a href="mailto:?body='.urlencode($record->getShortUrl()).'" target="_blank" rel="noopener noreferrer" class="flex flex-col items-center gap-2 group flex-shrink-0 cursor-pointer">
|
||||
<div class="w-11 h-11 rounded-full bg-[#6b7280] text-white flex items-center justify-center shadow-sm group-hover:scale-105 transition-transform duration-200">
|
||||
<svg class="w-5.5 h-5.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-[11px] font-medium text-gray-500 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white transition-colors">Email</span>
|
||||
</a>
|
||||
</div>
|
||||
')),
|
||||
|
||||
Forms\Components\Placeholder::make('copy_field')
|
||||
->label('')
|
||||
->content(fn (ShortUrl $record): HtmlString => new HtmlString('
|
||||
<div class="flex items-center gap-2 relative mt-2">
|
||||
<input type="text"
|
||||
readonly
|
||||
value="'.e($record->getShortUrl()).'"
|
||||
id="share_link_input_'.$record->id.'"
|
||||
class="flex-1 min-w-0 block w-full px-3.5 py-2.5 rounded-lg border border-gray-300 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 text-gray-900 dark:text-gray-100 text-sm focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-400 dark:focus:border-primary-400 focus:outline-none">
|
||||
|
||||
<button onclick="
|
||||
const input = document.getElementById(\'share_link_input_'.$record->id.'\');
|
||||
input.select();
|
||||
navigator.clipboard.writeText(input.value);
|
||||
if (typeof FilamentNotification !== \'undefined\') {
|
||||
new FilamentNotification()
|
||||
.title(\''.e(__('filament-short-url::default.share_copied')).'\')
|
||||
.success()
|
||||
.send();
|
||||
} else if (typeof Alpine !== \'undefined\') {
|
||||
Alpine.store(\'filament-notifications\')?.send({
|
||||
status: \'success\',
|
||||
title: \''.e(__('filament-short-url::default.share_copied')).'\'
|
||||
});
|
||||
}
|
||||
"
|
||||
class="flex-shrink-0 inline-flex items-center justify-center gap-1.5 px-4 py-2.5 bg-gray-900 hover:bg-gray-800 dark:bg-gray-100 dark:hover:bg-white text-white dark:text-gray-950 font-semibold text-sm rounded-lg shadow-sm hover:shadow transition duration-200">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span>'.e(__('filament-short-url::default.share_copy')).'</span>
|
||||
</button>
|
||||
</div>
|
||||
')),
|
||||
]),
|
||||
|
||||
Action::make('qrCode')
|
||||
->label(__('filament-short-url::default.action_qr') ?? 'QR Code')
|
||||
->icon('heroicon-o-qr-code')
|
||||
->color('gray')
|
||||
->iconButton()
|
||||
->tooltip(__('filament-short-url::default.action_qr') ?? 'QR Code')
|
||||
->modalWidth('md')
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelAction(false)
|
||||
->form(function (ShortUrl $record): array {
|
||||
$shortUrl = $record->getShortUrl();
|
||||
$qrTargetUrl = $shortUrl.'?source=qr';
|
||||
$destHost = parse_url($record->destination_url, PHP_URL_HOST) ?? '';
|
||||
$urlKey = $record->url_key;
|
||||
$eid = 'fsu_'.substr(md5($shortUrl), 0, 8);
|
||||
|
||||
$qrHelperText = __('filament-short-url::default.qr_modal_helper') ?? 'Scan, copy, or download your custom QR code.';
|
||||
$downloadSvgText = __('filament-short-url::default.qr_download_svg') ?? 'Download SVG';
|
||||
$downloadPngText = __('filament-short-url::default.qr_download_png') ?? 'Download PNG';
|
||||
$closeButtonText = __('filament-short-url::default.close_button') ?? 'Close';
|
||||
$copyLinkText = __('filament-short-url::default.action_copy') ?? 'Copy link';
|
||||
$openLinkText = __('filament-short-url::default.open_link') ?? 'Open link';
|
||||
|
||||
$qrDefaults = $record->getQrOptions();
|
||||
$isGrad = ($qrDefaults['gradient_enabled'] ?? false) || (($qrDefaults['color_mode'] ?? '') === 'gradient');
|
||||
$dotStyle = $qrDefaults['dot_style'] ?? 'square';
|
||||
$fgColor = $qrDefaults['foreground_color'] ?? '#000000';
|
||||
$bgColor = ($qrDefaults['bg_transparent'] ?? false) ? 'rgba(0,0,0,0)' : ($qrDefaults['background_color'] ?? '#ffffff');
|
||||
|
||||
$dotsOptions = $isGrad ? [
|
||||
'type' => $dotStyle,
|
||||
'gradient' => [
|
||||
'type' => $qrDefaults['gradient_type'] ?? 'linear',
|
||||
'colorStops' => [
|
||||
['offset' => 0, 'color' => $qrDefaults['gradient_from'] ?? '#4f46e5'],
|
||||
['offset' => 1, 'color' => $qrDefaults['gradient_to'] ?? '#06b6d4'],
|
||||
],
|
||||
],
|
||||
] : [
|
||||
'type' => $dotStyle,
|
||||
'color' => $fgColor,
|
||||
];
|
||||
|
||||
$mainColor = $isGrad ? ($qrDefaults['gradient_from'] ?? '#4f46e5') : $fgColor;
|
||||
|
||||
$eyeConfigEnabled = $qrDefaults['eye_config_enabled'] ?? false;
|
||||
$eyeSquareStyle = $qrDefaults['eye_square_style'] ?? ($dotStyle === 'dots' ? 'dot' : 'square');
|
||||
$eyeDotStyle = $qrDefaults['eye_dot_style'] ?? ($dotStyle === 'dots' ? 'dot' : 'square');
|
||||
$eyeColor = $qrDefaults['eye_color'] ?? $mainColor;
|
||||
|
||||
$cornersSquareOptions = $eyeConfigEnabled ? [
|
||||
'type' => $eyeSquareStyle,
|
||||
'color' => $eyeColor,
|
||||
] : [
|
||||
'type' => $dotStyle === 'dots' ? 'dot' : 'square',
|
||||
'color' => $mainColor,
|
||||
];
|
||||
|
||||
$cornersDotOptions = $eyeConfigEnabled ? [
|
||||
'type' => $eyeDotStyle,
|
||||
'color' => $eyeColor,
|
||||
] : [
|
||||
'type' => $dotStyle === 'dots' ? 'dot' : 'square',
|
||||
'color' => $mainColor,
|
||||
];
|
||||
|
||||
$logo = $qrDefaults['logo'] ?? null;
|
||||
$logoSize = $qrDefaults['logo_size'] ?? 0.3;
|
||||
$logoMargin = $qrDefaults['logo_margin'] ?? 9;
|
||||
$logoHideBackground = $qrDefaults['logo_hide_background'] ?? true;
|
||||
$logoShape = $qrDefaults['logo_shape'] ?? 'square';
|
||||
|
||||
$qrOptionsJson = json_encode([
|
||||
'type' => 'svg',
|
||||
'width' => 200,
|
||||
'height' => 200,
|
||||
'margin' => $qrDefaults['margin'] ?? 1,
|
||||
'dotsOptions' => $dotsOptions,
|
||||
'backgroundOptions' => ['color' => $bgColor],
|
||||
'cornersSquareOptions' => $cornersSquareOptions,
|
||||
'cornersDotOptions' => $cornersDotOptions,
|
||||
'image' => $logo ?: null,
|
||||
'imageOptions' => [
|
||||
'crossOrigin' => 'anonymous',
|
||||
'hideBackgroundDots' => $logoHideBackground,
|
||||
'imageSize' => $logoSize,
|
||||
'margin' => $logoMargin,
|
||||
'logoShape' => $logoShape,
|
||||
],
|
||||
'qrOptions' => ['errorCorrectionLevel' => $logo ? 'H' : 'M'],
|
||||
]);
|
||||
|
||||
$escapedQrOptions = e($qrOptionsJson);
|
||||
|
||||
$html = <<<HTML
|
||||
<div data-qr-options="{$escapedQrOptions}" x-data="{
|
||||
shortUrl: '{$shortUrl}',
|
||||
qrTargetUrl: '{$qrTargetUrl}',
|
||||
urlKey: '{$urlKey}',
|
||||
eid: '{$eid}',
|
||||
init() {
|
||||
this.\$nextTick(() => {
|
||||
this.loadScript().then(() => {
|
||||
const canvas = document.getElementById(this.eid + '_qr_canvas');
|
||||
if (canvas && !canvas.innerHTML) {
|
||||
const opts = JSON.parse(this.\$el.getAttribute('data-qr-options'));
|
||||
opts.data = this.qrTargetUrl;
|
||||
|
||||
const fixSvg = () => {
|
||||
const svg = canvas.querySelector('svg');
|
||||
if (svg) {
|
||||
const w = svg.getAttribute('width') || opts.width || 200;
|
||||
const h = svg.getAttribute('height') || opts.height || 200;
|
||||
svg.setAttribute('viewBox', '0 0 ' + w + ' ' + h);
|
||||
svg.style.width = '100%';
|
||||
svg.style.height = '100%';
|
||||
}
|
||||
};
|
||||
|
||||
if (opts.image && opts.imageOptions) {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = 1200;
|
||||
cv.height = 1200;
|
||||
const cx = cv.getContext('2d');
|
||||
cx.imageSmoothingEnabled = true;
|
||||
cx.imageSmoothingQuality = 'high';
|
||||
|
||||
const isCircle = opts.imageOptions.logoShape === 'circle';
|
||||
const targetDim = 1200 - (parseFloat(opts.imageOptions.margin || 0) * 20);
|
||||
|
||||
cx.save();
|
||||
if (isCircle) {
|
||||
cx.beginPath();
|
||||
cx.arc(600, 600, targetDim / 2, 0, 2 * Math.PI);
|
||||
cx.clip();
|
||||
|
||||
const scale = Math.max(targetDim / img.width, targetDim / img.height);
|
||||
const w = img.width * scale;
|
||||
const h = img.height * scale;
|
||||
const x = (1200 - w) / 2;
|
||||
const y = (1200 - h) / 2;
|
||||
|
||||
cx.drawImage(img, x, y, w, h);
|
||||
} else {
|
||||
cx.beginPath();
|
||||
const offset = (1200 - targetDim) / 2;
|
||||
const radius = 144 * (targetDim / 1200);
|
||||
|
||||
if (typeof cx.roundRect === 'function') {
|
||||
cx.roundRect(offset, offset, targetDim, targetDim, radius);
|
||||
} else {
|
||||
cx.moveTo(offset + radius, offset);
|
||||
cx.lineTo(offset + targetDim - radius, offset);
|
||||
cx.quadraticCurveTo(offset + targetDim, offset, offset + targetDim, offset + radius);
|
||||
cx.lineTo(offset + targetDim, offset + targetDim - radius);
|
||||
cx.quadraticCurveTo(offset + targetDim, offset + targetDim, offset + targetDim - radius, offset + targetDim);
|
||||
cx.lineTo(offset + radius, offset + targetDim);
|
||||
cx.quadraticCurveTo(offset, offset + targetDim, offset, offset + targetDim - radius);
|
||||
cx.lineTo(offset, offset + radius);
|
||||
cx.quadraticCurveTo(offset, offset, offset + radius, offset);
|
||||
cx.closePath();
|
||||
}
|
||||
cx.clip();
|
||||
|
||||
const scale = Math.max(targetDim / img.width, targetDim / img.height);
|
||||
const w = img.width * scale;
|
||||
const h = img.height * scale;
|
||||
const x = (1200 - w) / 2;
|
||||
const y = (1200 - h) / 2;
|
||||
|
||||
cx.drawImage(img, x, y, w, h);
|
||||
}
|
||||
cx.restore();
|
||||
|
||||
opts.image = cv.toDataURL('image/png');
|
||||
opts.imageOptions.margin = 0;
|
||||
|
||||
|
||||
window['qr_' + this.eid] = new window.QRCodeStyling(opts);
|
||||
window['qr_' + this.eid].append(canvas);
|
||||
fixSvg();
|
||||
};
|
||||
img.src = opts.image;
|
||||
} else {
|
||||
window['qr_' + this.eid] = new window.QRCodeStyling(opts);
|
||||
window['qr_' + this.eid].append(canvas);
|
||||
fixSvg();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
loadScript() {
|
||||
return new Promise((resolve) => {
|
||||
if (window.QRCodeStyling) return resolve();
|
||||
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 = () => {
|
||||
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();
|
||||
document.head.appendChild(s2);
|
||||
};
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
}">
|
||||
|
||||
<!-- Close Button (x) -->
|
||||
<button type="button" x-on:click="event.preventDefault(); event.stopPropagation(); const f=\$el.closest('.fi-modal-window'); const m=f?(f.getAttribute('x-on:keydown.window.escape')||'').match(/'(fi-[^']*)'/):null; if(m){\$dispatch('close-modal',{id:m[1]})}else{\$wire.unmountAction()}"
|
||||
style="position:absolute;top:16px;right:16px;z-index:50;width:28px;height:28px;border-radius:50%;border:none;background:#f3f4f6;cursor:pointer;display:flex;align-items:center;justify-content:center;color:#6b7280;transition:color .15s,background-color .15s"
|
||||
onmouseover="this.style.color='#374151';this.style.backgroundColor='#e5e7eb'"
|
||||
onmouseout="this.style.color='#6b7280';this.style.backgroundColor='#f3f4f6'"
|
||||
title="{$closeButtonText}">
|
||||
<svg style="width:16px;height:16px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div style="text-align:center;padding:4px 0 8px">
|
||||
<p style="font-size:13px;color:#9ca3af;margin:0">{$qrHelperText}</p>
|
||||
</div>
|
||||
|
||||
<!-- URL pill -->
|
||||
<div style="display:flex;align-items:center;gap:10px;background:#EFF6FF;border-radius:999px;padding:10px 14px;margin:18px 0 0">
|
||||
<div style="width:30px;height:30px;border-radius:50%;background:#fff;display:flex;align-items:center;justify-content:center;flex-shrink:0;border:1px solid #e5e7eb">
|
||||
<img src="https://icons.duckduckgo.com/ip2/{$destHost}.ico" style="width:16px;height:16px;object-fit:contain" onerror="this.style.display='none'">
|
||||
</div>
|
||||
<span style="flex:1;font-size:14px;font-weight:600;color:#1d4ed8;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{$shortUrl}</span>
|
||||
<div style="display:flex;align-items:center;gap:4px;flex-shrink:0">
|
||||
<button id="{$eid}_copy" type="button"
|
||||
onclick="
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const u='{$shortUrl}';
|
||||
if(navigator.clipboard&&window.isSecureContext){navigator.clipboard.writeText(u);}
|
||||
else{const t=document.createElement('textarea');t.value=u;t.style.cssText='position:fixed;left:-9999px';document.body.appendChild(t);t.select();document.execCommand('copy');t.remove();}
|
||||
const b=document.getElementById('{$eid}_copy');
|
||||
const prev=b.innerHTML;
|
||||
b.innerHTML='<svg style=\'width:16px;height:16px;color:#16a34a\' fill=\'none\' viewBox=\'0 0 24 24\' stroke=\'currentColor\' stroke-width=\'2\'><path stroke-linecap=\'round\' stroke-linejoin=\'round\' d=\'M5 13l4 4L19 7\'/></svg>';
|
||||
setTimeout(()=>b.innerHTML=prev,1800);
|
||||
"
|
||||
style="width:30px;height:30px;border-radius:7px;border:1px solid #e5e7eb;background:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .15s"
|
||||
onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'" title="{$copyLinkText}">
|
||||
<svg style="width:16px;height:16px;color:#6b7280" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path stroke-linecap="round" stroke-linejoin="round" d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
|
||||
</button>
|
||||
<a href="{$shortUrl}" target="_blank" rel="noopener noreferrer"
|
||||
style="width:30px;height:30px;border-radius:7px;border:1px solid #e5e7eb;background:#fff;display:flex;align-items:center;justify-content:center;text-decoration:none;transition:background .15s"
|
||||
onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'" title="{$openLinkText}">
|
||||
<svg style="width:15px;height:15px;color:#6b7280" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QR Code Container (Visible immediately) -->
|
||||
<div id="{$eid}_qr_container" style="display:block;margin:16px 0 0;text-align:center;background:#f9fafb;border:1px solid #e5e7eb;border-radius:12px;padding:16px">
|
||||
<div style="display:flex;justify-content:center;margin-bottom:12px">
|
||||
<div id="{$eid}_qr_canvas" style="background:#fff;padding:8px;border-radius:8px;box-shadow:0 1px 3px rgba(0,0,0,0.05)"></div>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:center;gap:8px">
|
||||
<button type="button" onclick="event.preventDefault();event.stopPropagation();window['qr_{$eid}']?.download({ name: '{$urlKey}-qr', extension: 'svg' })" style="font-size:12px;font-weight:600;color:#374151;border:1.5px solid #e5e7eb;background:#fff;padding:6px 12px;border-radius:8px;cursor:pointer;transition:background .15s" onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'">{$downloadSvgText}</button>
|
||||
<button type="button" onclick="event.preventDefault();event.stopPropagation();window['qr_{$eid}']?.download({ name: '{$urlKey}-qr', extension: 'png' })" style="font-size:12px;font-weight:600;color:#374151;border:1.5px solid #e5e7eb;background:#fff;padding:6px 12px;border-radius:8px;cursor:pointer;transition:background .15s" onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'">{$downloadPngText}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
HTML;
|
||||
|
||||
return [
|
||||
Forms\Components\Placeholder::make('qr_modal_content')
|
||||
->hiddenLabel()
|
||||
->content(new HtmlString($html)),
|
||||
];
|
||||
}),
|
||||
|
||||
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',
|
||||
]),
|
||||
->iconButton()
|
||||
->tooltip(__('filament-short-url::default.action_stats'))
|
||||
->url(fn (ShortUrl $record): string => ShortUrlResource::getUrl('stats', ['record' => $record])),
|
||||
|
||||
EditAction::make()
|
||||
->icon('heroicon-o-pencil-square')
|
||||
->size('sm')
|
||||
->color('gray'),
|
||||
->color('gray')
|
||||
->iconButton()
|
||||
->tooltip(__('filament-short-url::default.action_edit')),
|
||||
|
||||
DeleteAction::make()
|
||||
->icon('heroicon-o-trash')
|
||||
->color('gray')
|
||||
->iconButton()
|
||||
->tooltip(__('filament-short-url::default.action_delete')),
|
||||
])
|
||||
->contentGrid([
|
||||
'md' => 1,
|
||||
'xl' => 1,
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
BulkAction::make('enable')
|
||||
->label('Enable selected')
|
||||
->label(__('filament-short-url::default.action_enable_selected'))
|
||||
->icon('heroicon-o-check-circle')
|
||||
->action(fn ($records) => $records->each->update(['is_enabled' => true]))
|
||||
->deselectRecordsAfterCompletion(),
|
||||
|
||||
BulkAction::make('disable')
|
||||
->label('Disable selected')
|
||||
->label(__('filament-short-url::default.action_disable_selected'))
|
||||
->icon('heroicon-o-x-circle')
|
||||
->action(fn ($records) => $records->each->update(['is_enabled' => false]))
|
||||
->deselectRecordsAfterCompletion(),
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit;
|
||||
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ShortUrlGlobalOverview extends BaseWidget
|
||||
{
|
||||
/** Disable automatic Livewire polling — cache is invalidated via model events instead. */
|
||||
protected ?string $pollingInterval = null;
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
/** Cache key for link counts (forever, busted on create/delete). */
|
||||
public const LINKS_CACHE_KEY = 'filament-short-url:global-links';
|
||||
|
||||
/** Cache key for click trends (short TTL, time-based data). */
|
||||
public const CLICKS_CACHE_KEY = 'filament-short-url:global-clicks';
|
||||
|
||||
protected function getStats(): array
|
||||
{
|
||||
$linkData = $this->getLinkStats();
|
||||
$clickData = $this->getClickStats();
|
||||
|
||||
$clicksTrend = $clickData['prev7Clicks'] > 0
|
||||
? round((($clickData['last7Clicks'] - $clickData['prev7Clicks']) / $clickData['prev7Clicks']) * 100, 1)
|
||||
: ($clickData['last7Clicks'] > 0 ? 100.0 : 0.0);
|
||||
|
||||
$uniquesTrend = $clickData['prev7Uniques'] > 0
|
||||
? round((($clickData['last7Uniques'] - $clickData['prev7Uniques']) / $clickData['prev7Uniques']) * 100, 1)
|
||||
: ($clickData['last7Uniques'] > 0 ? 100.0 : 0.0);
|
||||
|
||||
return [
|
||||
Stat::make(__('filament-short-url::default.navigation_label'), number_format($linkData['totalLinks']))
|
||||
->description($linkData['activeLinks'].' active · '.$linkData['activeRatio'].'%')
|
||||
->descriptionIcon('heroicon-m-link')
|
||||
->icon('heroicon-o-link')
|
||||
->color('gray'),
|
||||
|
||||
Stat::make(__('filament-short-url::default.stats_card_total'), number_format($clickData['totalVisits']))
|
||||
->description(abs($clicksTrend).'% '.($clicksTrend >= 0 ? 'up' : 'down').' vs last 7 days')
|
||||
->descriptionIcon($clicksTrend >= 0 ? 'heroicon-m-arrow-trending-up' : 'heroicon-m-arrow-trending-down')
|
||||
->icon('heroicon-o-cursor-arrow-rays')
|
||||
->color($clicksTrend >= 0 ? 'success' : 'danger'),
|
||||
|
||||
Stat::make(__('filament-short-url::default.stats_card_unique'), number_format($clickData['uniqueVisits']))
|
||||
->description(abs($uniquesTrend).'% '.($uniquesTrend >= 0 ? 'up' : 'down').' vs last 7 days')
|
||||
->descriptionIcon($uniquesTrend >= 0 ? 'heroicon-m-arrow-trending-up' : 'heroicon-m-arrow-trending-down')
|
||||
->icon('heroicon-o-user-group')
|
||||
->color($uniquesTrend >= 0 ? 'success' : 'danger'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Link counts cached forever — invalidated only when a ShortUrl is created or deleted.
|
||||
*
|
||||
* @return array{totalLinks: int, activeLinks: int, activeRatio: int}
|
||||
*/
|
||||
protected function getLinkStats(): array
|
||||
{
|
||||
return Cache::rememberForever(self::LINKS_CACHE_KEY, function () {
|
||||
$agg = ShortUrl::query()
|
||||
->select([
|
||||
DB::raw('COUNT(*) as total'),
|
||||
DB::raw('SUM(CASE WHEN is_enabled THEN 1 ELSE 0 END) as active'),
|
||||
])
|
||||
->first();
|
||||
|
||||
$total = (int) ($agg->total ?? 0);
|
||||
$active = (int) ($agg->active ?? 0);
|
||||
|
||||
return [
|
||||
'totalLinks' => $total,
|
||||
'activeLinks' => $active,
|
||||
'activeRatio' => $total > 0 ? round(($active / $total) * 100) : 0,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Click/visit stats cached with a short TTL (time-based data changes frequently).
|
||||
*
|
||||
* @return array{totalVisits: int, uniqueVisits: int, last7Clicks: int, prev7Clicks: int, last7Uniques: int, prev7Uniques: int}
|
||||
*/
|
||||
protected function getClickStats(): array
|
||||
{
|
||||
$ttl = (int) config('filament-short-url.geo_ip.stats_cache_ttl', 300);
|
||||
|
||||
return Cache::remember(self::CLICKS_CACHE_KEY, $ttl, function () {
|
||||
$agg = ShortUrl::query()
|
||||
->select([
|
||||
DB::raw('SUM(total_visits) as total_visits'),
|
||||
DB::raw('SUM(unique_visits) as unique_visits'),
|
||||
])
|
||||
->first();
|
||||
|
||||
$last7Clicks = ShortUrlVisit::where('visited_at', '>=', now()->subDays(7))->count();
|
||||
$prev7Clicks = ShortUrlVisit::where('visited_at', '>=', now()->subDays(14))
|
||||
->where('visited_at', '<', now()->subDays(7))
|
||||
->count();
|
||||
|
||||
$last7Uniques = ShortUrlVisit::where('visited_at', '>=', now()->subDays(7))
|
||||
->distinct('ip_hash')
|
||||
->count('ip_hash');
|
||||
|
||||
$prev7Uniques = ShortUrlVisit::whereBetween('visited_at', [now()->subDays(14), now()->subDays(7)])
|
||||
->distinct('ip_hash')
|
||||
->count('ip_hash');
|
||||
|
||||
return [
|
||||
'totalVisits' => (int) ($agg->total_visits ?? 0),
|
||||
'uniqueVisits' => (int) ($agg->unique_visits ?? 0),
|
||||
'last7Clicks' => $last7Clicks,
|
||||
'prev7Clicks' => $prev7Clicks,
|
||||
'last7Uniques' => $last7Uniques,
|
||||
'prev7Uniques' => $prev7Uniques,
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -10,13 +10,27 @@ class ShortUrlStatsOverview extends BaseWidget
|
||||
{
|
||||
public ?ShortUrl $record = null;
|
||||
|
||||
public ?string $dateFrom = null;
|
||||
|
||||
public ?string $dateTo = null;
|
||||
|
||||
protected function getStats(): array
|
||||
{
|
||||
if (! $this->record) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$stats = $this->record->getCachedStats();
|
||||
$stats = $this->record->getCachedStats($this->dateFrom, $this->dateTo);
|
||||
|
||||
$topSource = 'Direct';
|
||||
if (! empty($stats['utmSources'])) {
|
||||
$topSource = array_key_first($stats['utmSources']);
|
||||
}
|
||||
|
||||
$topCountry = '—';
|
||||
if (! empty($stats['visitsByCountry'])) {
|
||||
$topCountry = array_key_first($stats['visitsByCountry']);
|
||||
}
|
||||
|
||||
return [
|
||||
Stat::make(__('filament-short-url::default.stats_card_total'), number_format($stats['totalVisits'] ?? 0))
|
||||
@@ -27,17 +41,21 @@ class ShortUrlStatsOverview extends BaseWidget
|
||||
->icon('heroicon-o-user-group')
|
||||
->color('info'),
|
||||
|
||||
Stat::make(__('filament-short-url::default.stats_card_today'), number_format($stats['visitsToday'] ?? 0))
|
||||
->icon('heroicon-o-sun')
|
||||
->color('warning'),
|
||||
|
||||
Stat::make(__('filament-short-url::default.stats_card_week'), number_format($stats['visitsThisWeek'] ?? 0))
|
||||
->icon('heroicon-o-calendar-days')
|
||||
Stat::make(__('filament-short-url::default.stats_card_today_clicks'), number_format($stats['visitsToday'] ?? 0))
|
||||
->icon('heroicon-o-clock')
|
||||
->color('success'),
|
||||
|
||||
Stat::make(__('filament-short-url::default.stats_card_month'), number_format($stats['visitsThisMonth'] ?? 0))
|
||||
->icon('heroicon-o-calendar')
|
||||
->color('gray'),
|
||||
Stat::make(__('filament-short-url::default.stats_card_top_source'), $topSource)
|
||||
->icon('heroicon-o-megaphone')
|
||||
->color('warning'),
|
||||
|
||||
Stat::make(__('filament-short-url::default.stats_card_top_country'), $topCountry)
|
||||
->icon('heroicon-o-globe-alt')
|
||||
->color('success'),
|
||||
|
||||
Stat::make(__('filament-short-url::default.stats_card_qr_scans'), number_format($stats['qrScans'] ?? 0))
|
||||
->icon('heroicon-o-qr-code')
|
||||
->color('info'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@ class ShortUrlVisitsBottomBreakdown extends Widget
|
||||
{
|
||||
public ?ShortUrl $record = null;
|
||||
|
||||
public ?string $dateFrom = null;
|
||||
|
||||
public ?string $dateTo = null;
|
||||
|
||||
protected string $view = 'filament-short-url::widgets.visits-bottom-breakdown';
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
@@ -29,17 +33,25 @@ class ShortUrlVisitsBottomBreakdown extends Widget
|
||||
'visitsByBrowser' => [],
|
||||
'visitsByOs' => [],
|
||||
'visitsByReferer' => [],
|
||||
'visitsByCity' => [],
|
||||
'utmSources' => [],
|
||||
'utmMediums' => [],
|
||||
'utmCampaigns' => [],
|
||||
'totalVisits' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$stats = $this->record->getCachedStats();
|
||||
$stats = $this->record->getCachedStats($this->dateFrom, $this->dateTo);
|
||||
|
||||
return [
|
||||
'visitsByDevice' => $stats['visitsByDevice'] ?? [],
|
||||
'visitsByBrowser' => $stats['visitsByBrowser'] ?? [],
|
||||
'visitsByOs' => $stats['visitsByOs'] ?? [],
|
||||
'visitsByReferer' => $stats['visitsByReferer'] ?? [],
|
||||
'visitsByCity' => $stats['visitsByCity'] ?? [],
|
||||
'utmSources' => $stats['utmSources'] ?? [],
|
||||
'utmMediums' => $stats['utmMediums'] ?? [],
|
||||
'utmCampaigns' => $stats['utmCampaigns'] ?? [],
|
||||
'totalVisits' => $stats['totalVisits'] ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -4,16 +4,19 @@ namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Filament\Widgets\ChartWidget;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class ShortUrlVisitsChart extends ChartWidget
|
||||
{
|
||||
public ?ShortUrl $record = null;
|
||||
|
||||
public ?string $dateFrom = null;
|
||||
|
||||
public ?string $dateTo = null;
|
||||
|
||||
protected ?string $maxHeight = '200px';
|
||||
|
||||
protected int|string|array $columnSpan = [
|
||||
'lg' => 2,
|
||||
];
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
public function getHeading(): string
|
||||
{
|
||||
@@ -29,7 +32,7 @@ class ShortUrlVisitsChart extends ChartWidget
|
||||
];
|
||||
}
|
||||
|
||||
$stats = $this->record->getCachedStats();
|
||||
$stats = $this->record->getCachedStats($this->dateFrom, $this->dateTo);
|
||||
$visitsByDay = $stats['visitsByDay'] ?? [];
|
||||
|
||||
return [
|
||||
@@ -46,7 +49,18 @@ class ShortUrlVisitsChart extends ChartWidget
|
||||
'fill' => true,
|
||||
],
|
||||
],
|
||||
'labels' => array_keys($visitsByDay),
|
||||
'labels' => array_map(function (string $date) {
|
||||
try {
|
||||
$carbon = Carbon::parse($date);
|
||||
if (strlen($date) === 7) { // Y-m
|
||||
return $carbon->format('m.Y');
|
||||
}
|
||||
|
||||
return $carbon->format('d.m');
|
||||
} catch (\Throwable $e) {
|
||||
return $date;
|
||||
}
|
||||
}, array_keys($visitsByDay)),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -9,11 +9,13 @@ class ShortUrlVisitsRightBreakdown extends Widget
|
||||
{
|
||||
public ?ShortUrl $record = null;
|
||||
|
||||
public ?string $dateFrom = null;
|
||||
|
||||
public ?string $dateTo = null;
|
||||
|
||||
protected string $view = 'filament-short-url::widgets.visits-right-breakdown';
|
||||
|
||||
protected int|string|array $columnSpan = [
|
||||
'lg' => 1,
|
||||
];
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
public function mount(?ShortUrl $record = null): void
|
||||
{
|
||||
@@ -25,18 +27,84 @@ class ShortUrlVisitsRightBreakdown extends Widget
|
||||
*/
|
||||
protected function getViewData(): array
|
||||
{
|
||||
$languageNames = __('filament-short-url::languages');
|
||||
if (! is_array($languageNames)) {
|
||||
$languageNames = [];
|
||||
}
|
||||
|
||||
if (! $this->record) {
|
||||
return [
|
||||
'visitsByCountry' => [],
|
||||
'visitsByLanguage' => [],
|
||||
'languageNames' => $languageNames,
|
||||
'totalVisits' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$stats = $this->record->getCachedStats();
|
||||
$stats = $this->record->getCachedStats($this->dateFrom, $this->dateTo);
|
||||
|
||||
return [
|
||||
'visitsByCountry' => $stats['visitsByCountry'] ?? [],
|
||||
'visitsByLanguage' => $stats['visitsByLanguage'] ?? [],
|
||||
'languageNames' => $languageNames,
|
||||
'totalVisits' => $stats['totalVisits'] ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamically translate an ISO language code to the current application locale.
|
||||
*/
|
||||
public static function getLanguageTranslation(string $langCode): string
|
||||
{
|
||||
$langCode = strtolower(trim($langCode));
|
||||
|
||||
// 1. Try loading from languages translation file (languages.php)
|
||||
$translatedLanguages = __('filament-short-url::languages');
|
||||
if (is_array($translatedLanguages) && isset($translatedLanguages[$langCode])) {
|
||||
return $translatedLanguages[$langCode];
|
||||
}
|
||||
|
||||
// 2. Try PHP Locale extension
|
||||
if (class_exists(\Locale::class)) {
|
||||
try {
|
||||
$name = \Locale::getDisplayLanguage($langCode, app()->getLocale());
|
||||
if ($name && $name !== $langCode) {
|
||||
return mb_convert_case($name, MB_CASE_TITLE, 'UTF-8');
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Fallback
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Simple fallback
|
||||
return strtoupper($langCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamically translate an English country name to the current application locale.
|
||||
*/
|
||||
public static function getCountryTranslation(string $englishName): string
|
||||
{
|
||||
$englishName = trim($englishName);
|
||||
|
||||
try {
|
||||
$enCountries = trans('filament-short-url::countries', [], 'en');
|
||||
if (is_array($enCountries)) {
|
||||
$flipped = array_change_key_case(array_flip($enCountries), CASE_LOWER);
|
||||
$lookupKey = strtolower($englishName);
|
||||
$code = $flipped[$lookupKey] ?? null;
|
||||
|
||||
if ($code) {
|
||||
$translated = __('filament-short-url::countries.'.strtoupper($code));
|
||||
if ($translated && $translated !== 'filament-short-url::countries.'.strtoupper($code)) {
|
||||
return $translated;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Fallback
|
||||
}
|
||||
|
||||
return $englishName;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit;
|
||||
use Filament\Widgets\Widget;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ShortUrlWorldMapWidget extends Widget
|
||||
{
|
||||
public ?ShortUrl $record = null;
|
||||
|
||||
public ?string $dateFrom = null;
|
||||
|
||||
public ?string $dateTo = null;
|
||||
|
||||
protected string $view = 'filament-short-url::widgets.world-map';
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
public function mount(?ShortUrl $record = null): void
|
||||
{
|
||||
$this->record = $record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a country_code → visit count map, merging daily stats and today's raw visits.
|
||||
*
|
||||
* @return array<string, int>
|
||||
*/
|
||||
public function getCountryData(): array
|
||||
{
|
||||
if (! $this->record) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$dateFromClean = $this->dateFrom ? Carbon::parse($this->dateFrom)->toDateString() : null;
|
||||
$dateToClean = $this->dateTo ? Carbon::parse($this->dateTo)->toDateString() : null;
|
||||
$today = Carbon::today()->toDateString();
|
||||
|
||||
$cacheTtl = (int) config('filament-short-url.geo_ip.stats_cache_ttl', 300);
|
||||
$cacheKey = "short_url_world_map_{$this->record->id}_".($dateFromClean ?: 'all').'_'.($dateToClean ?: 'all');
|
||||
|
||||
return cache()->remember($cacheKey, $cacheTtl, function () use ($dateFromClean, $dateToClean) {
|
||||
$counts = [];
|
||||
|
||||
$query = ShortUrlVisit::query()
|
||||
->select('country_code', DB::raw('COUNT(*) as cnt'))
|
||||
->where('short_url_id', $this->record->id)
|
||||
->whereNotNull('country_code')
|
||||
->where('country_code', '!=', '')
|
||||
->where('is_bot', false)
|
||||
->where('is_proxy', false);
|
||||
|
||||
if ($dateFromClean) {
|
||||
$query->whereDate('visited_at', '>=', $dateFromClean);
|
||||
}
|
||||
if ($dateToClean) {
|
||||
$query->whereDate('visited_at', '<=', $dateToClean);
|
||||
}
|
||||
|
||||
foreach ($query->groupBy('country_code')->get() as $row) {
|
||||
$code = strtoupper(trim($row->country_code));
|
||||
if ($code) {
|
||||
$counts[$code] = (int) $row->cnt;
|
||||
}
|
||||
}
|
||||
|
||||
arsort($counts);
|
||||
|
||||
return $counts;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function getViewData(): array
|
||||
{
|
||||
$countryData = $this->getCountryData();
|
||||
$max = max(1, ...array_values($countryData) ?: [1]);
|
||||
$total = array_sum($countryData);
|
||||
|
||||
// Normalise to 0–100 for CSS opacity/intensity
|
||||
$normalized = [];
|
||||
foreach ($countryData as $code => $count) {
|
||||
$normalized[$code] = round(($count / $max) * 100);
|
||||
}
|
||||
|
||||
// Load and clean up raw SVG map on the server-side where paths are reliable
|
||||
$svgPath = dirname(__FILE__, 6).'/resources/views/widgets/world-map.svg';
|
||||
$svgContent = '';
|
||||
if (file_exists($svgPath)) {
|
||||
$svgContent = file_get_contents($svgPath);
|
||||
// Remove XML declaration and DOCTYPE tags
|
||||
$svgContent = preg_replace('/<\?xml[^>]*\?>/i', '', $svgContent);
|
||||
$svgContent = preg_replace('/<!DOCTYPE[^>]*>/i', '', $svgContent);
|
||||
}
|
||||
|
||||
return [
|
||||
'countryData' => $countryData,
|
||||
'maxCount' => $max,
|
||||
'totalClicks' => $total,
|
||||
'normalized' => $normalized,
|
||||
'svgContent' => $svgContent,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlPixelResource;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
|
||||
use Filament\Contracts\Plugin;
|
||||
use Filament\Panel;
|
||||
@@ -12,8 +13,14 @@ class FilamentShortUrlPlugin implements Plugin
|
||||
|
||||
protected ?int $navigationSort = null;
|
||||
|
||||
protected ?string $navigationLabel = null;
|
||||
|
||||
protected ?string $navigationIcon = null;
|
||||
|
||||
protected ?string $routePrefix = null;
|
||||
|
||||
protected ?\Closure $authorizeSettingsUsing = null;
|
||||
|
||||
// ─── Factory ─────────────────────────────────────────────────────────────
|
||||
|
||||
public static function make(): static
|
||||
@@ -38,6 +45,10 @@ class FilamentShortUrlPlugin implements Plugin
|
||||
$panel
|
||||
->resources([
|
||||
ShortUrlResource::class,
|
||||
ShortUrlPixelResource::class,
|
||||
])
|
||||
->pages([
|
||||
\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\ShortUrlSettingsPage::class,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -67,6 +78,8 @@ class FilamentShortUrlPlugin implements Plugin
|
||||
|
||||
/**
|
||||
* Set the navigation sort order.
|
||||
*
|
||||
* @example FilamentShortUrlPlugin::make()->navigationSort(50)
|
||||
*/
|
||||
public function navigationSort(int $sort): static
|
||||
{
|
||||
@@ -79,4 +92,55 @@ class FilamentShortUrlPlugin implements Plugin
|
||||
{
|
||||
return $this->navigationSort;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override the navigation label (menu item name).
|
||||
*
|
||||
* @example FilamentShortUrlPlugin::make()->navigationLabel('Short Links')
|
||||
*/
|
||||
public function navigationLabel(string $label): static
|
||||
{
|
||||
$this->navigationLabel = $label;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getNavigationLabel(): ?string
|
||||
{
|
||||
return $this->navigationLabel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override the navigation icon.
|
||||
*
|
||||
* @example FilamentShortUrlPlugin::make()->navigationIcon('heroicon-o-link')
|
||||
*/
|
||||
public function navigationIcon(string $icon): static
|
||||
{
|
||||
$this->navigationIcon = $icon;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getNavigationIcon(): ?string
|
||||
{
|
||||
return $this->navigationIcon;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a custom callback to authorize access to the Short URL settings page.
|
||||
*
|
||||
* @example FilamentShortUrlPlugin::make()->authorizeSettingsUsing(fn () => auth()->user()->hasRole('admin'))
|
||||
*/
|
||||
public function authorizeSettingsUsing(\Closure $callback): static
|
||||
{
|
||||
$this->authorizeSettingsUsing = $callback;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAuthorizeSettingsUsing(): ?\Closure
|
||||
{
|
||||
return $this->authorizeSettingsUsing;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,17 @@
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Console\Commands\SyncBufferedCountersCommand;
|
||||
use Bjanczak\FilamentShortUrl\Services\GeoIpService;
|
||||
use Bjanczak\FilamentShortUrl\Services\ProxyDetectionService;
|
||||
use Bjanczak\FilamentShortUrl\Services\SafeBrowsingService;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlSettingsManager;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlTracker;
|
||||
use Bjanczak\FilamentShortUrl\Services\UserAgentParser;
|
||||
use Filament\Support\Assets\Css;
|
||||
use Filament\Support\Facades\FilamentAsset;
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
use Spatie\LaravelPackageTools\Package;
|
||||
use Spatie\LaravelPackageTools\PackageServiceProvider;
|
||||
|
||||
@@ -24,6 +30,21 @@ class FilamentShortUrlServiceProvider extends PackageServiceProvider
|
||||
->hasMigrations([
|
||||
'2024_01_01_000001_create_short_urls_table',
|
||||
'2024_01_01_000002_create_short_url_visits_table',
|
||||
'2026_06_01_000003_add_utm_city_referer_to_short_url_visits_table',
|
||||
'2026_06_01_000004_add_targeting_and_security_to_short_urls_table',
|
||||
'2026_06_01_000005_create_short_url_daily_stats_table',
|
||||
'2026_06_02_000006_add_max_visits_and_expiration_redirect_to_short_urls_table',
|
||||
'2026_06_02_000007_add_retargeting_pixels_and_webhooks_to_short_urls_table',
|
||||
'2026_06_02_000008_add_bot_and_proxy_to_short_url_visits_table',
|
||||
'2026_06_02_224250_add_qr_logo_to_short_urls_table',
|
||||
'2026_06_03_110000_add_qr_and_language_to_visits_and_daily_stats',
|
||||
'2026_06_03_120000_add_track_browser_language_to_short_urls_table',
|
||||
'2026_06_03_150000_create_short_url_pixels_table',
|
||||
'2026_06_03_160000_add_auto_open_app_mobile_to_short_urls_table',
|
||||
])
|
||||
->hasCommands([
|
||||
SyncBufferedCountersCommand::class,
|
||||
Console\Commands\AggregateAndPruneVisitsCommand::class,
|
||||
])
|
||||
->hasRoutes(['web']);
|
||||
}
|
||||
@@ -38,5 +59,25 @@ class FilamentShortUrlServiceProvider extends PackageServiceProvider
|
||||
$this->app->singleton(GeoIpService::class);
|
||||
$this->app->singleton(ShortUrlService::class);
|
||||
$this->app->singleton(ShortUrlTracker::class);
|
||||
$this->app->singleton(ProxyDetectionService::class);
|
||||
$this->app->singleton(SafeBrowsingService::class);
|
||||
}
|
||||
|
||||
public function packageBooted(): void
|
||||
{
|
||||
FilamentAsset::register([
|
||||
Css::make('filament-short-url', __DIR__.'/../resources/dist/filament-short-url.css'),
|
||||
], package: 'janczakb/filament-short-url');
|
||||
|
||||
// Automatically register scheduled tasks in the application scheduler
|
||||
$this->app->booted(function (): void {
|
||||
$schedule = $this->app->make(Schedule::class);
|
||||
|
||||
$schedule->command('short-url:aggregate-and-prune')->dailyAt('02:00');
|
||||
|
||||
if (config('filament-short-url.counter_buffering.enabled', false)) {
|
||||
$schedule->command('short-url:sync-counters')->everyMinute();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
433
src/Http/Controllers/ShortUrlApiController.php
Normal file
433
src/Http/Controllers/ShortUrlApiController.php
Normal file
@@ -0,0 +1,433 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Http\Controllers;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Jobs\SendWebhookJob;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class ShortUrlApiController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ShortUrlService $service
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Display a listing of short URLs.
|
||||
*/
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$links = ShortUrl::orderBy('id', 'desc')->paginate(30);
|
||||
|
||||
$transformed = $links->getCollection()->map(fn ($link) => $this->transformLink($link));
|
||||
|
||||
return response()->json([
|
||||
'data' => $transformed,
|
||||
'meta' => [
|
||||
'current_page' => $links->currentPage(),
|
||||
'last_page' => $links->lastPage(),
|
||||
'per_page' => $links->perPage(),
|
||||
'total' => $links->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created short URL.
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'destination_url' => 'required|url|max:2048',
|
||||
'url_key' => 'nullable|string|alpha_dash|max:50|unique:short_urls,url_key',
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
'is_enabled' => 'nullable|boolean',
|
||||
'redirect_status_code' => 'nullable|integer|in:301,302',
|
||||
'single_use' => 'nullable|boolean',
|
||||
'forward_query_params' => 'nullable|boolean',
|
||||
'max_visits' => 'nullable|integer|min:1',
|
||||
'expiration_redirect_url' => 'nullable|url|max:2048',
|
||||
'activated_at' => 'nullable|date',
|
||||
'expires_at' => 'nullable|date',
|
||||
'webhook_url' => 'nullable|url|max:2048',
|
||||
'targeting_rules' => 'nullable|array',
|
||||
'password' => 'nullable|string|max:255',
|
||||
'show_warning_page' => 'nullable|boolean',
|
||||
'track_visits' => 'nullable|boolean',
|
||||
'track_browser_language' => 'nullable|boolean',
|
||||
'pixels' => 'nullable|array',
|
||||
'pixels.*' => 'integer|exists:short_url_pixels,id',
|
||||
// Keep for backward compatibility
|
||||
'pixel_meta_id' => 'nullable|string|max:100',
|
||||
'pixel_google_id' => 'nullable|string|max:100',
|
||||
'pixel_linkedin_id' => 'nullable|string|max:100',
|
||||
]);
|
||||
|
||||
$pixelIds = $validated['pixels'] ?? [];
|
||||
|
||||
// Backward compatibility support for old pixel columns
|
||||
if (! empty($validated['pixel_meta_id'])) {
|
||||
$metaPixelId = \Bjanczak\FilamentShortUrl\Models\ShortUrlPixel::firstOrCreate([
|
||||
'type' => 'meta',
|
||||
'pixel_id' => $validated['pixel_meta_id'],
|
||||
], [
|
||||
'name' => 'Meta Pixel (' . $validated['pixel_meta_id'] . ')',
|
||||
'is_active' => true,
|
||||
])->id;
|
||||
$pixelIds[] = $metaPixelId;
|
||||
}
|
||||
|
||||
if (! empty($validated['pixel_google_id'])) {
|
||||
$googlePixelId = \Bjanczak\FilamentShortUrl\Models\ShortUrlPixel::firstOrCreate([
|
||||
'type' => 'google',
|
||||
'pixel_id' => $validated['pixel_google_id'],
|
||||
], [
|
||||
'name' => 'Google Tag (' . $validated['pixel_google_id'] . ')',
|
||||
'is_active' => true,
|
||||
])->id;
|
||||
$pixelIds[] = $googlePixelId;
|
||||
}
|
||||
|
||||
if (! empty($validated['pixel_linkedin_id'])) {
|
||||
$linkedinPixelId = \Bjanczak\FilamentShortUrl\Models\ShortUrlPixel::firstOrCreate([
|
||||
'type' => 'linkedin',
|
||||
'pixel_id' => $validated['pixel_linkedin_id'],
|
||||
], [
|
||||
'name' => 'LinkedIn Insight (' . $validated['pixel_linkedin_id'] . ')',
|
||||
'is_active' => true,
|
||||
])->id;
|
||||
$pixelIds[] = $linkedinPixelId;
|
||||
}
|
||||
|
||||
// Clean up parameters that shouldn't be mass assigned
|
||||
unset($validated['pixel_meta_id'], $validated['pixel_google_id'], $validated['pixel_linkedin_id'], $validated['pixels']);
|
||||
|
||||
$shortUrl = $this->service->create($validated);
|
||||
|
||||
if (! empty($pixelIds)) {
|
||||
$shortUrl->pixels()->sync($pixelIds);
|
||||
}
|
||||
|
||||
// Fire 'created' webhook if active
|
||||
$this->dispatchCreatedWebhook($shortUrl);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Short URL created successfully.',
|
||||
'data' => $this->transformLink($shortUrl),
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified short URL.
|
||||
*/
|
||||
public function destroy(int $id): JsonResponse
|
||||
{
|
||||
$shortUrl = ShortUrl::findOrFail($id);
|
||||
$shortUrl->delete();
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Short URL deleted successfully.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a ShortUrl model to API response array.
|
||||
*/
|
||||
private function transformLink(ShortUrl $link): array
|
||||
{
|
||||
$pixels = $link->relationLoaded('pixels') ? $link->pixels : $link->pixels()->get();
|
||||
|
||||
return [
|
||||
'id' => $link->id,
|
||||
'destination_url' => $link->destination_url,
|
||||
'url_key' => $link->url_key,
|
||||
'short_url' => $link->getShortUrl(),
|
||||
'is_enabled' => (bool) $link->is_enabled,
|
||||
'redirect_status_code' => (int) $link->redirect_status_code,
|
||||
'total_visits' => (int) $link->total_visits,
|
||||
'unique_visits' => (int) $link->unique_visits,
|
||||
'max_visits' => $link->max_visits ? (int) $link->max_visits : null,
|
||||
'activated_at' => $link->activated_at ? $link->activated_at->toIso8601String() : null,
|
||||
'expires_at' => $link->expires_at ? $link->expires_at->toIso8601String() : null,
|
||||
'pixel_meta_id' => $pixels->where('type', 'meta')->first()?->pixel_id,
|
||||
'pixel_google_id' => $pixels->where('type', 'google')->first()?->pixel_id,
|
||||
'pixel_linkedin_id' => $pixels->where('type', 'linkedin')->first()?->pixel_id,
|
||||
'webhook_url' => $link->webhook_url,
|
||||
'targeting_rules' => $link->targeting_rules,
|
||||
'password' => $link->password,
|
||||
'show_warning_page' => (bool) $link->show_warning_page,
|
||||
'track_visits' => (bool) $link->track_visits,
|
||||
'track_browser_language' => (bool) $link->track_browser_language,
|
||||
'pixels' => $pixels->map(fn ($p) => [
|
||||
'id' => $p->id,
|
||||
'name' => $p->name,
|
||||
'type' => $p->type,
|
||||
'pixel_id' => $p->pixel_id,
|
||||
'is_active' => (bool) $p->is_active,
|
||||
])->toArray(),
|
||||
'notes' => $link->notes,
|
||||
'created_at' => $link->created_at->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch webhook if global or per-link webhook is active for 'created' event.
|
||||
*/
|
||||
private function dispatchCreatedWebhook(ShortUrl $shortUrl): void
|
||||
{
|
||||
$targetUrl = $shortUrl->webhook_url;
|
||||
$globalUrl = config('filament-short-url.global_webhook_url');
|
||||
$events = config('filament-short-url.webhook_events', []);
|
||||
|
||||
if (empty($targetUrl) && ! empty($globalUrl) && in_array('created', $events)) {
|
||||
$targetUrl = $globalUrl;
|
||||
}
|
||||
|
||||
if (! empty($targetUrl)) {
|
||||
try {
|
||||
$connection = config('filament-short-url.queue_connection', 'sync');
|
||||
dispatch(new SendWebhookJob(
|
||||
url: $targetUrl,
|
||||
event: 'created',
|
||||
payload: [
|
||||
'event' => 'created',
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
'short_url' => $this->transformLink($shortUrl),
|
||||
]
|
||||
)->onConnection($connection ?: 'sync'));
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('[FilamentShortUrl] Created webhook dispatch failed', [
|
||||
'url_key' => $shortUrl->url_key,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload QR logo file from admin panel.
|
||||
*/
|
||||
public function uploadLogo(Request $request): JsonResponse
|
||||
{
|
||||
Log::info('ShortUrlApiController::uploadLogo called', [
|
||||
'auth_check' => auth()->check(),
|
||||
'user_id' => auth()->id(),
|
||||
'has_file' => $request->hasFile('logo'),
|
||||
'all_files' => array_keys($request->allFiles()),
|
||||
]);
|
||||
|
||||
if (! auth()->check()) {
|
||||
return response()->json(['error' => 'Unauthorized'], 401);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'logo' => 'required|image|max:10240',
|
||||
]);
|
||||
|
||||
if ($request->hasFile('logo')) {
|
||||
$file = $request->file('logo');
|
||||
$tempPath = $file->getRealPath();
|
||||
$filename = Str::random(40).'.webp';
|
||||
$targetPath = 'short-urls/tmp/'.$filename;
|
||||
|
||||
$processed = $this->processLogo($tempPath, $targetPath);
|
||||
|
||||
if ($processed) {
|
||||
$path = $targetPath;
|
||||
} else {
|
||||
// Fallback: store raw file if image processing fails
|
||||
$path = $file->store('short-urls/tmp', 'public');
|
||||
}
|
||||
|
||||
$url = route('short-url.logo', ['filename' => basename($path)]);
|
||||
|
||||
return response()->json([
|
||||
'path' => $path,
|
||||
'url' => $url,
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json(['error' => 'No file uploaded'], 400);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the uploaded logo: detect available driver and optimize/convert to WebP.
|
||||
*/
|
||||
private function processLogo(string $filePath, string $targetPath): bool
|
||||
{
|
||||
if (extension_loaded('imagick') && class_exists(\Imagick::class)) {
|
||||
return $this->processLogoWithImagick($filePath, $targetPath);
|
||||
}
|
||||
|
||||
if (extension_loaded('gd') && function_exists('gd_info')) {
|
||||
return $this->processLogoWithGd($filePath, $targetPath);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the logo using Imagick: scale down to 800px max edge and convert to WebP.
|
||||
*/
|
||||
private function processLogoWithImagick(string $filePath, string $targetPath): bool
|
||||
{
|
||||
try {
|
||||
$imagick = new \Imagick($filePath);
|
||||
|
||||
// Get original dimensions
|
||||
$width = $imagick->getImageWidth();
|
||||
$height = $imagick->getImageHeight();
|
||||
|
||||
// Calculate new dimensions
|
||||
$maxDim = 800;
|
||||
if ($width > $maxDim || $height > $maxDim) {
|
||||
if ($width > $height) {
|
||||
$newWidth = $maxDim;
|
||||
$newHeight = (int) round(($height * $maxDim) / $width);
|
||||
} else {
|
||||
$newHeight = $maxDim;
|
||||
$newWidth = (int) round(($width * $maxDim) / $height);
|
||||
}
|
||||
$imagick->scaleImage($newWidth, $newHeight);
|
||||
}
|
||||
|
||||
// Convert to WebP format
|
||||
$imagick->setImageFormat('webp');
|
||||
$imagick->setImageCompressionQuality(85);
|
||||
|
||||
// Get image data as blob
|
||||
$webpData = $imagick->getImageBlob();
|
||||
|
||||
// Clear resources
|
||||
$imagick->clear();
|
||||
$imagick->destroy();
|
||||
|
||||
if ($webpData) {
|
||||
return Storage::disk('public')->put($targetPath, $webpData);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Fall back to GD or raw store
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the uploaded logo using GD: scale down to 800px on the longer side (preserving aspect ratio) and convert to WebP.
|
||||
*/
|
||||
private function processLogoWithGd(string $filePath, string $targetPath): bool
|
||||
{
|
||||
// 1. Get original dimensions and type
|
||||
$info = @getimagesize($filePath);
|
||||
if (! $info) {
|
||||
return false;
|
||||
}
|
||||
|
||||
[$width, $height, $type] = $info;
|
||||
|
||||
// 2. Load image based on type
|
||||
switch ($type) {
|
||||
case IMAGETYPE_JPEG:
|
||||
$src = @imagecreatefromjpeg($filePath);
|
||||
break;
|
||||
case IMAGETYPE_PNG:
|
||||
$src = @imagecreatefrompng($filePath);
|
||||
break;
|
||||
case IMAGETYPE_WEBP:
|
||||
$src = @imagecreatefromwebp($filePath);
|
||||
break;
|
||||
case IMAGETYPE_GIF:
|
||||
$src = @imagecreatefromgif($filePath);
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $src) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. Calculate new dimensions
|
||||
$maxDim = 800;
|
||||
$newWidth = $width;
|
||||
$newHeight = $height;
|
||||
|
||||
if ($width > $maxDim || $height > $maxDim) {
|
||||
if ($width > $height) {
|
||||
$newWidth = $maxDim;
|
||||
$newHeight = (int) round(($height * $maxDim) / $width);
|
||||
} else {
|
||||
$newHeight = $maxDim;
|
||||
$newWidth = (int) round(($width * $maxDim) / $height);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Create new truecolor image
|
||||
$dst = imagecreatetruecolor($newWidth, $newHeight);
|
||||
if (! $dst) {
|
||||
imagedestroy($src);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Preserve transparency for PNG and WebP
|
||||
imagealphablending($dst, false);
|
||||
imagesavealpha($dst, true);
|
||||
|
||||
// Resize
|
||||
imagecopyresampled($dst, $src, 0, 0, 0, 0, $newWidth, $newHeight, $width, $height);
|
||||
|
||||
// 5. Save as WebP
|
||||
$disk = Storage::disk('public');
|
||||
|
||||
ob_start();
|
||||
$saved = imagewebp($dst, null, 85);
|
||||
$webpData = ob_get_clean();
|
||||
|
||||
// Free memory
|
||||
imagedestroy($src);
|
||||
imagedestroy($dst);
|
||||
|
||||
if ($saved && $webpData !== false) {
|
||||
return $disk->put($targetPath, $webpData);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve the uploaded QR logo.
|
||||
*/
|
||||
public function serveLogo(string $filename): StreamedResponse|BinaryFileResponse
|
||||
{
|
||||
// Prevent directory traversal attacks
|
||||
$filename = basename($filename);
|
||||
if (! preg_match('/^[a-zA-Z0-9_\-]+\.[a-zA-Z0-9]+$/', $filename)) {
|
||||
abort(400, 'Invalid filename');
|
||||
}
|
||||
|
||||
$disk = Storage::disk('public');
|
||||
$path = 'short-urls/logos/'.$filename;
|
||||
|
||||
if (! $disk->exists($path)) {
|
||||
$path = 'short-urls/tmp/'.$filename;
|
||||
if (! $disk->exists($path)) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
|
||||
return $disk->response($path, null, [
|
||||
'Access-Control-Allow-Origin' => '*',
|
||||
'Access-Control-Allow-Methods' => 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers' => 'Content-Type, X-Requested-With',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,17 @@ namespace Bjanczak\FilamentShortUrl\Http\Controllers;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Jobs\TrackShortUrlVisitJob;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Services\AppLinkingEngine;
|
||||
use Bjanczak\FilamentShortUrl\Services\ClientIpExtractor;
|
||||
use Bjanczak\FilamentShortUrl\Services\ProxyDetectionService;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Bjanczak\FilamentShortUrl\Services\UserAgentParser;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\MessageBag;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ShortUrlRedirectController extends Controller
|
||||
{
|
||||
@@ -16,7 +22,7 @@ class ShortUrlRedirectController extends Controller
|
||||
private readonly ShortUrlService $service,
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request, string $key): RedirectResponse
|
||||
public function __invoke(Request $request, string $key): Response
|
||||
{
|
||||
$shortUrl = ShortUrl::findByKey($key);
|
||||
|
||||
@@ -25,28 +31,144 @@ class ShortUrlRedirectController extends Controller
|
||||
abort(404);
|
||||
}
|
||||
|
||||
// 410 Gone if disabled or expired
|
||||
// Redirect to custom expiration URL if defined, otherwise 410 Gone if disabled or expired
|
||||
if (! $shortUrl->isActive()) {
|
||||
abort(410);
|
||||
if ($shortUrl->expiration_redirect_url) {
|
||||
return redirect()->away($shortUrl->expiration_redirect_url, 302);
|
||||
}
|
||||
|
||||
return response(view('filament-short-url::expired', [
|
||||
'shortUrl' => $shortUrl,
|
||||
]), 410)->header('Content-Type', 'text/html');
|
||||
}
|
||||
|
||||
if ($shortUrl->track_visits) {
|
||||
$connection = config('filament-short-url.queue_connection');
|
||||
// 1. VPN/Proxy & Bot Blocking Check
|
||||
if (config('filament-short-url.vpn_detection.enabled', false) && config('filament-short-url.vpn_detection.block_action') === 'block_with_403') {
|
||||
$ipAddress = ClientIpExtractor::getIp($request);
|
||||
$countryCode = ClientIpExtractor::getCountryCode($request);
|
||||
$proxyDetector = app(ProxyDetectionService::class);
|
||||
$detection = $proxyDetector->detect($ipAddress);
|
||||
if ($detection['is_proxy'] || $detection['is_bot']) {
|
||||
abort(403, 'Access denied. VPN, Proxy, or automated scraping connection detected.');
|
||||
}
|
||||
}
|
||||
|
||||
$job = new TrackShortUrlVisitJob(
|
||||
shortUrl: $shortUrl,
|
||||
ipAddress: $ipAddress,
|
||||
userAgent: $request->userAgent() ?? '',
|
||||
refererUrl: $request->header('Referer'),
|
||||
countryCode: $countryCode,
|
||||
);
|
||||
// 2. Rate Limiting Check
|
||||
if (config('filament-short-url.rate_limiting.enabled', false)) {
|
||||
$maxAttempts = (int) config('filament-short-url.rate_limiting.max_attempts', 60);
|
||||
$decaySeconds = (int) config('filament-short-url.rate_limiting.decay_seconds', 60);
|
||||
$ipAddress = ClientIpExtractor::getIp($request);
|
||||
$limiterKey = "short_url_limit:{$key}:".$ipAddress;
|
||||
|
||||
if ($connection && $connection !== 'default') {
|
||||
dispatch($job->onConnection($connection));
|
||||
} else {
|
||||
dispatch($job);
|
||||
if (RateLimiter::tooManyAttempts($limiterKey, $maxAttempts)) {
|
||||
$retryAfter = RateLimiter::availableIn($limiterKey);
|
||||
abort(429, 'Too many requests. Please try again in '.$retryAfter.' seconds.', [
|
||||
'Retry-After' => $retryAfter,
|
||||
]);
|
||||
}
|
||||
RateLimiter::hit($limiterKey, $decaySeconds);
|
||||
}
|
||||
|
||||
// 3. Password Protection Check
|
||||
if (! empty($shortUrl->password)) {
|
||||
$sessionKey = "short-url-auth-{$shortUrl->id}";
|
||||
if (! session()->get($sessionKey)) {
|
||||
if ($request->isMethod('POST')) {
|
||||
$submitted = $request->input('password');
|
||||
if ($submitted === $shortUrl->password) {
|
||||
session()->put($sessionKey, true);
|
||||
|
||||
return redirect()->to($request->fullUrl());
|
||||
}
|
||||
|
||||
$errors = new MessageBag([
|
||||
'password' => __('filament-short-url::default.password_error') ?? 'Incorrect password.',
|
||||
]);
|
||||
|
||||
return response(view('filament-short-url::password-prompt', ['errors' => $errors]))
|
||||
->header('Content-Type', 'text/html');
|
||||
}
|
||||
|
||||
return response(view('filament-short-url::password-prompt'))
|
||||
->header('Content-Type', 'text/html');
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Resolve Destination URL (evaluating targeting rules and forwarding query parameters)
|
||||
$destination = $this->service->resolveRedirectUrl($shortUrl, $request);
|
||||
|
||||
// App Linking / Deep Links Auto-Open Check
|
||||
if ($shortUrl->auto_open_app_mobile) {
|
||||
$uaParser = app(UserAgentParser::class);
|
||||
$parsedUa = $uaParser->parse($request->userAgent() ?? '');
|
||||
|
||||
if ($parsedUa['device_type'] === 'mobile' || $parsedUa['device_type'] === 'tablet') {
|
||||
$matchedApp = AppLinkingEngine::matchApp($destination);
|
||||
if ($matchedApp !== null) {
|
||||
$deepLink = AppLinkingEngine::convertToScheme($destination, $matchedApp);
|
||||
$activePixels = $shortUrl->pixels()->where('is_active', true)->get();
|
||||
|
||||
return response(view('filament-short-url::app-redirect', [
|
||||
'destination' => $destination,
|
||||
'deepLink' => $deepLink,
|
||||
'appId' => $matchedApp,
|
||||
'pixels' => $activePixels,
|
||||
]))->header('Content-Type', 'text/html');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Warning / Intermediate Page Check
|
||||
if ($shortUrl->show_warning_page && ! $request->has('confirmed')) {
|
||||
return response(view('filament-short-url::warning', ['destinationUrl' => $destination]))
|
||||
->header('Content-Type', 'text/html');
|
||||
}
|
||||
|
||||
// 6. Track Visit
|
||||
if ($shortUrl->track_visits) {
|
||||
try {
|
||||
$connection = config('filament-short-url.queue_connection', 'sync');
|
||||
$ipAddress = ClientIpExtractor::getIp($request);
|
||||
$countryCode = ClientIpExtractor::getCountryCode($request);
|
||||
$city = ClientIpExtractor::getCity($request);
|
||||
|
||||
$isQrScan = (bool) ($request->query('source') === 'qr' || $request->query('qr') === '1');
|
||||
$languages = $request->getLanguages();
|
||||
$browserLanguage = null;
|
||||
if (! empty($languages)) {
|
||||
$parts = explode('-', str_replace('_', '-', $languages[0]));
|
||||
$browserLanguage = strtolower(trim($parts[0]));
|
||||
if (strlen($browserLanguage) > 5) {
|
||||
$browserLanguage = substr($browserLanguage, 0, 5);
|
||||
}
|
||||
}
|
||||
|
||||
$job = new TrackShortUrlVisitJob(
|
||||
shortUrl: $shortUrl,
|
||||
ipAddress: $ipAddress,
|
||||
userAgent: $request->userAgent() ?? '',
|
||||
refererUrl: $request->header('Referer'),
|
||||
countryCode: $countryCode,
|
||||
city: $city,
|
||||
utmSource: $request->query('utm_source'),
|
||||
utmMedium: $request->query('utm_medium'),
|
||||
utmCampaign: $request->query('utm_campaign'),
|
||||
utmTerm: $request->query('utm_term'),
|
||||
utmContent: $request->query('utm_content'),
|
||||
isQrScan: $isQrScan,
|
||||
browserLanguage: $browserLanguage,
|
||||
);
|
||||
|
||||
if ($connection) {
|
||||
dispatch($job->onConnection($connection));
|
||||
} else {
|
||||
dispatch($job->onConnection('sync'));
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Never let tracking failures block the redirection of the user!
|
||||
Log::error('[FilamentShortUrl] Redirect tracking failed', [
|
||||
'url_key' => $key,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,12 +180,90 @@ class ShortUrlRedirectController extends Controller
|
||||
|
||||
// Another request beat us to it — this visit should 410
|
||||
if ($affected === 0) {
|
||||
abort(410);
|
||||
return response(view('filament-short-url::expired', [
|
||||
'shortUrl' => $shortUrl,
|
||||
]), 410)->header('Content-Type', 'text/html');
|
||||
}
|
||||
|
||||
// Manually forget cache since DB-level update does not trigger Eloquent events
|
||||
cache()->forget("filament-short-url:{$shortUrl->url_key}");
|
||||
}
|
||||
|
||||
$redirectUrl = $this->service->resolveRedirectUrl($shortUrl, $request);
|
||||
$activePixels = $shortUrl->pixels()->where('is_active', true)->get();
|
||||
|
||||
return redirect()->away($redirectUrl, $shortUrl->redirect_status_code);
|
||||
if ($activePixels->isNotEmpty()) {
|
||||
return response(view('filament-short-url::pixel-loading', [
|
||||
'destination' => $destination,
|
||||
'pixels' => $activePixels,
|
||||
]))->header('Content-Type', 'text/html');
|
||||
}
|
||||
|
||||
return redirect()->away($destination, $shortUrl->redirect_status_code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve the Apple App Site Association (AASA) file for iOS Universal Links.
|
||||
*/
|
||||
public function serveAasa(Request $request): Response
|
||||
{
|
||||
if (! config('filament-short-url.deep_linking.enabled', false)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$aasaJson = config('filament-short-url.deep_linking.aasa_json');
|
||||
|
||||
if (empty($aasaJson)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$minified = cache()->remember('fsu:deep-linking:aasa', 604800, function () use ($aasaJson): string {
|
||||
try {
|
||||
$decoded = json_decode($aasaJson, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
return json_encode($decoded, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
} catch (\Throwable) {
|
||||
return $aasaJson;
|
||||
}
|
||||
});
|
||||
|
||||
return response($minified, 200, [
|
||||
'Content-Type' => 'application/json; charset=utf-8',
|
||||
'Cache-Control' => 'public, max-age=604800, must-revalidate',
|
||||
'X-Content-Type-Options' => 'nosniff',
|
||||
'Access-Control-Allow-Origin' => '*',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve the Asset Links file for Android App Links.
|
||||
*/
|
||||
public function serveAssetLinks(Request $request): Response
|
||||
{
|
||||
if (! config('filament-short-url.deep_linking.enabled', false)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$assetlinksJson = config('filament-short-url.deep_linking.assetlinks_json');
|
||||
|
||||
if (empty($assetlinksJson)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$minified = cache()->remember('fsu:deep-linking:assetlinks', 604800, function () use ($assetlinksJson): string {
|
||||
try {
|
||||
$decoded = json_decode($assetlinksJson, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
return json_encode($decoded, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
} catch (\Throwable) {
|
||||
return $assetlinksJson;
|
||||
}
|
||||
});
|
||||
|
||||
return response($minified, 200, [
|
||||
'Content-Type' => 'application/json; charset=utf-8',
|
||||
'Cache-Control' => 'public, max-age=604800, must-revalidate',
|
||||
'X-Content-Type-Options' => 'nosniff',
|
||||
'Access-Control-Allow-Origin' => '*',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
56
src/Http/Middleware/AuthenticateShortUrlApi.php
Normal file
56
src/Http/Middleware/AuthenticateShortUrlApi.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Http\Middleware;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlSettingsManager;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class AuthenticateShortUrlApi
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! config('filament-short-url.api_enabled', false)) {
|
||||
return response()->json([
|
||||
'error' => 'The Developer API is currently disabled. Enable it in Short URL Settings → API & Webhooks.',
|
||||
], 503);
|
||||
}
|
||||
|
||||
$apiKey = $request->header('X-Api-Key');
|
||||
|
||||
if (! $apiKey && $auth = $request->header('Authorization')) {
|
||||
if (str_starts_with(strtolower($auth), 'bearer ')) {
|
||||
$apiKey = substr($auth, 7);
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($apiKey)) {
|
||||
return response()->json([
|
||||
'error' => 'Unauthorized. API Key is missing.',
|
||||
], 401);
|
||||
}
|
||||
|
||||
$mgr = app(ShortUrlSettingsManager::class);
|
||||
$keys = $mgr->get('api_keys', []);
|
||||
|
||||
$valid = false;
|
||||
foreach ($keys as $keyObj) {
|
||||
if (($keyObj['key'] ?? '') === $apiKey && (bool) ($keyObj['is_active'] ?? false)) {
|
||||
$valid = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $valid) {
|
||||
return response()->json([
|
||||
'error' => 'Unauthorized. Invalid or inactive API Key.',
|
||||
], 401);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
52
src/Jobs/IncrementVisitJob.php
Normal file
52
src/Jobs/IncrementVisitJob.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Jobs;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class IncrementVisitJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly int $shortUrlId,
|
||||
public readonly bool $isUnique = false,
|
||||
public readonly bool $isQrScan = false,
|
||||
) {
|
||||
$this->onQueue(config('filament-short-url.queue_name', 'default'));
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$shortUrl = ShortUrl::find($this->shortUrlId);
|
||||
|
||||
if (! $shortUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
$updates = [];
|
||||
if ($this->isUnique) {
|
||||
$updates['unique_visits'] = DB::raw('unique_visits + 1');
|
||||
}
|
||||
if ($this->isQrScan) {
|
||||
$updates['qr_scans'] = DB::raw('qr_scans + 1');
|
||||
}
|
||||
|
||||
$shortUrl->newQuery()
|
||||
->where('id', $shortUrl->id)
|
||||
->increment(
|
||||
'total_visits',
|
||||
1,
|
||||
$updates
|
||||
);
|
||||
}
|
||||
}
|
||||
70
src/Jobs/SendWebhookJob.php
Normal file
70
src/Jobs/SendWebhookJob.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Jobs;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class SendWebhookJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
/** @var int Max retry attempts if the webhook fails */
|
||||
public int $tries = 3;
|
||||
|
||||
/** @var int Delay between retries (seconds) */
|
||||
public int $backoff = 10;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $url,
|
||||
public readonly string $event,
|
||||
public readonly array $payload
|
||||
) {
|
||||
$this->onQueue(config('filament-short-url.queue_name', 'default'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
try {
|
||||
$response = Http::timeout(10)
|
||||
->withHeaders([
|
||||
'Content-Type' => 'application/json',
|
||||
'User-Agent' => 'wYachts-ShortUrl-Webhook/1.5',
|
||||
])
|
||||
->post($this->url, $this->payload);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::warning('[FilamentShortUrl] Webhook delivery returned client/server error', [
|
||||
'url' => $this->url,
|
||||
'event' => $this->event,
|
||||
'status' => $response->status(),
|
||||
]);
|
||||
|
||||
// Throw exception to trigger queue retry
|
||||
throw new \RuntimeException('Webhook failed with status '.$response->status());
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('[FilamentShortUrl] Webhook delivery failed', [
|
||||
'url' => $this->url,
|
||||
'event' => $this->event,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,14 @@ class TrackShortUrlVisitJob implements ShouldQueue
|
||||
public readonly string $userAgent,
|
||||
public readonly ?string $refererUrl,
|
||||
public readonly ?string $countryCode = null,
|
||||
public readonly ?string $city = null,
|
||||
public readonly ?string $utmSource = null,
|
||||
public readonly ?string $utmMedium = null,
|
||||
public readonly ?string $utmCampaign = null,
|
||||
public readonly ?string $utmTerm = null,
|
||||
public readonly ?string $utmContent = null,
|
||||
public readonly bool $isQrScan = false,
|
||||
public readonly ?string $browserLanguage = null,
|
||||
) {
|
||||
$this->onQueue(config('filament-short-url.queue_name', 'default'));
|
||||
}
|
||||
@@ -62,7 +70,21 @@ class TrackShortUrlVisitJob implements ShouldQueue
|
||||
]);
|
||||
|
||||
$countryCode = isset($this->countryCode) ? $this->countryCode : null;
|
||||
$visit = $tracker->record($shortUrl, $request, $countryCode);
|
||||
$city = isset($this->city) ? $this->city : null;
|
||||
|
||||
$visit = $tracker->record(
|
||||
shortUrl: $shortUrl,
|
||||
request: $request,
|
||||
preResolvedCountryCode: $countryCode,
|
||||
preResolvedCity: $city,
|
||||
utmSource: $this->utmSource,
|
||||
utmMedium: $this->utmMedium,
|
||||
utmCampaign: $this->utmCampaign,
|
||||
utmTerm: $this->utmTerm,
|
||||
utmContent: $this->utmContent,
|
||||
isQrScan: $this->isQrScan,
|
||||
browserLanguage: $this->browserLanguage,
|
||||
);
|
||||
|
||||
// Null means bot/crawler — nothing to dispatch or report
|
||||
if ($visit === null) {
|
||||
@@ -72,6 +94,71 @@ class TrackShortUrlVisitJob implements ShouldQueue
|
||||
// Fire event for user listeners
|
||||
ShortUrlVisited::dispatch($shortUrl, $visit);
|
||||
|
||||
// Trigger Webhook if active
|
||||
$targetUrl = $shortUrl->webhook_url;
|
||||
$globalUrl = config('filament-short-url.global_webhook_url');
|
||||
$events = config('filament-short-url.webhook_events', []);
|
||||
|
||||
$webhooksToDispatch = [];
|
||||
if (! empty($targetUrl)) {
|
||||
$webhooksToDispatch[] = $targetUrl;
|
||||
}
|
||||
if (! empty($globalUrl) && in_array('visited', $events)) {
|
||||
$webhooksToDispatch[] = $globalUrl;
|
||||
}
|
||||
|
||||
if (! empty($webhooksToDispatch)) {
|
||||
$payload = [
|
||||
'event' => 'visited',
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
'short_url' => [
|
||||
'id' => $shortUrl->id,
|
||||
'destination_url' => $shortUrl->destination_url,
|
||||
'url_key' => $shortUrl->url_key,
|
||||
'short_url' => $shortUrl->getShortUrl(),
|
||||
'total_visits' => (int) $shortUrl->getRealTimeTotalVisits(),
|
||||
'unique_visits' => (int) $shortUrl->unique_visits,
|
||||
],
|
||||
'visit' => [
|
||||
'id' => $visit->id,
|
||||
'visited_at' => $visit->visited_at->toIso8601String(),
|
||||
'device_type' => $visit->device_type,
|
||||
'browser' => $visit->browser,
|
||||
'browser_version' => $visit->browser_version,
|
||||
'operating_system' => $visit->operating_system,
|
||||
'operating_system_version' => $visit->operating_system_version,
|
||||
'country' => $visit->country,
|
||||
'country_code' => $visit->country_code,
|
||||
'city' => $visit->city,
|
||||
'referer_url' => $visit->referer_url,
|
||||
'referer_host' => $visit->referer_host,
|
||||
'utm_source' => $visit->utm_source,
|
||||
'utm_medium' => $visit->utm_medium,
|
||||
'utm_campaign' => $visit->utm_campaign,
|
||||
'utm_term' => $visit->utm_term,
|
||||
'utm_content' => $visit->utm_content,
|
||||
'is_qr_scan' => (bool) $visit->is_qr_scan,
|
||||
'browser_language' => $visit->browser_language,
|
||||
],
|
||||
];
|
||||
|
||||
foreach (array_unique($webhooksToDispatch) as $url) {
|
||||
try {
|
||||
dispatch(new SendWebhookJob(
|
||||
url: $url,
|
||||
event: 'visited',
|
||||
payload: $payload
|
||||
)->onConnection($this->connection ?: 'sync'));
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('[FilamentShortUrl] Visited webhook dispatch failed', [
|
||||
'url' => $url,
|
||||
'url_key' => $shortUrl->url_key,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Optional GA4 Measurement Protocol integration
|
||||
if ($shortUrl->ga_tracking_id && config('filament-short-url.ga4.api_secret')) {
|
||||
$this->sendGa4Hit($shortUrl, $visit);
|
||||
|
||||
@@ -2,15 +2,24 @@
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Models;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlGlobalOverview;
|
||||
use Bjanczak\FilamentShortUrl\Jobs\IncrementVisitJob;
|
||||
use Bjanczak\FilamentShortUrl\Services\ClientIpExtractor;
|
||||
use Bjanczak\FilamentShortUrl\Services\GeoIpService;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlBuilder;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
|
||||
use Bjanczak\FilamentShortUrl\Services\UserAgentParser;
|
||||
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\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
@@ -64,10 +73,20 @@ class ShortUrl extends Model
|
||||
'track_operating_system_version',
|
||||
'track_device_type',
|
||||
'track_referer_url',
|
||||
'track_browser_language',
|
||||
'qr_options',
|
||||
'ga_tracking_id',
|
||||
'password',
|
||||
'show_warning_page',
|
||||
'targeting_rules',
|
||||
'total_visits',
|
||||
'unique_visits',
|
||||
'max_visits',
|
||||
'expiration_redirect_url',
|
||||
'webhook_url',
|
||||
'qr_logo',
|
||||
'qr_scans',
|
||||
'auto_open_app_mobile',
|
||||
];
|
||||
|
||||
/** @var array<string, string> */
|
||||
@@ -75,6 +94,7 @@ class ShortUrl extends Model
|
||||
'is_enabled' => 'boolean',
|
||||
'single_use' => 'boolean',
|
||||
'forward_query_params' => 'boolean',
|
||||
'auto_open_app_mobile' => 'boolean',
|
||||
'track_visits' => 'boolean',
|
||||
'track_ip_address' => 'boolean',
|
||||
'track_browser' => 'boolean',
|
||||
@@ -83,10 +103,15 @@ class ShortUrl extends Model
|
||||
'track_operating_system_version' => 'boolean',
|
||||
'track_device_type' => 'boolean',
|
||||
'track_referer_url' => 'boolean',
|
||||
'track_browser_language' => 'boolean',
|
||||
'qr_options' => 'array',
|
||||
'show_warning_page' => 'boolean',
|
||||
'targeting_rules' => 'array',
|
||||
'max_visits' => 'integer',
|
||||
'activated_at' => 'datetime',
|
||||
'deactivated_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
'qr_scans' => 'integer',
|
||||
];
|
||||
|
||||
// ─── Relations ───────────────────────────────────────────────────────────
|
||||
@@ -96,6 +121,16 @@ class ShortUrl extends Model
|
||||
return $this->hasMany(ShortUrlVisit::class, 'short_url_id');
|
||||
}
|
||||
|
||||
public function dailyStats(): HasMany
|
||||
{
|
||||
return $this->hasMany(ShortUrlDailyStats::class, 'short_url_id');
|
||||
}
|
||||
|
||||
public function pixels(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(ShortUrlPixel::class, 'short_url_pixel', 'short_url_id', 'pixel_id');
|
||||
}
|
||||
|
||||
// ─── Scopes ──────────────────────────────────────────────────────────────
|
||||
|
||||
public function scopeEnabled(Builder $query): Builder
|
||||
@@ -107,9 +142,21 @@ class ShortUrl extends Model
|
||||
{
|
||||
return $query
|
||||
->enabled()
|
||||
->where(fn (Builder $q) => $q
|
||||
->whereNull('activated_at')
|
||||
->orWhere('activated_at', '<=', now())
|
||||
)
|
||||
->where(fn (Builder $q) => $q
|
||||
->whereNull('deactivated_at')
|
||||
->orWhere('deactivated_at', '>', now())
|
||||
)
|
||||
->where(fn (Builder $q) => $q
|
||||
->whereNull('expires_at')
|
||||
->orWhere('expires_at', '>', now())
|
||||
)
|
||||
->where(fn (Builder $q) => $q
|
||||
->whereNull('max_visits')
|
||||
->orWhereRaw('total_visits < max_visits')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -140,12 +187,71 @@ class ShortUrl extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* Bust the cache when the model is saved or deleted.
|
||||
* Bust the redirect cache when the model is saved or deleted.
|
||||
* Also invalidate the global overview link-count cache (forever cache)
|
||||
* on creation or deletion — the count changes only in those cases.
|
||||
*/
|
||||
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}"));
|
||||
static::saving(function (self $m) {
|
||||
if ($m->single_use) {
|
||||
$m->max_visits = null;
|
||||
$m->redirect_status_code = 302; // Force temporary redirect to prevent browser caching of single-use URLs
|
||||
}
|
||||
|
||||
if ($m->activated_at === null && $m->expires_at === null) {
|
||||
$m->expiration_redirect_url = null;
|
||||
}
|
||||
|
||||
if (empty($m->webhook_url)) {
|
||||
$m->webhook_url = null;
|
||||
}
|
||||
|
||||
if ($m->isDirty('qr_logo') && ! empty($m->qr_logo)) {
|
||||
if (str_starts_with($m->qr_logo, 'short-urls/tmp/')) {
|
||||
$tmpPath = $m->qr_logo;
|
||||
$filename = basename($tmpPath);
|
||||
$newPath = 'short-urls/logos/'.$filename;
|
||||
|
||||
$disk = Storage::disk('public');
|
||||
if ($disk->exists($tmpPath)) {
|
||||
$disk->move($tmpPath, $newPath);
|
||||
$m->qr_logo = $newPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
static::updating(function (self $m) {
|
||||
if ($m->isDirty('qr_logo')) {
|
||||
$oldLogo = $m->getOriginal('qr_logo');
|
||||
if (! empty($oldLogo)) {
|
||||
Storage::disk('public')->delete($oldLogo);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
static::saved(function (self $m) {
|
||||
cache()->forget("filament-short-url:{$m->url_key}");
|
||||
|
||||
if ($m->wasChanged('url_key')) {
|
||||
$oldKey = $m->getOriginal('url_key');
|
||||
if ($oldKey) {
|
||||
cache()->forget("filament-short-url:{$oldKey}");
|
||||
}
|
||||
}
|
||||
});
|
||||
static::deleted(function (self $m) {
|
||||
cache()->forget("filament-short-url:{$m->url_key}");
|
||||
cache()->forget(ShortUrlGlobalOverview::LINKS_CACHE_KEY);
|
||||
|
||||
if (! empty($m->qr_logo)) {
|
||||
Storage::disk('public')->delete($m->qr_logo);
|
||||
}
|
||||
});
|
||||
|
||||
// Bust the forever-cached link counts displayed in the global overview widget.
|
||||
static::created(fn () => cache()->forget(ShortUrlGlobalOverview::LINKS_CACHE_KEY));
|
||||
}
|
||||
|
||||
/** @return Collection<int, static> */
|
||||
@@ -164,7 +270,24 @@ class ShortUrl extends Model
|
||||
return app(ShortUrlService::class)->destination($url);
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||
public function getRealTimeTotalVisits(): int
|
||||
{
|
||||
$buffered = 0;
|
||||
if (config('filament-short-url.counter_buffering.enabled', false)) {
|
||||
$prefix = config('filament-short-url.counter_buffering.cache_key_prefix', 'filament-short-url:buffer:');
|
||||
$buffered = (int) cache()->get("{$prefix}total:{$this->id}", 0);
|
||||
}
|
||||
|
||||
if ($this->max_visits !== null) {
|
||||
$dbVal = (int) DB::table($this->table)
|
||||
->where('id', $this->id)
|
||||
->value('total_visits');
|
||||
|
||||
return $dbVal + $buffered;
|
||||
}
|
||||
|
||||
return $this->total_visits + $buffered;
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
@@ -172,6 +295,13 @@ class ShortUrl extends Model
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->single_use) {
|
||||
$realEnabled = DB::table($this->table)->where('id', $this->id)->value('is_enabled');
|
||||
if (! $realEnabled) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Not yet active
|
||||
if ($this->activated_at && $this->activated_at->isFuture()) {
|
||||
return false;
|
||||
@@ -187,12 +317,25 @@ class ShortUrl extends Model
|
||||
return false;
|
||||
}
|
||||
|
||||
// Visit limit reached
|
||||
if (! $this->single_use && $this->max_visits !== null && $this->getRealTimeTotalVisits() >= $this->max_visits) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function isExpired(): bool
|
||||
{
|
||||
return $this->expires_at !== null && $this->expires_at->isPast();
|
||||
if ($this->expires_at !== null && $this->expires_at->isPast()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! $this->single_use && $this->max_visits !== null && $this->getRealTimeTotalVisits() >= $this->max_visits) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function trackingEnabled(): bool
|
||||
@@ -217,34 +360,200 @@ class ShortUrl extends Model
|
||||
'operating_system_version' => $this->track_operating_system_version,
|
||||
'device_type' => $this->track_device_type,
|
||||
'referer_url' => $this->track_referer_url,
|
||||
'browser_language' => $this->track_browser_language,
|
||||
]));
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function getQrOptions(): array
|
||||
{
|
||||
return array_merge(
|
||||
$opts = array_merge(
|
||||
config('filament-short-url.qr_defaults', []),
|
||||
$this->qr_options ?? []
|
||||
);
|
||||
|
||||
if (! empty($this->qr_logo)) {
|
||||
$opts['logo'] = route('short-url.logo', ['filename' => basename($this->qr_logo)]);
|
||||
}
|
||||
|
||||
return $opts;
|
||||
}
|
||||
|
||||
public function getShortUrl(): string
|
||||
{
|
||||
$prefix = config('filament-short-url.route_prefix', 's');
|
||||
$prefix = config('filament-short-url.route_prefix');
|
||||
|
||||
return rtrim(config('app.url'), '/')."/{$prefix}/{$this->url_key}";
|
||||
if (! empty($prefix)) {
|
||||
return rtrim(config('app.url'), '/').'/'.trim($prefix, '/').'/'.$this->url_key;
|
||||
}
|
||||
|
||||
return rtrim(config('app.url'), '/').'/'.$this->url_key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the targeted destination URL based on request headers/context.
|
||||
*/
|
||||
public function resolveDestinationUrl(Request $request): string
|
||||
{
|
||||
$rules = $this->targeting_rules;
|
||||
|
||||
if (empty($rules)) {
|
||||
return $this->destination_url;
|
||||
}
|
||||
|
||||
$type = $rules['type'] ?? 'none';
|
||||
|
||||
if ($type === 'device') {
|
||||
$parser = app(UserAgentParser::class);
|
||||
$deviceType = $parser->parse($request->userAgent() ?? '')['device_type'];
|
||||
|
||||
if ($deviceType === 'mobile') {
|
||||
return $rules['device']['mobile'] ?? $rules['device']['ios'] ?? $this->destination_url;
|
||||
}
|
||||
if ($deviceType === 'tablet') {
|
||||
return $rules['device']['tablet'] ?? $rules['device']['android'] ?? $this->destination_url;
|
||||
}
|
||||
|
||||
return $rules['device']['desktop'] ?? $this->destination_url;
|
||||
}
|
||||
|
||||
if ($type === 'geo') {
|
||||
$countryCode = ClientIpExtractor::getCountryCode($request);
|
||||
if (! $countryCode) {
|
||||
// Try resolving via GeoIpService
|
||||
$ip = ClientIpExtractor::getIp($request);
|
||||
$geo = app(GeoIpService::class)->resolve($ip);
|
||||
$countryCode = $geo['country_code'] ?? null;
|
||||
}
|
||||
|
||||
if ($countryCode) {
|
||||
$countryCode = strtoupper(trim($countryCode));
|
||||
foreach ($rules['geo'] ?? [] as $rule) {
|
||||
if (strtoupper($rule['country_code'] ?? '') === $countryCode) {
|
||||
return $rule['url'] ?? $this->destination_url;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($type === 'language') {
|
||||
$acceptedLanguages = $request->getLanguages();
|
||||
|
||||
// Pass 1: Exact match (e.g. "en-us" matches "en-us" rule, or "pl" matches "pl" rule)
|
||||
foreach ($acceptedLanguages as $acceptedLanguage) {
|
||||
$acceptedLanguage = strtolower(trim(str_replace('_', '-', $acceptedLanguage)));
|
||||
if (empty($acceptedLanguage)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($rules['language'] ?? [] as $rule) {
|
||||
$ruleLang = strtolower(trim(str_replace('_', '-', $rule['language_code'] ?? '')));
|
||||
if ($ruleLang === $acceptedLanguage) {
|
||||
return $rule['url'] ?? $this->destination_url;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: Primary language fallback match (e.g. "en-us" matches general "en" rule)
|
||||
foreach ($acceptedLanguages as $acceptedLanguage) {
|
||||
$acceptedLanguage = strtolower(trim(str_replace('_', '-', $acceptedLanguage)));
|
||||
if (empty($acceptedLanguage)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parts = explode('-', $acceptedLanguage);
|
||||
$primaryLang = strtolower(trim($parts[0]));
|
||||
|
||||
foreach ($rules['language'] ?? [] as $rule) {
|
||||
$ruleLang = strtolower(trim(str_replace('_', '-', $rule['language_code'] ?? '')));
|
||||
if ($ruleLang === $primaryLang) {
|
||||
return $rule['url'] ?? $this->destination_url;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($type === 'rotation') {
|
||||
$items = $rules['rotation'] ?? [];
|
||||
if (! empty($items)) {
|
||||
$totalWeight = array_sum(array_column($items, 'weight'));
|
||||
if ($totalWeight > 0) {
|
||||
$rand = mt_rand(1, $totalWeight);
|
||||
$currentWeight = 0;
|
||||
foreach ($items as $item) {
|
||||
$currentWeight += (int) ($item['weight'] ?? 0);
|
||||
if ($rand <= $currentWeight) {
|
||||
return $item['url'] ?? $this->destination_url;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this->destination_url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically increment visit counters — single query when unique,
|
||||
* to avoid race conditions and two round-trips.
|
||||
* to avoid race conditions and two round-trips. Supports write-back caching.
|
||||
*/
|
||||
public function incrementVisits(bool $isUnique = false): void
|
||||
public function incrementVisits(bool $isUnique = false, bool $isQrScan = false): void
|
||||
{
|
||||
if (config('filament-short-url.counter_buffering.enabled', false)) {
|
||||
$prefix = config('filament-short-url.counter_buffering.cache_key_prefix', 'filament-short-url:buffer:');
|
||||
try {
|
||||
// Increment atomically in cache (works on Redis, Memcached, Database, File, etc.)
|
||||
cache()->increment("{$prefix}total:{$this->id}");
|
||||
|
||||
if ($isUnique) {
|
||||
cache()->increment("{$prefix}unique:{$this->id}");
|
||||
}
|
||||
|
||||
if ($isQrScan) {
|
||||
cache()->increment("{$prefix}qr:{$this->id}");
|
||||
}
|
||||
|
||||
// Add to dirty IDs list atomically
|
||||
if (cache()->getDefaultDriver() === 'redis' && class_exists(Redis::class)) {
|
||||
Redis::sadd("{$prefix}dirty_ids", $this->id);
|
||||
} else {
|
||||
// Safe, concurrent-proof fallback for non-Redis stores using Cache locks
|
||||
$lock = cache()->lock("{$prefix}dirty_ids_lock", 2);
|
||||
$lock->get(function () use ($prefix) {
|
||||
$dirtyIds = cache()->get("{$prefix}dirty_ids", []);
|
||||
if (! is_array($dirtyIds)) {
|
||||
$dirtyIds = [];
|
||||
}
|
||||
if (! in_array($this->id, $dirtyIds)) {
|
||||
$dirtyIds[] = $this->id;
|
||||
cache()->forever("{$prefix}dirty_ids", $dirtyIds);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
} catch (\Throwable $e) {
|
||||
// Log and fall back to queue job below if caching backend fails
|
||||
}
|
||||
|
||||
// Safe fallback: Dispatch async job so clicks are queued and not lost on cache clear
|
||||
$connection = config('filament-short-url.queue_connection', 'sync');
|
||||
dispatch((new IncrementVisitJob($this->id, $isUnique, $isQrScan))->onConnection($connection ?: 'sync'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$updates = [];
|
||||
if ($isUnique) {
|
||||
$updates['unique_visits'] = DB::raw('unique_visits + 1');
|
||||
}
|
||||
if ($isQrScan) {
|
||||
$updates['qr_scans'] = DB::raw('qr_scans + 1');
|
||||
}
|
||||
|
||||
$this->newQuery()
|
||||
->where('id', $this->id)
|
||||
->increment('total_visits', 1, $isUnique ? ['unique_visits' => DB::raw('unique_visits + 1')] : []);
|
||||
->increment('total_visits', 1, $updates);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -252,98 +561,345 @@ class ShortUrl extends Model
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getCachedStats(): array
|
||||
public function getCachedStats(?string $dateFrom = null, ?string $dateTo = null): array
|
||||
{
|
||||
$dateFromClean = $dateFrom ? Carbon::parse($dateFrom)->toDateString() : null;
|
||||
$dateToClean = $dateTo ? Carbon::parse($dateTo)->toDateString() : null;
|
||||
|
||||
$cacheTtl = (int) config('filament-short-url.geo_ip.stats_cache_ttl', 300);
|
||||
$cacheKey = "short_url_stats_{$this->id}";
|
||||
$cacheKey = "short_url_stats_{$this->id}_".($dateFromClean ?: 'all').'_'.($dateToClean ?: 'all');
|
||||
|
||||
return cache()->remember($cacheKey, $cacheTtl, function () {
|
||||
$visits = $this->visits();
|
||||
return cache()->remember($cacheKey, $cacheTtl, function () use ($dateFromClean, $dateToClean) {
|
||||
$today = Carbon::today()->toDateString();
|
||||
|
||||
$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();
|
||||
// 1. Fetch daily stats (aggregated historical data)
|
||||
$dailyQuery = $this->dailyStats()->where('date', '<', $today);
|
||||
if ($dateFromClean) {
|
||||
$dailyQuery->where('date', '>=', $dateFromClean);
|
||||
}
|
||||
if ($dateToClean && $dateToClean < $today) {
|
||||
$dailyQuery->where('date', '<=', $dateToClean);
|
||||
}
|
||||
$dailyStatsRows = $dailyQuery->get();
|
||||
|
||||
// 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;
|
||||
// 2. Fetch raw visits for today (if within date range)
|
||||
$includeToday = ($dateToClean === null || $dateToClean >= $today);
|
||||
if ($dateFromClean && $dateFromClean > $today) {
|
||||
$includeToday = false;
|
||||
}
|
||||
|
||||
// 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();
|
||||
$rawVisits = [];
|
||||
if ($includeToday) {
|
||||
$rawVisits = $this->visits()->where('visited_at', '>=', $today.' 00:00:00')->get();
|
||||
}
|
||||
|
||||
// Device types
|
||||
$visitsByDevice = (clone $visits)
|
||||
->whereNotNull('device_type')
|
||||
->selectRaw('device_type, COUNT(*) as count')
|
||||
->groupBy('device_type')
|
||||
->orderByDesc('count')
|
||||
->pluck('count', 'device_type')
|
||||
->toArray();
|
||||
// Helper to merge associative stats arrays
|
||||
$mergeStats = function (array $base, ?array $additional): array {
|
||||
if (empty($additional)) {
|
||||
return $base;
|
||||
}
|
||||
foreach ($additional as $key => $val) {
|
||||
$base[$key] = ($base[$key] ?? 0) + $val;
|
||||
}
|
||||
|
||||
// Browsers
|
||||
$visitsByBrowser = (clone $visits)
|
||||
->whereNotNull('browser')
|
||||
->selectRaw('browser, COUNT(*) as count')
|
||||
->groupBy('browser')
|
||||
->orderByDesc('count')
|
||||
->limit(8)
|
||||
->pluck('count', 'browser')
|
||||
->toArray();
|
||||
return $base;
|
||||
};
|
||||
|
||||
// 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();
|
||||
// Initialize metrics
|
||||
$totalVisits = 0;
|
||||
$uniqueVisitsCount = 0;
|
||||
$visitsToday = count($rawVisits);
|
||||
$visitsThisWeek = 0;
|
||||
$visitsThisMonth = 0;
|
||||
$qrScans = 0;
|
||||
|
||||
// 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();
|
||||
$visitsByCountry = [];
|
||||
$visitsByCity = [];
|
||||
$visitsByDevice = [];
|
||||
$visitsByBrowser = [];
|
||||
$visitsByOs = [];
|
||||
$visitsByReferer = [];
|
||||
$utmSources = [];
|
||||
$utmMediums = [];
|
||||
$utmCampaigns = [];
|
||||
$visitsByLanguage = [];
|
||||
|
||||
// Sum up daily stats
|
||||
$startOfWeek = now()->startOfWeek()->toDateString();
|
||||
$startOfMonth = now()->startOfMonth()->toDateString();
|
||||
|
||||
foreach ($dailyStatsRows as $row) {
|
||||
$totalVisits += $row->visits_count;
|
||||
$uniqueVisitsCount += $row->unique_visits_count;
|
||||
$qrScans += $row->qr_visits_count ?? 0;
|
||||
|
||||
$rowDate = $row->date->toDateString();
|
||||
if ($rowDate >= $startOfWeek) {
|
||||
$visitsThisWeek += $row->visits_count;
|
||||
}
|
||||
if ($rowDate >= $startOfMonth) {
|
||||
$visitsThisMonth += $row->visits_count;
|
||||
}
|
||||
|
||||
$visitsByCountry = $mergeStats($visitsByCountry, $row->country_stats);
|
||||
$visitsByCity = $mergeStats($visitsByCity, $row->city_stats);
|
||||
$visitsByDevice = $mergeStats($visitsByDevice, $row->device_stats);
|
||||
$visitsByBrowser = $mergeStats($visitsByBrowser, $row->browser_stats);
|
||||
$visitsByOs = $mergeStats($visitsByOs, $row->os_stats);
|
||||
$visitsByReferer = $mergeStats($visitsByReferer, $row->referer_stats);
|
||||
$utmSources = $mergeStats($utmSources, $row->utm_source_stats);
|
||||
$utmMediums = $mergeStats($utmMediums, $row->utm_medium_stats);
|
||||
$utmCampaigns = $mergeStats($utmCampaigns, $row->utm_campaign_stats);
|
||||
$visitsByLanguage = $mergeStats($visitsByLanguage, $row->language_stats);
|
||||
}
|
||||
|
||||
// Combine today's raw visits
|
||||
if ($includeToday) {
|
||||
$totalVisits += count($rawVisits);
|
||||
$uniqueVisitsCount += count(array_unique(array_filter($rawVisits->pluck('ip_hash')->toArray())));
|
||||
|
||||
$visitsThisWeek += count($rawVisits);
|
||||
$visitsThisMonth += count($rawVisits);
|
||||
|
||||
foreach ($rawVisits as $visit) {
|
||||
if ($visit->is_qr_scan) {
|
||||
$qrScans++;
|
||||
}
|
||||
if ($visit->browser_language) {
|
||||
$visitsByLanguage[$visit->browser_language] = ($visitsByLanguage[$visit->browser_language] ?? 0) + 1;
|
||||
}
|
||||
if ($visit->country) {
|
||||
$visitsByCountry[$visit->country] = ($visitsByCountry[$visit->country] ?? 0) + 1;
|
||||
}
|
||||
if ($visit->city) {
|
||||
$cityKey = "{$visit->city} ({$visit->country_code})";
|
||||
$visitsByCity[$cityKey] = ($visitsByCity[$cityKey] ?? 0) + 1;
|
||||
}
|
||||
if ($visit->device_type) {
|
||||
$visitsByDevice[$visit->device_type] = ($visitsByDevice[$visit->device_type] ?? 0) + 1;
|
||||
}
|
||||
if ($visit->browser) {
|
||||
$visitsByBrowser[$visit->browser] = ($visitsByBrowser[$visit->browser] ?? 0) + 1;
|
||||
}
|
||||
if ($visit->operating_system) {
|
||||
$visitsByOs[$visit->operating_system] = ($visitsByOs[$visit->operating_system] ?? 0) + 1;
|
||||
}
|
||||
$refererHost = $visit->referer_host ?: 'Direct';
|
||||
$visitsByReferer[$refererHost] = ($visitsByReferer[$refererHost] ?? 0) + 1;
|
||||
|
||||
if ($visit->utm_source) {
|
||||
$utmSources[$visit->utm_source] = ($utmSources[$visit->utm_source] ?? 0) + 1;
|
||||
}
|
||||
if ($visit->utm_medium) {
|
||||
$utmMediums[$visit->utm_medium] = ($utmMediums[$visit->utm_medium] ?? 0) + 1;
|
||||
}
|
||||
if ($visit->utm_campaign) {
|
||||
$utmCampaigns[$visit->utm_campaign] = ($utmCampaigns[$visit->utm_campaign] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build visitsByDay timeline
|
||||
$chartFrom = $dateFromClean ? Carbon::parse($dateFromClean) : now()->subDays(29)->startOfDay();
|
||||
$chartTo = $dateToClean ? Carbon::parse($dateToClean) : now()->endOfDay();
|
||||
$daysDiff = (int) $chartFrom->diffInDays($chartTo);
|
||||
|
||||
$visitsByDay = [];
|
||||
if ($daysDiff > 90) {
|
||||
// Group by month
|
||||
foreach ($dailyStatsRows as $row) {
|
||||
$m = $row->date->format('Y-m');
|
||||
$visitsByDay[$m] = ($visitsByDay[$m] ?? 0) + $row->visits_count;
|
||||
}
|
||||
if ($includeToday) {
|
||||
$mToday = Carbon::parse($today)->format('Y-m');
|
||||
$visitsByDay[$mToday] = ($visitsByDay[$mToday] ?? 0) + count($rawVisits);
|
||||
}
|
||||
} else {
|
||||
// Initialize timeline with zeros
|
||||
for ($i = $daysDiff; $i >= 0; $i--) {
|
||||
$d = (clone $chartTo)->subDays($i)->format('Y-m-d');
|
||||
$visitsByDay[$d] = 0;
|
||||
}
|
||||
// Fill daily stats
|
||||
foreach ($dailyStatsRows as $row) {
|
||||
$d = $row->date->format('Y-m-d');
|
||||
if (isset($visitsByDay[$d])) {
|
||||
$visitsByDay[$d] = $row->visits_count;
|
||||
}
|
||||
}
|
||||
// Fill today
|
||||
if ($includeToday && isset($visitsByDay[$today])) {
|
||||
$visitsByDay[$today] = count($rawVisits);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort distributions descending
|
||||
arsort($visitsByCountry);
|
||||
arsort($visitsByCity);
|
||||
arsort($visitsByDevice);
|
||||
arsort($visitsByBrowser);
|
||||
arsort($visitsByOs);
|
||||
arsort($visitsByReferer);
|
||||
arsort($utmSources);
|
||||
arsort($utmMediums);
|
||||
arsort($utmCampaigns);
|
||||
arsort($visitsByLanguage);
|
||||
|
||||
return [
|
||||
'totalVisits' => $totalVisits,
|
||||
'uniqueVisits' => $uniqueVisits,
|
||||
'uniqueVisits' => $uniqueVisitsCount,
|
||||
'visitsToday' => $visitsToday,
|
||||
'visitsThisWeek' => $visitsThisWeek,
|
||||
'visitsThisMonth' => $visitsThisMonth,
|
||||
'visitsByDay' => $visitsByDay,
|
||||
'visitsByCountry' => $visitsByCountry,
|
||||
'visitsByCountry' => array_slice($visitsByCountry, 0, 10, true),
|
||||
'visitsByCity' => array_slice($visitsByCity, 0, 10, true),
|
||||
'visitsByDevice' => $visitsByDevice,
|
||||
'visitsByBrowser' => $visitsByBrowser,
|
||||
'visitsByOs' => $visitsByOs,
|
||||
'visitsByReferer' => $visitsByReferer,
|
||||
'visitsByBrowser' => array_slice($visitsByBrowser, 0, 8, true),
|
||||
'visitsByOs' => array_slice($visitsByOs, 0, 8, true),
|
||||
'visitsByReferer' => array_slice($visitsByReferer, 0, 10, true),
|
||||
'utmSources' => array_slice($utmSources, 0, 8, true),
|
||||
'utmMediums' => array_slice($utmMediums, 0, 8, true),
|
||||
'utmCampaigns' => array_slice($utmCampaigns, 0, 8, true),
|
||||
'qrScans' => $qrScans,
|
||||
'visitsByLanguage' => array_slice($visitsByLanguage, 0, 10, true),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache properties to hold preloaded buffered visits for the current request.
|
||||
*/
|
||||
protected static ?array $bufferedTotalVisits = null;
|
||||
|
||||
protected static ?array $bufferedUniqueVisits = null;
|
||||
|
||||
protected static ?array $bufferedQrScans = null;
|
||||
|
||||
/**
|
||||
* Preload all buffered clicks in a single batch query for the entire request.
|
||||
* Prevents N+1 database queries even if database cache driver is used.
|
||||
*/
|
||||
protected static function loadAllBufferedVisits(): void
|
||||
{
|
||||
if (static::$bufferedTotalVisits !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
static::$bufferedTotalVisits = [];
|
||||
static::$bufferedUniqueVisits = [];
|
||||
static::$bufferedQrScans = [];
|
||||
|
||||
if (! config('filament-short-url.counter_buffering.enabled', false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$prefix = config('filament-short-url.counter_buffering.cache_key_prefix', 'filament-short-url:buffer:');
|
||||
$dirtyKey = "{$prefix}dirty_ids";
|
||||
|
||||
// 1. Fetch the list of dirty IDs (URLs with pending buffered clicks) in one query
|
||||
$dirtyIds = [];
|
||||
try {
|
||||
if (cache()->getDefaultDriver() === 'redis' && class_exists(Redis::class)) {
|
||||
$dirtyIds = Redis::smembers($dirtyKey);
|
||||
} else {
|
||||
$dirtyIds = cache()->get($dirtyKey, []);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Fallback
|
||||
}
|
||||
|
||||
if (empty($dirtyIds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$dirtyIds = array_unique(array_filter((array) $dirtyIds));
|
||||
|
||||
// 2. Build array of keys to fetch in a single cache store read
|
||||
$totalKeys = [];
|
||||
$uniqueKeys = [];
|
||||
$qrKeys = [];
|
||||
foreach ($dirtyIds as $id) {
|
||||
$totalKeys[$id] = "{$prefix}total:{$id}";
|
||||
$uniqueKeys[$id] = "{$prefix}unique:{$id}";
|
||||
$qrKeys[$id] = "{$prefix}qr:{$id}";
|
||||
}
|
||||
|
||||
try {
|
||||
// Cache::many() is highly optimized (e.g. 1 database query for database store, or 1 MGET for Redis)
|
||||
$totals = cache()->many(array_values($totalKeys));
|
||||
$uniques = cache()->many(array_values($uniqueKeys));
|
||||
$qrs = cache()->many(array_values($qrKeys));
|
||||
|
||||
foreach ($totalKeys as $id => $key) {
|
||||
static::$bufferedTotalVisits[$id] = (int) ($totals[$key] ?? 0);
|
||||
}
|
||||
foreach ($uniqueKeys as $id => $key) {
|
||||
static::$bufferedUniqueVisits[$id] = (int) ($uniques[$key] ?? 0);
|
||||
}
|
||||
foreach ($qrKeys as $id => $key) {
|
||||
static::$bufferedQrScans[$id] = (int) ($qrs[$key] ?? 0);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Fallback
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total visits count, merging the database value with any buffered clicks in cache.
|
||||
* Prevents database N+1 queries.
|
||||
*/
|
||||
public function getTotalVisitsAttribute(): int
|
||||
{
|
||||
$dbValue = $this->attributes['total_visits'] ?? 0;
|
||||
|
||||
if (! config('filament-short-url.counter_buffering.enabled', false)) {
|
||||
return $dbValue;
|
||||
}
|
||||
|
||||
static::loadAllBufferedVisits();
|
||||
|
||||
$buffered = static::$bufferedTotalVisits[$this->id] ?? 0;
|
||||
|
||||
return $dbValue + $buffered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the unique visits count, merging the database value with any buffered clicks in cache.
|
||||
* Prevents database N+1 queries.
|
||||
*/
|
||||
public function getUniqueVisitsAttribute(): int
|
||||
{
|
||||
$dbValue = $this->attributes['unique_visits'] ?? 0;
|
||||
|
||||
if (! config('filament-short-url.counter_buffering.enabled', false)) {
|
||||
return $dbValue;
|
||||
}
|
||||
|
||||
static::loadAllBufferedVisits();
|
||||
|
||||
$buffered = static::$bufferedUniqueVisits[$this->id] ?? 0;
|
||||
|
||||
return $dbValue + $buffered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the QR scans count, merging the database value with any buffered clicks in cache.
|
||||
* Prevents database N+1 queries.
|
||||
*/
|
||||
public function getQrScansAttribute(): int
|
||||
{
|
||||
$dbValue = $this->attributes['qr_scans'] ?? 0;
|
||||
|
||||
if (! config('filament-short-url.counter_buffering.enabled', false)) {
|
||||
return $dbValue;
|
||||
}
|
||||
|
||||
static::loadAllBufferedVisits();
|
||||
|
||||
$buffered = static::$bufferedQrScans[$this->id] ?? 0;
|
||||
|
||||
return $dbValue + $buffered;
|
||||
}
|
||||
}
|
||||
|
||||
71
src/Models/ShortUrlDailyStats.php
Normal file
71
src/Models/ShortUrlDailyStats.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?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 Carbon $date
|
||||
* @property int $visits_count
|
||||
* @property int $unique_visits_count
|
||||
* @property array|null $device_stats
|
||||
* @property array|null $browser_stats
|
||||
* @property array|null $os_stats
|
||||
* @property array|null $country_stats
|
||||
* @property array|null $city_stats
|
||||
* @property array|null $referer_stats
|
||||
* @property array|null $utm_source_stats
|
||||
* @property array|null $utm_medium_stats
|
||||
* @property array|null $utm_campaign_stats
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
*/
|
||||
class ShortUrlDailyStats extends Model
|
||||
{
|
||||
protected $table = 'short_url_daily_stats';
|
||||
|
||||
protected $fillable = [
|
||||
'short_url_id',
|
||||
'date',
|
||||
'visits_count',
|
||||
'unique_visits_count',
|
||||
'device_stats',
|
||||
'browser_stats',
|
||||
'os_stats',
|
||||
'country_stats',
|
||||
'city_stats',
|
||||
'referer_stats',
|
||||
'utm_source_stats',
|
||||
'utm_medium_stats',
|
||||
'utm_campaign_stats',
|
||||
'qr_visits_count',
|
||||
'language_stats',
|
||||
];
|
||||
|
||||
/** @var array<string, string> */
|
||||
protected $casts = [
|
||||
'date' => 'date',
|
||||
'visits_count' => 'integer',
|
||||
'unique_visits_count' => 'integer',
|
||||
'device_stats' => 'array',
|
||||
'browser_stats' => 'array',
|
||||
'os_stats' => 'array',
|
||||
'country_stats' => 'array',
|
||||
'city_stats' => 'array',
|
||||
'referer_stats' => 'array',
|
||||
'utm_source_stats' => 'array',
|
||||
'utm_medium_stats' => 'array',
|
||||
'utm_campaign_stats' => 'array',
|
||||
'qr_visits_count' => 'integer',
|
||||
'language_stats' => 'array',
|
||||
];
|
||||
|
||||
public function shortUrl(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ShortUrl::class, 'short_url_id');
|
||||
}
|
||||
}
|
||||
28
src/Models/ShortUrlPixel.php
Normal file
28
src/Models/ShortUrlPixel.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
class ShortUrlPixel extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'type',
|
||||
'pixel_id',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the short URLs associated with this pixel.
|
||||
*/
|
||||
public function shortUrls(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(ShortUrl::class, 'short_url_pixel', 'pixel_id', 'short_url_id');
|
||||
}
|
||||
}
|
||||
@@ -37,14 +37,28 @@ class ShortUrlVisit extends Model
|
||||
'operating_system_version',
|
||||
'device_type',
|
||||
'referer_url',
|
||||
'referer_host',
|
||||
'country',
|
||||
'country_code',
|
||||
'city',
|
||||
'utm_source',
|
||||
'utm_medium',
|
||||
'utm_campaign',
|
||||
'utm_term',
|
||||
'utm_content',
|
||||
'visited_at',
|
||||
'is_bot',
|
||||
'is_proxy',
|
||||
'is_qr_scan',
|
||||
'browser_language',
|
||||
];
|
||||
|
||||
/** @var array<string, string> */
|
||||
protected $casts = [
|
||||
'visited_at' => 'datetime',
|
||||
'is_bot' => 'boolean',
|
||||
'is_proxy' => 'boolean',
|
||||
'is_qr_scan' => 'boolean',
|
||||
];
|
||||
|
||||
public function shortUrl(): BelongsTo
|
||||
|
||||
242
src/Services/AppLinkingEngine.php
Normal file
242
src/Services/AppLinkingEngine.php
Normal file
@@ -0,0 +1,242 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Services;
|
||||
|
||||
class AppLinkingEngine
|
||||
{
|
||||
/**
|
||||
* List of supported apps with their metadata.
|
||||
*
|
||||
* @return array<string, array{name: string, color: string, icon: string, domains: string[]}>
|
||||
*/
|
||||
public static function getSupportedApps(): array
|
||||
{
|
||||
return [
|
||||
'youtube' => [
|
||||
'name' => 'YouTube',
|
||||
'color' => '#ff0000',
|
||||
'icon' => 'fab-youtube',
|
||||
'domains' => ['youtube.com', 'youtu.be', 'youtube-nocookie.com'],
|
||||
],
|
||||
'tiktok' => [
|
||||
'name' => 'TikTok',
|
||||
'color' => '#fc295d',
|
||||
'icon' => 'fab-tiktok',
|
||||
'domains' => ['tiktok.com'],
|
||||
],
|
||||
'instagram' => [
|
||||
'name' => 'Instagram',
|
||||
'color' => '#e1306c',
|
||||
'icon' => 'fab-instagram',
|
||||
'domains' => ['instagram.com', 'instagr.am'],
|
||||
],
|
||||
'x' => [
|
||||
'name' => 'X (Twitter)',
|
||||
'color' => '#1da1f2',
|
||||
'icon' => 'fab-x-twitter',
|
||||
'domains' => ['x.com', 'twitter.com'],
|
||||
],
|
||||
'spotify' => [
|
||||
'name' => 'Spotify',
|
||||
'color' => '#2cd85c',
|
||||
'icon' => 'fab-spotify',
|
||||
'domains' => ['spotify.com', 'open.spotify.com', 'spotify.link'],
|
||||
],
|
||||
'facebook' => [
|
||||
'name' => 'Facebook',
|
||||
'color' => '#1877f2',
|
||||
'icon' => 'fab-facebook',
|
||||
'domains' => ['facebook.com', 'fb.com', 'fb.me'],
|
||||
],
|
||||
'reddit' => [
|
||||
'name' => 'Reddit',
|
||||
'color' => '#fc3a06',
|
||||
'icon' => 'fab-reddit',
|
||||
'domains' => ['reddit.com', 'redd.it'],
|
||||
],
|
||||
'snapchat' => [
|
||||
'name' => 'Snapchat',
|
||||
'color' => '#ffb700',
|
||||
'icon' => 'fab-snapchat',
|
||||
'domains' => ['snapchat.com'],
|
||||
],
|
||||
'whatsapp' => [
|
||||
'name' => 'WhatsApp',
|
||||
'color' => '#128c7e',
|
||||
'icon' => 'fab-whatsapp',
|
||||
'domains' => ['whatsapp.com', 'wa.me', 'api.whatsapp.com'],
|
||||
],
|
||||
'linkedin' => [
|
||||
'name' => 'LinkedIn',
|
||||
'color' => '#0a66c2',
|
||||
'icon' => 'fab-linkedin',
|
||||
'domains' => ['linkedin.com'],
|
||||
],
|
||||
'pinterest' => [
|
||||
'name' => 'Pinterest',
|
||||
'color' => '#e60023',
|
||||
'icon' => 'fab-pinterest',
|
||||
'domains' => ['pinterest.com', 'pin.it'],
|
||||
],
|
||||
'twitch' => [
|
||||
'name' => 'Twitch',
|
||||
'color' => '#9146ff',
|
||||
'icon' => 'fab-twitch',
|
||||
'domains' => ['twitch.tv'],
|
||||
],
|
||||
'netflix' => [
|
||||
'name' => 'Netflix',
|
||||
'color' => '#e50914',
|
||||
'icon' => 'fas-film',
|
||||
'domains' => ['netflix.com'],
|
||||
],
|
||||
'google_sheets' => [
|
||||
'name' => 'Google Sheets',
|
||||
'color' => '#25a465',
|
||||
'icon' => 'fas-file',
|
||||
'domains' => ['docs.google.com/spreadsheets'],
|
||||
],
|
||||
'google_docs' => [
|
||||
'name' => 'Google Docs',
|
||||
'color' => '#2a7efc',
|
||||
'icon' => 'fas-file-word',
|
||||
'domains' => ['docs.google.com/document'],
|
||||
],
|
||||
'google_slides' => [
|
||||
'name' => 'Google Slides',
|
||||
'color' => '#fabe0b',
|
||||
'icon' => 'fas-image',
|
||||
'domains' => ['docs.google.com/presentation'],
|
||||
],
|
||||
'google_maps' => [
|
||||
'name' => 'Google Maps',
|
||||
'color' => '#4285f4',
|
||||
'icon' => 'fas-map-location-dot',
|
||||
'domains' => ['google.com/maps', 'maps.google.com', 'maps.app.goo.gl'],
|
||||
],
|
||||
'facebook_messenger' => [
|
||||
'name' => 'Facebook Messenger',
|
||||
'color' => '#0084ff',
|
||||
'icon' => 'fab-facebook-messenger',
|
||||
'domains' => ['messenger.com'],
|
||||
],
|
||||
'apple_music' => [
|
||||
'name' => 'Apple Music',
|
||||
'color' => '#f8506b',
|
||||
'icon' => 'fab-apple',
|
||||
'domains' => ['music.apple.com'],
|
||||
],
|
||||
'airbnb' => [
|
||||
'name' => 'Airbnb',
|
||||
'color' => '#ff5a5f',
|
||||
'icon' => 'fab-airbnb',
|
||||
'domains' => ['airbnb.com', 'airbnb.pl', 'airbnb.de', 'airbnb.fr'],
|
||||
],
|
||||
'tripadvisor' => [
|
||||
'name' => 'TripAdvisor',
|
||||
'color' => '#00af87',
|
||||
'icon' => 'fas-plane',
|
||||
'domains' => ['tripadvisor.com', 'tripadvisor.pl'],
|
||||
],
|
||||
'amazon' => [
|
||||
'name' => 'Amazon',
|
||||
'color' => '#ff9900',
|
||||
'icon' => 'fab-amazon',
|
||||
'domains' => ['amazon.com', 'amazon.co.uk', 'amazon.de', 'amazon.pl', 'amazon.fr', 'amazon.it', 'amazon.es'],
|
||||
],
|
||||
'stockx' => [
|
||||
'name' => 'StockX',
|
||||
'color' => '#006341',
|
||||
'icon' => 'fas-chart-line',
|
||||
'domains' => ['stockx.com'],
|
||||
],
|
||||
'booking' => [
|
||||
'name' => 'Booking.com',
|
||||
'color' => '#003580',
|
||||
'icon' => 'fas-hotel',
|
||||
'domains' => ['booking.com'],
|
||||
],
|
||||
'aliexpress' => [
|
||||
'name' => 'AliExpress',
|
||||
'color' => '#e62e04',
|
||||
'icon' => 'fas-cart-shopping',
|
||||
'domains' => ['aliexpress.com', 'aliexpress.ru'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify the app matching the destination URL.
|
||||
*/
|
||||
public static function matchApp(?string $url): ?string
|
||||
{
|
||||
if (empty($url)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$urlLower = strtolower($url);
|
||||
|
||||
foreach (static::getSupportedApps() as $appId => $app) {
|
||||
foreach ($app['domains'] as $domain) {
|
||||
if (str_contains($urlLower, $domain)) {
|
||||
return $appId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert standard HTTP URL to its native deep link scheme.
|
||||
*/
|
||||
public static function convertToScheme(string $url, string $appId): string
|
||||
{
|
||||
// Standard fallback: replace http(s) with scheme
|
||||
$scheme = match ($appId) {
|
||||
'youtube' => 'youtube://',
|
||||
'tiktok' => 'tiktok://',
|
||||
'instagram' => 'instagram://',
|
||||
'x' => 'twitter://',
|
||||
'spotify' => 'spotify://',
|
||||
'facebook' => 'fb://',
|
||||
'reddit' => 'reddit://',
|
||||
'snapchat' => 'snapchat://',
|
||||
'whatsapp' => 'whatsapp://',
|
||||
'linkedin' => 'linkedin://',
|
||||
'pinterest' => 'pinterest://',
|
||||
'twitch' => 'twitch://',
|
||||
'netflix' => 'netflix://',
|
||||
'facebook_messenger' => 'fb-messenger://',
|
||||
'apple_music' => 'music://',
|
||||
'airbnb' => 'airbnb://',
|
||||
'tripadvisor' => 'tripadvisor://',
|
||||
'amazon' => 'amazon://',
|
||||
'stockx' => 'stockx://',
|
||||
'booking' => 'booking://',
|
||||
'aliexpress' => 'aliexpress://',
|
||||
'google_sheets' => 'googlesheets://',
|
||||
'google_docs' => 'googledocs://',
|
||||
'google_slides' => 'googleslides://',
|
||||
'google_maps' => 'googlemaps://',
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($scheme === null) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
// Strip http:// or https://
|
||||
$urlWithoutProtocol = preg_replace('/^https?:\/\//i', '', $url);
|
||||
|
||||
// Spotify URI format (special case)
|
||||
if ($appId === 'spotify') {
|
||||
// open.spotify.com/track/123 -> spotify:track:123
|
||||
if (preg_match('/open\.spotify\.com\/(track|playlist|album|artist)\/([a-zA-Z0-9]+)/i', $url, $matches)) {
|
||||
return "spotify:{$matches[1]}:{$matches[2]}";
|
||||
}
|
||||
}
|
||||
|
||||
return $scheme . $urlWithoutProtocol;
|
||||
}
|
||||
}
|
||||
@@ -11,29 +11,31 @@ class ClientIpExtractor
|
||||
*/
|
||||
public static function getIp(Request $request): string
|
||||
{
|
||||
// 1. Cloudflare connecting IP header
|
||||
if ($cfIp = $request->header('CF-Connecting-IP')) {
|
||||
return trim($cfIp);
|
||||
if (config('filament-short-url.trust_cdn_headers', false)) {
|
||||
// 1. Cloudflare connecting IP header
|
||||
if ($cfIp = $request->header('CF-Connecting-IP')) {
|
||||
return trim($cfIp);
|
||||
}
|
||||
|
||||
// 2. Akamai or other CDNs True-Client-IP header
|
||||
if ($trueIp = $request->header('True-Client-IP')) {
|
||||
return trim($trueIp);
|
||||
}
|
||||
|
||||
// 3. General reverse proxy / Nginx X-Real-IP header
|
||||
if ($realIp = $request->header('X-Real-IP')) {
|
||||
return trim($realIp);
|
||||
}
|
||||
|
||||
// 4. Standard X-Forwarded-For header chain (first IP is the client)
|
||||
if ($forwardedFor = $request->header('X-Forwarded-For')) {
|
||||
$ips = explode(',', $forwardedFor);
|
||||
|
||||
return trim($ips[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Akamai or other CDNs True-Client-IP header
|
||||
if ($trueIp = $request->header('True-Client-IP')) {
|
||||
return trim($trueIp);
|
||||
}
|
||||
|
||||
// 3. General reverse proxy / Nginx X-Real-IP header
|
||||
if ($realIp = $request->header('X-Real-IP')) {
|
||||
return trim($realIp);
|
||||
}
|
||||
|
||||
// 4. Standard X-Forwarded-For header chain (first IP is the client)
|
||||
if ($forwardedFor = $request->header('X-Forwarded-For')) {
|
||||
$ips = explode(',', $forwardedFor);
|
||||
|
||||
return trim($ips[0]);
|
||||
}
|
||||
|
||||
// 5. Fallback to standard Laravel/Symfony IP resolver
|
||||
// 5. Fallback to standard Laravel/Symfony IP resolver (which respects Trusted Proxies)
|
||||
return $request->ip() ?? '0.0.0.0';
|
||||
}
|
||||
|
||||
@@ -42,19 +44,39 @@ class ClientIpExtractor
|
||||
*/
|
||||
public static function getCountryCode(Request $request): ?string
|
||||
{
|
||||
// 1. Cloudflare IP Country header
|
||||
if ($cfCountry = $request->header('CF-IPCountry')) {
|
||||
return strtoupper(trim($cfCountry));
|
||||
if (config('filament-short-url.trust_cdn_headers', false)) {
|
||||
// 1. Cloudflare IP Country header
|
||||
if ($cfCountry = $request->header('CF-IPCountry')) {
|
||||
return strtoupper(trim($cfCountry));
|
||||
}
|
||||
|
||||
// 2. AWS CloudFront country header
|
||||
if ($cfViewerCountry = $request->header('CloudFront-Viewer-Country')) {
|
||||
return strtoupper(trim($cfViewerCountry));
|
||||
}
|
||||
|
||||
// 3. Generic CDN / Proxy country header
|
||||
if ($xCountry = $request->header('X-Country-Code')) {
|
||||
return strtoupper(trim($xCountry));
|
||||
}
|
||||
}
|
||||
|
||||
// 2. AWS CloudFront country header
|
||||
if ($cfViewerCountry = $request->header('CloudFront-Viewer-Country')) {
|
||||
return strtoupper(trim($cfViewerCountry));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3. Generic CDN / Proxy country header
|
||||
if ($xCountry = $request->header('X-Country-Code')) {
|
||||
return strtoupper(trim($xCountry));
|
||||
/**
|
||||
* Extract the edge-provided city name from CDN/proxy headers.
|
||||
*/
|
||||
public static function getCity(Request $request): ?string
|
||||
{
|
||||
if (config('filament-short-url.trust_cdn_headers', false)) {
|
||||
if ($cfCity = $request->header('CF-IPCity')) {
|
||||
return trim($cfCity);
|
||||
}
|
||||
|
||||
if ($cfViewerCity = $request->header('CloudFront-Viewer-City')) {
|
||||
return trim($cfViewerCity);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -17,31 +17,32 @@ use Illuminate\Support\Facades\Log;
|
||||
*/
|
||||
class GeoIpService
|
||||
{
|
||||
private const API_URL = 'http://ip-api.com/json/%s?fields=status,country,countryCode';
|
||||
private const API_URL = 'http://ip-api.com/json/%s?fields=status,country,countryCode,city';
|
||||
|
||||
/** @var array<string, array{country: string|null, country_code: string|null}> */
|
||||
/** @var array<string, array{country: string|null, country_code: string|null, city: string|null}> */
|
||||
private static array $runtimeCache = [];
|
||||
|
||||
/**
|
||||
* Resolve country data for the given IP address.
|
||||
* Resolve country and city data for the given IP address.
|
||||
*
|
||||
* @return array{country: string|null, country_code: string|null}
|
||||
* @return array{country: string|null, country_code: string|null, city: string|null}
|
||||
*/
|
||||
public function resolve(string $ip, ?string $preResolvedCountryCode = null): array
|
||||
public function resolve(string $ip, ?string $preResolvedCountryCode = null, ?string $preResolvedCity = null): array
|
||||
{
|
||||
$empty = ['country' => null, 'country_code' => null];
|
||||
$empty = ['country' => null, 'country_code' => null, 'city' => null];
|
||||
|
||||
if (! config('filament-short-url.geo_ip.enabled', true)) {
|
||||
return $empty;
|
||||
}
|
||||
|
||||
// 1. Prioritise edge-provided CDN country code (extremely fast, zero latency)
|
||||
// 1. Prioritise edge-provided CDN country code & city (extremely fast, zero latency)
|
||||
if ($preResolvedCountryCode) {
|
||||
$code = strtoupper(trim($preResolvedCountryCode));
|
||||
|
||||
return [
|
||||
'country' => $this->getCountryName($code),
|
||||
'country_code' => $code,
|
||||
'city' => $preResolvedCity,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -90,7 +91,7 @@ class GeoIpService
|
||||
|
||||
if (class_exists(\Locale::class)) {
|
||||
try {
|
||||
$name = \Locale::getDisplayRegion('-'.$code, 'en');
|
||||
$name = \Locale::getDisplayRegion('en-'.$code, 'en');
|
||||
if ($name && $name !== $code) {
|
||||
return $name;
|
||||
}
|
||||
@@ -129,9 +130,9 @@ class GeoIpService
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve country using local MaxMind GeoIP2 DB.
|
||||
* Resolve country and city using local MaxMind GeoIP2 DB.
|
||||
*
|
||||
* @return array{country: string, country_code: string}|null
|
||||
* @return array{country: string|null, country_code: string|null, city: string|null}|null
|
||||
*/
|
||||
private function lookupMaxMind(string $ip): ?array
|
||||
{
|
||||
@@ -151,11 +152,24 @@ class GeoIpService
|
||||
|
||||
try {
|
||||
$reader = new Reader($dbPath);
|
||||
$dbType = $reader->metadata()->databaseType;
|
||||
|
||||
if (str_contains($dbType, 'City')) {
|
||||
$record = $reader->city($ip);
|
||||
|
||||
return [
|
||||
'country' => $record->country->name ?? null,
|
||||
'country_code' => $record->country->isoCode ?? null,
|
||||
'city' => $record->city->name ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
$record = $reader->country($ip);
|
||||
|
||||
return [
|
||||
'country' => $record->country->name ?? null,
|
||||
'country_code' => $record->country->isoCode ?? null,
|
||||
'city' => null,
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('[FilamentShortUrl] MaxMind GeoIP lookup failed', [
|
||||
@@ -168,9 +182,9 @@ class GeoIpService
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve country using free ip-api.com.
|
||||
* Resolve country and city using free ip-api.com.
|
||||
*
|
||||
* @return array{country: string, country_code: string}|null
|
||||
* @return array{country: string|null, country_code: string|null, city: string|null}|null
|
||||
*/
|
||||
private function fetchFromApi(string $ip): ?array
|
||||
{
|
||||
@@ -193,6 +207,7 @@ class GeoIpService
|
||||
return [
|
||||
'country' => $data['country'] ?? null,
|
||||
'country_code' => $data['countryCode'] ?? null,
|
||||
'city' => $data['city'] ?? null,
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('[FilamentShortUrl] GeoIP lookup failed', [
|
||||
|
||||
108
src/Services/ProxyDetectionService.php
Normal file
108
src/Services/ProxyDetectionService.php
Normal file
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ProxyDetectionService
|
||||
{
|
||||
/**
|
||||
* Detect if an IP address belongs to a VPN, proxy, Tor node, or data center hosting.
|
||||
*
|
||||
* @return array{is_proxy: bool, is_bot: bool}
|
||||
*/
|
||||
public function detect(string $ip): array
|
||||
{
|
||||
if (empty($ip) || $ip === '127.0.0.1' || $ip === '::1') {
|
||||
return ['is_proxy' => false, 'is_bot' => false];
|
||||
}
|
||||
|
||||
$enabled = config('filament-short-url.vpn_detection.enabled', false);
|
||||
if (! $enabled) {
|
||||
return ['is_proxy' => false, 'is_bot' => false];
|
||||
}
|
||||
|
||||
$cacheKey = "short-url:proxy-check:{$ip}";
|
||||
$cacheTtl = (int) config('filament-short-url.vpn_detection.cache_ttl', 86400);
|
||||
|
||||
return Cache::remember($cacheKey, $cacheTtl, function () use ($ip) {
|
||||
$driver = config('filament-short-url.vpn_detection.driver', 'ip-api');
|
||||
$timeout = (int) config('filament-short-url.vpn_detection.timeout', 2);
|
||||
|
||||
try {
|
||||
if ($driver === 'vpnapi') {
|
||||
return $this->queryVpnApi($ip, $timeout);
|
||||
}
|
||||
|
||||
// Default to ip-api
|
||||
return $this->queryIpApi($ip, $timeout);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("Proxy detection failed for IP: {$ip}. Error: ".$e->getMessage());
|
||||
|
||||
return ['is_proxy' => false, 'is_bot' => false];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Query ip-api.com endpoint.
|
||||
*
|
||||
* @return array{is_proxy: bool, is_bot: bool}
|
||||
*/
|
||||
private function queryIpApi(string $ip, int $timeout): array
|
||||
{
|
||||
$url = "http://ip-api.com/json/{$ip}?fields=status,message,proxy,hosting";
|
||||
|
||||
$response = Http::timeout($timeout)->get($url);
|
||||
|
||||
if ($response->failed() || $response->json('status') === 'fail') {
|
||||
return ['is_proxy' => false, 'is_bot' => false];
|
||||
}
|
||||
|
||||
$isProxy = (bool) $response->json('proxy', false);
|
||||
$isBot = (bool) $response->json('hosting', false); // hosting indicates data centers (bots/scrapers)
|
||||
|
||||
return [
|
||||
'is_proxy' => $isProxy,
|
||||
'is_bot' => $isBot,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Query vpnapi.io endpoint.
|
||||
*
|
||||
* @return array{is_proxy: bool, is_bot: bool}
|
||||
*/
|
||||
private function queryVpnApi(string $ip, int $timeout): array
|
||||
{
|
||||
$key = config('filament-short-url.vpn_detection.vpnapi_key');
|
||||
|
||||
if (empty($key)) {
|
||||
return ['is_proxy' => false, 'is_bot' => false];
|
||||
}
|
||||
|
||||
$url = "https://vpnapi.io/api/{$ip}?key={$key}";
|
||||
|
||||
$response = Http::timeout($timeout)->get($url);
|
||||
|
||||
if ($response->failed()) {
|
||||
return ['is_proxy' => false, 'is_bot' => false];
|
||||
}
|
||||
|
||||
$security = $response->json('security', []);
|
||||
$isVpn = (bool) ($security['vpn'] ?? false);
|
||||
$isProxy = (bool) ($security['proxy'] ?? false);
|
||||
$isTor = (bool) ($security['tor'] ?? false);
|
||||
$isRelay = (bool) ($security['relay'] ?? false);
|
||||
|
||||
// Treat hosting/data center as bot traffic
|
||||
$isHosting = (bool) ($response->json('security.hosting') ?? false);
|
||||
|
||||
return [
|
||||
'is_proxy' => $isVpn || $isProxy || $isTor || $isRelay,
|
||||
'is_bot' => $isHosting,
|
||||
];
|
||||
}
|
||||
}
|
||||
79
src/Services/SafeBrowsingService.php
Normal file
79
src/Services/SafeBrowsingService.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class SafeBrowsingService
|
||||
{
|
||||
/**
|
||||
* Check if a URL is safe using Google Safe Browsing API.
|
||||
*
|
||||
* Returns true if safe (or if API is disabled/failed), false if a threat is detected.
|
||||
*/
|
||||
public function isSafe(string $url): bool
|
||||
{
|
||||
$enabled = config('filament-short-url.safe_browsing.enabled', false);
|
||||
$apiKey = config('filament-short-url.safe_browsing.api_key');
|
||||
|
||||
if (! $enabled || empty($apiKey)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->isSafeWithKey($url, $apiKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a URL is safe using Google Safe Browsing API with a specific key.
|
||||
*/
|
||||
public function isSafeWithKey(string $url, string $apiKey): bool
|
||||
{
|
||||
if (empty($apiKey)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
$endpoint = "https://safebrowsing.googleapis.com/v4/threatMatches:find?key={$apiKey}";
|
||||
|
||||
$payload = [
|
||||
'client' => [
|
||||
'clientId' => 'filament-short-url-plugin',
|
||||
'clientVersion' => '2.0.0',
|
||||
],
|
||||
'threatInfo' => [
|
||||
'threatTypes' => [
|
||||
'MALWARE',
|
||||
'SOCIAL_ENGINEERING',
|
||||
'UNWANTED_SOFTWARE',
|
||||
'POTENTIALLY_HARMFUL_APPLICATION',
|
||||
],
|
||||
'platformTypes' => ['ANY_PLATFORM'],
|
||||
'threatEntryTypes' => ['URL'],
|
||||
'threatEntries' => [
|
||||
['url' => $url],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$response = Http::timeout(3)
|
||||
->withHeaders(['Content-Type' => 'application/json'])
|
||||
->post($endpoint, $payload);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::warning('Google Safe Browsing API request failed with status: '.$response->status());
|
||||
|
||||
return true; // Default to safe if API is down
|
||||
}
|
||||
|
||||
$matches = $response->json('matches', []);
|
||||
|
||||
// If there are matches, the URL is flagged as unsafe
|
||||
return empty($matches);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Google Safe Browsing check failed: '.$e->getMessage());
|
||||
|
||||
return true; // Default to safe on exception
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,34 @@ class ShortUrlBuilder
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function activatedAt(\DateTimeInterface|Carbon|null $date): static
|
||||
{
|
||||
$this->data['activated_at'] = $date ? Carbon::instance($date) : null;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function deactivatedAt(\DateTimeInterface|Carbon|null $date): static
|
||||
{
|
||||
$this->data['deactivated_at'] = $date ? Carbon::instance($date) : null;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function maxVisits(?int $maxVisits): static
|
||||
{
|
||||
$this->data['max_visits'] = $maxVisits;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function expirationRedirectUrl(?string $url): static
|
||||
{
|
||||
$this->data['expiration_redirect_url'] = $url;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function trackVisits(bool $track = true): static
|
||||
{
|
||||
$this->data['track_visits'] = $track;
|
||||
|
||||
@@ -107,13 +107,15 @@ class ShortUrlService
|
||||
*/
|
||||
public function resolveRedirectUrl(ShortUrl $shortUrl, Request $request): string
|
||||
{
|
||||
$destination = $shortUrl->destination_url;
|
||||
$destination = $shortUrl->resolveDestinationUrl($request);
|
||||
|
||||
if (! $shortUrl->forward_query_params) {
|
||||
return $destination;
|
||||
}
|
||||
|
||||
$queryParams = $request->query();
|
||||
// Remove routing/auth parameters so they don't leak to destination
|
||||
unset($queryParams['confirmed'], $queryParams['password']);
|
||||
|
||||
if (empty($queryParams)) {
|
||||
return $destination;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
class ShortUrlSettingsManager
|
||||
@@ -24,16 +25,22 @@ class ShortUrlSettingsManager
|
||||
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
|
||||
$readFromFile = function () {
|
||||
$path = $this->getSettingsPath();
|
||||
if (File::exists($path)) {
|
||||
try {
|
||||
return json_decode(File::get($path), true) ?: [];
|
||||
} catch (\Throwable) {
|
||||
// Fallback to empty if json is corrupt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
$stored = app()->bound('cache')
|
||||
? cache()->remember('filament-short-url:settings', 86400, $readFromFile)
|
||||
: $readFromFile();
|
||||
|
||||
// Merge stored settings with default values from config()
|
||||
$this->cache = array_merge([
|
||||
@@ -44,11 +51,55 @@ class ShortUrlSettingsManager
|
||||
'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')),
|
||||
'maxmind_database_path' => config('filament-short-url.geo_ip.maxmind.database_path', storage_path('geoip/GeoLite2-Country.mmdb')),
|
||||
'geo_ip_stats_cache_ttl' => config('filament-short-url.geo_ip.stats_cache_ttl', 300),
|
||||
'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'),
|
||||
'queue_name' => config('filament-short-url.queue_name', 'default'),
|
||||
'cache_ttl' => config('filament-short-url.cache_ttl', 3600),
|
||||
'counter_buffering_enabled' => config('filament-short-url.counter_buffering.enabled', false),
|
||||
'trust_cdn_headers' => config('filament-short-url.trust_cdn_headers', false),
|
||||
'pruning_enabled' => config('filament-short-url.pruning.enabled', true),
|
||||
'pruning_retention_days' => config('filament-short-url.pruning.retention_days', 90),
|
||||
'rate_limiting_enabled' => config('filament-short-url.rate_limiting.enabled', false),
|
||||
'rate_limiting_max_attempts' => config('filament-short-url.rate_limiting.max_attempts', 60),
|
||||
'rate_limiting_decay_seconds' => config('filament-short-url.rate_limiting.decay_seconds', 60),
|
||||
'tracking_enabled' => config('filament-short-url.tracking.enabled', true),
|
||||
'tracking_fields_ip_address' => config('filament-short-url.tracking.fields.ip_address', true),
|
||||
'tracking_fields_browser' => config('filament-short-url.tracking.fields.browser', true),
|
||||
'tracking_fields_browser_version' => config('filament-short-url.tracking.fields.browser_version', true),
|
||||
'tracking_fields_operating_system' => config('filament-short-url.tracking.fields.operating_system', true),
|
||||
'tracking_fields_operating_system_version' => config('filament-short-url.tracking.fields.operating_system_version', true),
|
||||
'tracking_fields_referer_url' => config('filament-short-url.tracking.fields.referer_url', true),
|
||||
'tracking_fields_device_type' => config('filament-short-url.tracking.fields.device_type', true),
|
||||
'tracking_fields_browser_language' => config('filament-short-url.tracking.fields.browser_language', true),
|
||||
'qr_size' => config('filament-short-url.qr_defaults.size', 300),
|
||||
'qr_margin' => config('filament-short-url.qr_defaults.margin', 1),
|
||||
'qr_dot_style' => config('filament-short-url.qr_defaults.dot_style', 'square'),
|
||||
'qr_foreground_color' => config('filament-short-url.qr_defaults.foreground_color', '#000000'),
|
||||
'qr_background_color' => config('filament-short-url.qr_defaults.background_color', '#ffffff'),
|
||||
'qr_gradient_enabled' => config('filament-short-url.qr_defaults.gradient_enabled', false),
|
||||
'qr_gradient_from' => config('filament-short-url.qr_defaults.gradient_from', '#4f46e5'),
|
||||
'qr_gradient_to' => config('filament-short-url.qr_defaults.gradient_to', '#06b6d4'),
|
||||
'qr_gradient_type' => config('filament-short-url.qr_defaults.gradient_type', 'linear'),
|
||||
'global_webhook_url' => null,
|
||||
'webhook_events' => ['visited'],
|
||||
'global_webhook_enabled' => false,
|
||||
'api_keys' => [],
|
||||
'api_enabled' => false,
|
||||
'site_name' => config('filament-short-url.site_name'),
|
||||
// Security v2.0
|
||||
'vpn_detection_enabled' => config('filament-short-url.vpn_detection.enabled', false),
|
||||
'vpn_detection_driver' => config('filament-short-url.vpn_detection.driver', 'ip-api'),
|
||||
'vpnapi_key' => config('filament-short-url.vpn_detection.vpnapi_key'),
|
||||
'vpn_block_action' => config('filament-short-url.vpn_detection.block_action', 'flag_only'),
|
||||
'safe_browsing_enabled' => config('filament-short-url.safe_browsing.enabled', false),
|
||||
'google_safe_browsing_api_key' => config('filament-short-url.safe_browsing.api_key'),
|
||||
// Deep Linking v2.1
|
||||
'deep_linking_enabled' => config('filament-short-url.deep_linking.enabled', false),
|
||||
'aasa_json' => config('filament-short-url.deep_linking.aasa_json'),
|
||||
'assetlinks_json' => config('filament-short-url.deep_linking.assetlinks_json'),
|
||||
], $stored);
|
||||
|
||||
return $this->cache;
|
||||
@@ -73,6 +124,8 @@ class ShortUrlSettingsManager
|
||||
File::makeDirectory($dir, 0755, true);
|
||||
}
|
||||
|
||||
$oldPrefix = $this->get('route_prefix');
|
||||
|
||||
// Keep only supported settings keys to prevent bloat
|
||||
$keys = [
|
||||
'route_prefix',
|
||||
@@ -83,10 +136,54 @@ class ShortUrlSettingsManager
|
||||
'geo_ip_cache_ttl',
|
||||
'geo_ip_timeout',
|
||||
'maxmind_database_path',
|
||||
'geo_ip_stats_cache_ttl',
|
||||
'ga4_api_secret',
|
||||
'ga4_firebase_app_id',
|
||||
'queue_connection',
|
||||
'queue_name',
|
||||
'cache_ttl',
|
||||
'counter_buffering_enabled',
|
||||
'trust_cdn_headers',
|
||||
'pruning_enabled',
|
||||
'pruning_retention_days',
|
||||
'rate_limiting_enabled',
|
||||
'rate_limiting_max_attempts',
|
||||
'rate_limiting_decay_seconds',
|
||||
'tracking_enabled',
|
||||
'tracking_fields_ip_address',
|
||||
'tracking_fields_browser',
|
||||
'tracking_fields_browser_version',
|
||||
'tracking_fields_operating_system',
|
||||
'tracking_fields_operating_system_version',
|
||||
'tracking_fields_referer_url',
|
||||
'tracking_fields_device_type',
|
||||
'tracking_fields_browser_language',
|
||||
'qr_size',
|
||||
'qr_margin',
|
||||
'qr_dot_style',
|
||||
'qr_foreground_color',
|
||||
'qr_background_color',
|
||||
'qr_gradient_enabled',
|
||||
'qr_gradient_from',
|
||||
'qr_gradient_to',
|
||||
'qr_gradient_type',
|
||||
'global_webhook_url',
|
||||
'webhook_events',
|
||||
'global_webhook_enabled',
|
||||
'api_keys',
|
||||
'api_enabled',
|
||||
'site_name',
|
||||
// Security v2.0
|
||||
'vpn_detection_enabled',
|
||||
'vpn_detection_driver',
|
||||
'vpnapi_key',
|
||||
'vpn_block_action',
|
||||
'safe_browsing_enabled',
|
||||
'google_safe_browsing_api_key',
|
||||
// Deep Linking v2.1
|
||||
'deep_linking_enabled',
|
||||
'aasa_json',
|
||||
'assetlinks_json',
|
||||
];
|
||||
|
||||
$filtered = array_intersect_key($data, array_flip($keys));
|
||||
@@ -107,15 +204,107 @@ class ShortUrlSettingsManager
|
||||
if (isset($filtered['geo_ip_timeout'])) {
|
||||
$filtered['geo_ip_timeout'] = (int) $filtered['geo_ip_timeout'];
|
||||
}
|
||||
if (isset($filtered['geo_ip_stats_cache_ttl'])) {
|
||||
$filtered['geo_ip_stats_cache_ttl'] = (int) $filtered['geo_ip_stats_cache_ttl'];
|
||||
}
|
||||
if (isset($filtered['cache_ttl'])) {
|
||||
$filtered['cache_ttl'] = (int) $filtered['cache_ttl'];
|
||||
}
|
||||
if (isset($filtered['counter_buffering_enabled'])) {
|
||||
$filtered['counter_buffering_enabled'] = (bool) $filtered['counter_buffering_enabled'];
|
||||
}
|
||||
if (isset($filtered['trust_cdn_headers'])) {
|
||||
$filtered['trust_cdn_headers'] = (bool) $filtered['trust_cdn_headers'];
|
||||
}
|
||||
if (isset($filtered['pruning_enabled'])) {
|
||||
$filtered['pruning_enabled'] = (bool) $filtered['pruning_enabled'];
|
||||
}
|
||||
if (isset($filtered['pruning_retention_days'])) {
|
||||
$filtered['pruning_retention_days'] = (int) $filtered['pruning_retention_days'];
|
||||
}
|
||||
if (isset($filtered['rate_limiting_enabled'])) {
|
||||
$filtered['rate_limiting_enabled'] = (bool) $filtered['rate_limiting_enabled'];
|
||||
}
|
||||
if (isset($filtered['rate_limiting_max_attempts'])) {
|
||||
$filtered['rate_limiting_max_attempts'] = (int) $filtered['rate_limiting_max_attempts'];
|
||||
}
|
||||
if (isset($filtered['rate_limiting_decay_seconds'])) {
|
||||
$filtered['rate_limiting_decay_seconds'] = (int) $filtered['rate_limiting_decay_seconds'];
|
||||
}
|
||||
|
||||
// Tracking defaults
|
||||
if (isset($filtered['tracking_enabled'])) {
|
||||
$filtered['tracking_enabled'] = (bool) $filtered['tracking_enabled'];
|
||||
}
|
||||
if (isset($filtered['tracking_fields_ip_address'])) {
|
||||
$filtered['tracking_fields_ip_address'] = (bool) $filtered['tracking_fields_ip_address'];
|
||||
}
|
||||
if (isset($filtered['tracking_fields_browser'])) {
|
||||
$filtered['tracking_fields_browser'] = (bool) $filtered['tracking_fields_browser'];
|
||||
}
|
||||
if (isset($filtered['tracking_fields_browser_version'])) {
|
||||
$filtered['tracking_fields_browser_version'] = (bool) $filtered['tracking_fields_browser_version'];
|
||||
}
|
||||
if (isset($filtered['tracking_fields_operating_system'])) {
|
||||
$filtered['tracking_fields_operating_system'] = (bool) $filtered['tracking_fields_operating_system'];
|
||||
}
|
||||
if (isset($filtered['tracking_fields_operating_system_version'])) {
|
||||
$filtered['tracking_fields_operating_system_version'] = (bool) $filtered['tracking_fields_operating_system_version'];
|
||||
}
|
||||
if (isset($filtered['tracking_fields_referer_url'])) {
|
||||
$filtered['tracking_fields_referer_url'] = (bool) $filtered['tracking_fields_referer_url'];
|
||||
}
|
||||
if (isset($filtered['tracking_fields_device_type'])) {
|
||||
$filtered['tracking_fields_device_type'] = (bool) $filtered['tracking_fields_device_type'];
|
||||
}
|
||||
if (isset($filtered['tracking_fields_browser_language'])) {
|
||||
$filtered['tracking_fields_browser_language'] = (bool) $filtered['tracking_fields_browser_language'];
|
||||
}
|
||||
|
||||
// QR defaults
|
||||
if (isset($filtered['qr_size'])) {
|
||||
$filtered['qr_size'] = (int) $filtered['qr_size'];
|
||||
}
|
||||
if (isset($filtered['qr_margin'])) {
|
||||
$filtered['qr_margin'] = (int) $filtered['qr_margin'];
|
||||
}
|
||||
if (isset($filtered['qr_gradient_enabled'])) {
|
||||
$filtered['qr_gradient_enabled'] = (bool) $filtered['qr_gradient_enabled'];
|
||||
}
|
||||
|
||||
if (isset($filtered['global_webhook_enabled'])) {
|
||||
$filtered['global_webhook_enabled'] = (bool) $filtered['global_webhook_enabled'];
|
||||
}
|
||||
|
||||
// Security v2.0 casts
|
||||
if (isset($filtered['vpn_detection_enabled'])) {
|
||||
$filtered['vpn_detection_enabled'] = (bool) $filtered['vpn_detection_enabled'];
|
||||
}
|
||||
if (isset($filtered['safe_browsing_enabled'])) {
|
||||
$filtered['safe_browsing_enabled'] = (bool) $filtered['safe_browsing_enabled'];
|
||||
}
|
||||
if (isset($filtered['deep_linking_enabled'])) {
|
||||
$filtered['deep_linking_enabled'] = (bool) $filtered['deep_linking_enabled'];
|
||||
}
|
||||
|
||||
File::put($path, json_encode($filtered, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
cache()->forget('filament-short-url:settings');
|
||||
$this->cache = null;
|
||||
|
||||
// Apply immediately to current request config
|
||||
$this->applyConfigOverrides();
|
||||
|
||||
// Clear route cache if route prefix has changed
|
||||
$newPrefix = $filtered['route_prefix'] ?? null;
|
||||
if ($oldPrefix !== null && $newPrefix !== null && $oldPrefix !== $newPrefix) {
|
||||
try {
|
||||
if (app()->routesAreCached()) {
|
||||
Artisan::call('route:clear');
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Ignore route clear errors during boot/test
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,10 +323,54 @@ class ShortUrlSettingsManager
|
||||
'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.geo_ip.stats_cache_ttl' => $settings['geo_ip_stats_cache_ttl'],
|
||||
'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.queue_name' => $settings['queue_name'],
|
||||
'filament-short-url.cache_ttl' => $settings['cache_ttl'],
|
||||
'filament-short-url.counter_buffering.enabled' => $settings['counter_buffering_enabled'],
|
||||
'filament-short-url.trust_cdn_headers' => $settings['trust_cdn_headers'],
|
||||
'filament-short-url.pruning.enabled' => $settings['pruning_enabled'],
|
||||
'filament-short-url.pruning.retention_days' => $settings['pruning_retention_days'],
|
||||
'filament-short-url.rate_limiting.enabled' => $settings['rate_limiting_enabled'],
|
||||
'filament-short-url.rate_limiting.max_attempts' => $settings['rate_limiting_max_attempts'],
|
||||
'filament-short-url.rate_limiting.decay_seconds' => $settings['rate_limiting_decay_seconds'],
|
||||
'filament-short-url.tracking.enabled' => $settings['tracking_enabled'],
|
||||
'filament-short-url.tracking.fields.ip_address' => $settings['tracking_fields_ip_address'],
|
||||
'filament-short-url.tracking.fields.browser' => $settings['tracking_fields_browser'],
|
||||
'filament-short-url.tracking.fields.browser_version' => $settings['tracking_fields_browser_version'],
|
||||
'filament-short-url.tracking.fields.operating_system' => $settings['tracking_fields_operating_system'],
|
||||
'filament-short-url.tracking.fields.operating_system_version' => $settings['tracking_fields_operating_system_version'],
|
||||
'filament-short-url.tracking.fields.referer_url' => $settings['tracking_fields_referer_url'],
|
||||
'filament-short-url.tracking.fields.device_type' => $settings['tracking_fields_device_type'],
|
||||
'filament-short-url.tracking.fields.browser_language' => $settings['tracking_fields_browser_language'],
|
||||
'filament-short-url.qr_defaults.size' => $settings['qr_size'],
|
||||
'filament-short-url.qr_defaults.margin' => $settings['qr_margin'],
|
||||
'filament-short-url.qr_defaults.dot_style' => $settings['qr_dot_style'],
|
||||
'filament-short-url.qr_defaults.foreground_color' => $settings['qr_foreground_color'],
|
||||
'filament-short-url.qr_defaults.background_color' => $settings['qr_background_color'],
|
||||
'filament-short-url.qr_defaults.gradient_enabled' => $settings['qr_gradient_enabled'],
|
||||
'filament-short-url.qr_defaults.gradient_from' => $settings['qr_gradient_from'],
|
||||
'filament-short-url.qr_defaults.gradient_to' => $settings['qr_gradient_to'],
|
||||
'filament-short-url.qr_defaults.gradient_type' => $settings['qr_gradient_type'],
|
||||
'filament-short-url.global_webhook_url' => $settings['global_webhook_url'] ?? null,
|
||||
'filament-short-url.webhook_events' => $settings['webhook_events'] ?? ['visited'],
|
||||
'filament-short-url.api_enabled' => (bool) ($settings['api_enabled'] ?? false),
|
||||
'filament-short-url.site_name' => $settings['site_name'] ?? null,
|
||||
// Security v2.0
|
||||
'filament-short-url.vpn_detection.enabled' => (bool) ($settings['vpn_detection_enabled'] ?? false),
|
||||
'filament-short-url.vpn_detection.driver' => $settings['vpn_detection_driver'] ?? 'ip-api',
|
||||
'filament-short-url.vpn_detection.vpnapi_key' => $settings['vpnapi_key'] ?? null,
|
||||
'filament-short-url.vpn_detection.block_action' => $settings['vpn_block_action'] ?? 'flag_only',
|
||||
'filament-short-url.vpn_detection.cache_ttl' => 86400,
|
||||
'filament-short-url.vpn_detection.timeout' => 2,
|
||||
'filament-short-url.safe_browsing.enabled' => (bool) ($settings['safe_browsing_enabled'] ?? false),
|
||||
'filament-short-url.safe_browsing.api_key' => $settings['google_safe_browsing_api_key'] ?? null,
|
||||
// Deep Linking v2.1
|
||||
'filament-short-url.deep_linking.enabled' => (bool) ($settings['deep_linking_enabled'] ?? false),
|
||||
'filament-short-url.deep_linking.aasa_json' => $settings['aasa_json'] ?? null,
|
||||
'filament-short-url.deep_linking.assetlinks_json' => $settings['assetlinks_json'] ?? null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ class ShortUrlTracker
|
||||
public function __construct(
|
||||
private readonly UserAgentParser $uaParser,
|
||||
private readonly GeoIpService $geoIp,
|
||||
private readonly ProxyDetectionService $proxyDetector,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -23,27 +24,53 @@ class ShortUrlTracker
|
||||
*
|
||||
* Returns null if the visit was from a bot/crawler (we don't track those).
|
||||
*/
|
||||
public function record(ShortUrl $shortUrl, Request $request, ?string $preResolvedCountryCode = null): ?ShortUrlVisit
|
||||
{
|
||||
public function record(
|
||||
ShortUrl $shortUrl,
|
||||
Request $request,
|
||||
?string $preResolvedCountryCode = null,
|
||||
?string $preResolvedCity = null,
|
||||
?string $utmSource = null,
|
||||
?string $utmMedium = null,
|
||||
?string $utmCampaign = null,
|
||||
?string $utmTerm = null,
|
||||
?string $utmContent = null,
|
||||
bool $isQrScan = false,
|
||||
?string $browserLanguage = 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;
|
||||
// Run bot & proxy/VPN detection
|
||||
$isBot = $parsed['device_type'] === 'robot';
|
||||
$isProxy = false;
|
||||
|
||||
if (! $isBot) {
|
||||
$detection = $this->proxyDetector->detect($ip);
|
||||
$isBot = (bool) $detection['is_bot'];
|
||||
$isProxy = (bool) $detection['is_proxy'];
|
||||
}
|
||||
|
||||
$geo = config('filament-short-url.geo_ip.enabled', true)
|
||||
? $this->geoIp->resolve($ip, $preResolvedCountryCode)
|
||||
: ['country' => null, 'country_code' => null];
|
||||
? $this->geoIp->resolve($ip, $preResolvedCountryCode, $preResolvedCity)
|
||||
: ['country' => null, 'country_code' => null, 'city' => null];
|
||||
|
||||
// Determine uniqueness: first time this IP hash visits this URL.
|
||||
// We check BEFORE insert to avoid a self-referential race.
|
||||
$isUnique = ! ShortUrlVisit::where('short_url_id', $shortUrl->id)
|
||||
->where('ip_hash', $ipHash)
|
||||
->exists();
|
||||
$isUnique = false;
|
||||
if (config('filament-short-url.counter_buffering.enabled', false)) {
|
||||
$uniqueCacheKey = "filament-short-url:unique-visit:{$shortUrl->id}:{$ipHash}";
|
||||
if (cache()->add($uniqueCacheKey, true, 86400)) {
|
||||
$isUnique = ! ShortUrlVisit::where('short_url_id', $shortUrl->id)
|
||||
->where('ip_hash', $ipHash)
|
||||
->exists();
|
||||
}
|
||||
} else {
|
||||
$isUnique = ! ShortUrlVisit::where('short_url_id', $shortUrl->id)
|
||||
->where('ip_hash', $ipHash)
|
||||
->exists();
|
||||
}
|
||||
|
||||
$visit = new ShortUrlVisit;
|
||||
$visit->short_url_id = $shortUrl->id;
|
||||
@@ -56,13 +83,53 @@ class ShortUrlTracker
|
||||
$visit->operating_system_version = $shortUrl->track_operating_system_version
|
||||
? $parsed['operating_system_version'] : null;
|
||||
$visit->device_type = $shortUrl->track_device_type ? $parsed['device_type'] : null;
|
||||
$visit->referer_url = $shortUrl->track_referer_url ? $request->header('Referer') : null;
|
||||
$visit->country = $geo['country'];
|
||||
$visit->country_code = $geo['country_code'];
|
||||
$visit->city = $geo['city'] ?? null;
|
||||
$visit->is_bot = $isBot;
|
||||
$visit->is_proxy = $isProxy;
|
||||
$visit->is_qr_scan = $isQrScan;
|
||||
$visit->browser_language = $shortUrl->track_browser_language ? $browserLanguage : null;
|
||||
|
||||
$visit->utm_source = $utmSource;
|
||||
$visit->utm_medium = $utmMedium;
|
||||
$visit->utm_campaign = $utmCampaign;
|
||||
$visit->utm_term = $utmTerm;
|
||||
$visit->utm_content = $utmContent;
|
||||
|
||||
// Clean & normalize referer URL to host domain
|
||||
if ($shortUrl->track_referer_url && $referer = $request->header('Referer')) {
|
||||
$visit->referer_url = $referer;
|
||||
$refererHost = parse_url($referer, PHP_URL_HOST);
|
||||
if ($refererHost) {
|
||||
$refererHost = preg_replace('/^www\./', '', strtolower($refererHost));
|
||||
if (str_contains($refererHost, 'facebook.com')) {
|
||||
$refererHost = 'facebook.com';
|
||||
} elseif (str_contains($refererHost, 'linkedin.com')) {
|
||||
$refererHost = 'linkedin.com';
|
||||
} elseif (str_contains($refererHost, 'twitter.com') || str_contains($refererHost, 't.co')) {
|
||||
$refererHost = 'twitter.com';
|
||||
} elseif (str_contains($refererHost, 'google.')) {
|
||||
$refererHost = 'google.com';
|
||||
} elseif (str_contains($refererHost, 'instagram.com')) {
|
||||
$refererHost = 'instagram.com';
|
||||
} elseif (str_contains($refererHost, 'youtube.com')) {
|
||||
$refererHost = 'youtube.com';
|
||||
}
|
||||
$visit->referer_host = $refererHost;
|
||||
} else {
|
||||
$visit->referer_host = 'Direct';
|
||||
}
|
||||
} else {
|
||||
$visit->referer_host = 'Direct';
|
||||
}
|
||||
|
||||
$visit->save();
|
||||
|
||||
$shortUrl->incrementVisits($isUnique);
|
||||
// Increment stats only if it's NOT a bot or proxy/VPN to keep analytics clean
|
||||
if (! $isBot && ! $isProxy) {
|
||||
$shortUrl->incrementVisits($isUnique, $isQrScan);
|
||||
}
|
||||
|
||||
return $visit;
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@ class UserAgentParser
|
||||
// Order matters — check specific first, generic last
|
||||
$patterns = [
|
||||
'Edg/' => 'Edge',
|
||||
'EdgiOS' => 'Edge',
|
||||
'EdgA' => 'Edge',
|
||||
'OPR/' => 'Opera',
|
||||
'Opera' => 'Opera',
|
||||
'SamsungBrowser' => 'Samsung Browser',
|
||||
@@ -67,6 +69,8 @@ class UserAgentParser
|
||||
{
|
||||
$patterns = [
|
||||
'/Edg\/([0-9.]+)/',
|
||||
'/EdgiOS\/([0-9.]+)/',
|
||||
'/EdgA\/([0-9.]+)/',
|
||||
'/OPR\/([0-9.]+)/',
|
||||
'/SamsungBrowser\/([0-9.]+)/',
|
||||
'/CriOS\/([0-9.]+)/',
|
||||
|
||||
125
tests/Feature/ShortUrlApiTest.php
Normal file
125
tests/Feature/ShortUrlApiTest.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Jobs\SendWebhookJob;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlSettingsManager;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
beforeEach(function () {
|
||||
// Enable REST API and inject mock keys
|
||||
app(ShortUrlSettingsManager::class)->set([
|
||||
'api_enabled' => true,
|
||||
'api_keys' => [
|
||||
['name' => 'Active Test Token', 'key' => 'sh_key_active_token', 'is_active' => true],
|
||||
['name' => 'Inactive Test Token', 'key' => 'sh_key_inactive_token', 'is_active' => false],
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects API requests without a key', function () {
|
||||
$response = $this->getJson('/api/short-url/links');
|
||||
|
||||
$response->assertStatus(401)
|
||||
->assertJsonFragment(['error' => 'Unauthorized. API Key is missing.']);
|
||||
});
|
||||
|
||||
it('returns 503 when the REST API is disabled in settings', function () {
|
||||
app(ShortUrlSettingsManager::class)->set(['api_enabled' => false]);
|
||||
|
||||
$response = $this->getJson('/api/short-url/links', [
|
||||
'X-Api-Key' => 'sh_key_active_token',
|
||||
]);
|
||||
|
||||
$response->assertStatus(503)
|
||||
->assertJsonPath('error', fn ($v) => str_contains($v, 'disabled'));
|
||||
});
|
||||
|
||||
it('rejects API requests with an invalid key', function () {
|
||||
$response = $this->getJson('/api/short-url/links', [
|
||||
'X-Api-Key' => 'sh_key_invalid_value',
|
||||
]);
|
||||
|
||||
$response->assertStatus(401)
|
||||
->assertJsonFragment(['error' => 'Unauthorized. Invalid or inactive API Key.']);
|
||||
});
|
||||
|
||||
it('rejects API requests with an inactive key', function () {
|
||||
$response = $this->getJson('/api/short-url/links', [
|
||||
'X-Api-Key' => 'sh_key_inactive_token',
|
||||
]);
|
||||
|
||||
$response->assertStatus(401)
|
||||
->assertJsonFragment(['error' => 'Unauthorized. Invalid or inactive API Key.']);
|
||||
});
|
||||
|
||||
it('allows API requests with a valid key', function () {
|
||||
// Seed at least one short URL
|
||||
ShortUrl::create([
|
||||
'destination_url' => 'https://example.com/api-test',
|
||||
'url_key' => 'apitest',
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/short-url/links', [
|
||||
'X-Api-Key' => 'sh_key_active_token',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonStructure([
|
||||
'data',
|
||||
'meta' => ['current_page', 'last_page', 'per_page', 'total'],
|
||||
])
|
||||
->assertJsonFragment(['url_key' => 'apitest']);
|
||||
});
|
||||
|
||||
it('allows creating a short link programmatically via POST', function () {
|
||||
Queue::fake([SendWebhookJob::class]);
|
||||
|
||||
$response = $this->postJson('/api/short-url/links', [
|
||||
'destination_url' => 'https://google.com',
|
||||
'url_key' => 'googleapi',
|
||||
'notes' => 'API Generated link',
|
||||
'single_use' => true,
|
||||
'pixel_meta_id' => '12345',
|
||||
'webhook_url' => 'https://webhook.site/test',
|
||||
], [
|
||||
'X-Api-Key' => 'sh_key_active_token',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201)
|
||||
->assertJsonFragment(['url_key' => 'googleapi'])
|
||||
->assertJsonFragment(['notes' => 'API Generated link']);
|
||||
|
||||
$this->assertDatabaseHas('short_urls', [
|
||||
'url_key' => 'googleapi',
|
||||
'single_use' => true,
|
||||
'webhook_url' => 'https://webhook.site/test',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('short_url_pixels', [
|
||||
'type' => 'meta',
|
||||
'pixel_id' => '12345',
|
||||
]);
|
||||
|
||||
// SendWebhookJob should be dispatched for 'created' event since custom webhook_url is set
|
||||
Queue::assertPushed(SendWebhookJob::class, function ($job) {
|
||||
return $job->url === 'https://webhook.site/test' && $job->event === 'created';
|
||||
});
|
||||
});
|
||||
|
||||
it('allows deleting a short link via DELETE', function () {
|
||||
$link = ShortUrl::create([
|
||||
'destination_url' => 'https://delete-me.com',
|
||||
'url_key' => 'delkey',
|
||||
]);
|
||||
|
||||
$response = $this->deleteJson("/api/short-url/links/{$link->id}", [], [
|
||||
'X-Api-Key' => 'sh_key_active_token',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonFragment(['message' => 'Short URL deleted successfully.']);
|
||||
|
||||
$this->assertDatabaseMissing('short_urls', [
|
||||
'id' => $link->id,
|
||||
]);
|
||||
});
|
||||
155
tests/Feature/ShortUrlDeepLinkingTest.php
Normal file
155
tests/Feature/ShortUrlDeepLinkingTest.php
Normal file
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\ShortUrlSettingsPage;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlSettingsManager;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
// Reset config overrides
|
||||
config([
|
||||
'filament-short-url.deep_linking.enabled' => false,
|
||||
'filament-short-url.deep_linking.aasa_json' => null,
|
||||
'filament-short-url.deep_linking.assetlinks_json' => null,
|
||||
]);
|
||||
|
||||
Cache::forget('fsu:deep-linking:aasa');
|
||||
Cache::forget('fsu:deep-linking:assetlinks');
|
||||
});
|
||||
|
||||
it('returns 404 for deep linking endpoints when disabled', function () {
|
||||
$this->get('/.well-known/apple-app-site-association')->assertStatus(404);
|
||||
$this->get('/apple-app-site-association')->assertStatus(404);
|
||||
$this->get('/.well-known/assetlinks.json')->assertStatus(404);
|
||||
});
|
||||
|
||||
it('returns 404 for deep linking endpoints when enabled but json is empty', function () {
|
||||
config([
|
||||
'filament-short-url.deep_linking.enabled' => true,
|
||||
'filament-short-url.deep_linking.aasa_json' => '',
|
||||
'filament-short-url.deep_linking.assetlinks_json' => null,
|
||||
]);
|
||||
|
||||
$this->get('/.well-known/apple-app-site-association')->assertStatus(404);
|
||||
$this->get('/.well-known/assetlinks.json')->assertStatus(404);
|
||||
});
|
||||
|
||||
it('serves minified aasa and assetlinks with correct headers and caches them', function () {
|
||||
$aasaInput = '{
|
||||
"applinks": {
|
||||
"apps": [],
|
||||
"details": [
|
||||
{
|
||||
"appID": "9JA89Q824A.com.example.app",
|
||||
"paths": [ "/s/*" ]
|
||||
}
|
||||
]
|
||||
}
|
||||
}';
|
||||
|
||||
$assetlinksInput = '[{
|
||||
"relation": ["delegate_permission/common.handle_all_urls"],
|
||||
"target": {
|
||||
"namespace": "android_app",
|
||||
"package_name": "com.example.app",
|
||||
"sha256_cert_fingerprints": ["14:6D:E9:..."]
|
||||
}
|
||||
}]';
|
||||
|
||||
config([
|
||||
'filament-short-url.deep_linking.enabled' => true,
|
||||
'filament-short-url.deep_linking.aasa_json' => $aasaInput,
|
||||
'filament-short-url.deep_linking.assetlinks_json' => $assetlinksInput,
|
||||
]);
|
||||
|
||||
// Request AASA
|
||||
$responseAasa = $this->get('/.well-known/apple-app-site-association');
|
||||
$responseAasa->assertStatus(200)
|
||||
->assertHeader('Content-Type', 'application/json; charset=utf-8')
|
||||
->assertHeader('Cache-Control', 'max-age=604800, must-revalidate, public')
|
||||
->assertHeader('X-Content-Type-Options', 'nosniff')
|
||||
->assertHeader('Access-Control-Allow-Origin', '*');
|
||||
|
||||
// Verify it is minified (no spaces or newlines)
|
||||
$minifiedAasa = json_encode(json_decode($aasaInput, true), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
expect($responseAasa->getContent())->toBe($minifiedAasa);
|
||||
|
||||
// Verify AASA is cached
|
||||
expect(Cache::get('fsu:deep-linking:aasa'))->toBe($minifiedAasa);
|
||||
|
||||
// Request AssetLinks
|
||||
$responseAssetLinks = $this->get('/.well-known/assetlinks.json');
|
||||
$responseAssetLinks->assertStatus(200)
|
||||
->assertHeader('Content-Type', 'application/json; charset=utf-8')
|
||||
->assertHeader('Cache-Control', 'max-age=604800, must-revalidate, public')
|
||||
->assertHeader('X-Content-Type-Options', 'nosniff')
|
||||
->assertHeader('Access-Control-Allow-Origin', '*');
|
||||
|
||||
$minifiedAssetLinks = json_encode(json_decode($assetlinksInput, true), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
expect($responseAssetLinks->getContent())->toBe($minifiedAssetLinks);
|
||||
|
||||
// Verify AssetLinks is cached
|
||||
expect(Cache::get('fsu:deep-linking:assetlinks'))->toBe($minifiedAssetLinks);
|
||||
});
|
||||
|
||||
it('clears deep linking cache when settings page save is triggered', function () {
|
||||
Cache::put('fsu:deep-linking:aasa', 'cached_aasa', 600);
|
||||
Cache::put('fsu:deep-linking:assetlinks', 'cached_assetlinks', 600);
|
||||
|
||||
expect(Cache::has('fsu:deep-linking:aasa'))->toBeTrue();
|
||||
expect(Cache::has('fsu:deep-linking:assetlinks'))->toBeTrue();
|
||||
|
||||
// Verify that calling page save logic (which invokes cache forgets) successfully clears cache
|
||||
$page = new class extends ShortUrlSettingsPage
|
||||
{
|
||||
public ?array $formData = [];
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
// Simulating save logic
|
||||
app(ShortUrlSettingsManager::class)->set($this->formData);
|
||||
|
||||
cache()->forget('fsu:deep-linking:aasa');
|
||||
cache()->forget('fsu:deep-linking:assetlinks');
|
||||
}
|
||||
};
|
||||
|
||||
$page->formData = [
|
||||
'deep_linking_enabled' => true,
|
||||
'aasa_json' => '{}',
|
||||
'assetlinks_json' => '[]',
|
||||
];
|
||||
|
||||
$page->save();
|
||||
|
||||
expect(Cache::has('fsu:deep-linking:aasa'))->toBeFalse();
|
||||
expect(Cache::has('fsu:deep-linking:assetlinks'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('redirects to the intermediate app-redirect page when auto_open_app_mobile is enabled and visited from mobile', function () {
|
||||
$shortUrl = app(\Bjanczak\FilamentShortUrl\Services\ShortUrlService::class)->create([
|
||||
'destination_url' => 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
'url_key' => 'ytmobile',
|
||||
'auto_open_app_mobile' => true,
|
||||
'track_visits' => false,
|
||||
]);
|
||||
|
||||
// Visited from desktop -> should redirect directly (no intermediate page)
|
||||
$responseDesktop = $this->get('/s/ytmobile', [
|
||||
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0',
|
||||
]);
|
||||
$responseDesktop->assertRedirect('https://www.youtube.com/watch?v=dQw4w9WgXcQ');
|
||||
|
||||
// Visited from mobile (iPhone) -> should render intermediate app-redirect page
|
||||
$responseMobile = $this->get('/s/ytmobile', [
|
||||
'User-Agent' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 Safari/604.1',
|
||||
]);
|
||||
|
||||
$responseMobile->assertStatus(200)
|
||||
->assertSee('Opening in YouTube')
|
||||
->assertSee('youtube://www.youtube.com/watch?v=dQw4w9WgXcQ')
|
||||
->assertSee('Open Native App')
|
||||
->assertSee('Open in Browser');
|
||||
});
|
||||
126
tests/Feature/ShortUrlPixelTest.php
Normal file
126
tests/Feature/ShortUrlPixelTest.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlPixel;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('serves the pixel-loading interstitial view when pixels are set', function () {
|
||||
$link = ShortUrl::create([
|
||||
'destination_url' => 'https://example.com/target',
|
||||
'url_key' => 'pixelkey',
|
||||
]);
|
||||
|
||||
$metaPixel = ShortUrlPixel::create([
|
||||
'name' => 'My Meta Pixel',
|
||||
'type' => 'meta',
|
||||
'pixel_id' => 'META-12345',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$googlePixel = ShortUrlPixel::create([
|
||||
'name' => 'My Google Tag',
|
||||
'type' => 'google',
|
||||
'pixel_id' => 'G-GA54321',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$linkedinPixel = ShortUrlPixel::create([
|
||||
'name' => 'My LinkedIn Tag',
|
||||
'type' => 'linkedin',
|
||||
'pixel_id' => 'LNK-98765',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$tiktokPixel = ShortUrlPixel::create([
|
||||
'name' => 'My TikTok Tag',
|
||||
'type' => 'tiktok',
|
||||
'pixel_id' => 'TT-65432',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$pinterestPixel = ShortUrlPixel::create([
|
||||
'name' => 'My Pinterest Tag',
|
||||
'type' => 'pinterest',
|
||||
'pixel_id' => 'PIN-78901',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$link->pixels()->sync([
|
||||
$metaPixel->id,
|
||||
$googlePixel->id,
|
||||
$linkedinPixel->id,
|
||||
$tiktokPixel->id,
|
||||
$pinterestPixel->id,
|
||||
]);
|
||||
|
||||
$response = $this->get('/s/pixelkey');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertViewIs('filament-short-url::pixel-loading');
|
||||
|
||||
// Assert Meta Pixel is rendered
|
||||
$response->assertSee("fbq('init', 'META-12345')", false);
|
||||
|
||||
// Assert Google Analytics is rendered
|
||||
$response->assertSee('https://www.googletagmanager.com/gtag/js?id=G-GA54321', false);
|
||||
$response->assertSee("gtag('config', 'G-GA54321')", false);
|
||||
|
||||
// Assert LinkedIn Insight is rendered
|
||||
$response->assertSee('window._linkedin_data_partner_ids.push("LNK-98765")', false);
|
||||
|
||||
// Assert TikTok Pixel is rendered
|
||||
$response->assertSee("ttq.load('TT-65432')", false);
|
||||
|
||||
// Assert Pinterest Tag is rendered
|
||||
$response->assertSee("pintrk('load', 'PIN-78901')", false);
|
||||
});
|
||||
|
||||
it('bypasses interstitial loading when no pixels are set', function () {
|
||||
$link = ShortUrl::create([
|
||||
'destination_url' => 'https://example.com/direct',
|
||||
'url_key' => 'directkey',
|
||||
]);
|
||||
|
||||
$response = $this->get('/s/directkey');
|
||||
|
||||
$response->assertRedirect('https://example.com/direct');
|
||||
});
|
||||
|
||||
it('does not load inactive pixels from registry', function () {
|
||||
$link = ShortUrl::create([
|
||||
'destination_url' => 'https://example.com/inactive-target',
|
||||
'url_key' => 'inactivekey',
|
||||
]);
|
||||
|
||||
$activePixel = ShortUrlPixel::create([
|
||||
'name' => 'Active Meta Pixel',
|
||||
'type' => 'meta',
|
||||
'pixel_id' => 'META-ACTIVE',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$inactivePixel = ShortUrlPixel::create([
|
||||
'name' => 'Inactive Google Tag',
|
||||
'type' => 'google',
|
||||
'pixel_id' => 'G-INACTIVE',
|
||||
'is_active' => false,
|
||||
]);
|
||||
|
||||
$link->pixels()->sync([
|
||||
$activePixel->id,
|
||||
$inactivePixel->id,
|
||||
]);
|
||||
|
||||
$response = $this->get('/s/inactivekey');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertViewIs('filament-short-url::pixel-loading');
|
||||
|
||||
// Assert active is rendered
|
||||
$response->assertSee("fbq('init', 'META-ACTIVE')", false);
|
||||
|
||||
// Assert inactive is NOT rendered
|
||||
$response->assertDontSee('G-INACTIVE', false);
|
||||
});
|
||||
687
tests/Feature/ShortUrlRedirectTest.php
Normal file
687
tests/Feature/ShortUrlRedirectTest.php
Normal file
@@ -0,0 +1,687 @@
|
||||
<?php
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\ViewShortUrlStats;
|
||||
use Bjanczak\FilamentShortUrl\Jobs\TrackShortUrlVisitJob;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlDailyStats;
|
||||
use Bjanczak\FilamentShortUrl\Services\GeoIpService;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
if (! function_exists('createShortUrl')) {
|
||||
function createShortUrl(array $attrs = []): ShortUrl
|
||||
{
|
||||
return app(ShortUrlService::class)->create(array_merge([
|
||||
'destination_url' => 'https://example.com',
|
||||
], $attrs));
|
||||
}
|
||||
}
|
||||
|
||||
it('redirects to destination url', function () {
|
||||
$shortUrl = createShortUrl(['url_key' => 'abc123', 'track_visits' => false]);
|
||||
|
||||
$this->get('/s/abc123')
|
||||
->assertRedirect('https://example.com');
|
||||
});
|
||||
|
||||
it('returns 404 for unknown key', function () {
|
||||
$this->get('/s/doesnotexist')->assertStatus(404);
|
||||
});
|
||||
|
||||
it('returns 410 for disabled url', function () {
|
||||
createShortUrl(['url_key' => 'disabled1', 'is_enabled' => false, 'track_visits' => false]);
|
||||
|
||||
$this->get('/s/disabled1')->assertStatus(410);
|
||||
});
|
||||
|
||||
it('returns 410 for expired url', function () {
|
||||
createShortUrl([
|
||||
'url_key' => 'expired1',
|
||||
'expires_at' => now()->subDay(),
|
||||
'track_visits' => false,
|
||||
]);
|
||||
|
||||
$this->get('/s/expired1')->assertStatus(410);
|
||||
});
|
||||
|
||||
it('redirects to expiration fallback url when deactivated or expired', function () {
|
||||
createShortUrl([
|
||||
'url_key' => 'fallback-expired',
|
||||
'expires_at' => now()->subDay(),
|
||||
'expiration_redirect_url' => 'https://fallback.example.com',
|
||||
'track_visits' => false,
|
||||
]);
|
||||
|
||||
$this->get('/s/fallback-expired')
|
||||
->assertRedirect('https://fallback.example.com');
|
||||
});
|
||||
|
||||
it('redirects to expiration fallback url when deactivated_at is past', function () {
|
||||
createShortUrl([
|
||||
'url_key' => 'fallback-deactivated',
|
||||
'activated_at' => now()->subDays(2),
|
||||
'deactivated_at' => now()->subDay(),
|
||||
'expiration_redirect_url' => 'https://fallback.example.com',
|
||||
'track_visits' => false,
|
||||
]);
|
||||
|
||||
$this->get('/s/fallback-deactivated')
|
||||
->assertRedirect('https://fallback.example.com');
|
||||
});
|
||||
|
||||
it('redirects to expiration fallback url when activated_at is in future', function () {
|
||||
createShortUrl([
|
||||
'url_key' => 'fallback-not-yet-active',
|
||||
'activated_at' => now()->addDay(),
|
||||
'expiration_redirect_url' => 'https://fallback.example.com',
|
||||
'track_visits' => false,
|
||||
]);
|
||||
|
||||
$this->get('/s/fallback-not-yet-active')
|
||||
->assertRedirect('https://fallback.example.com');
|
||||
});
|
||||
|
||||
it('redirects to destination when activated_at is in past and deactivated_at is in future', function () {
|
||||
createShortUrl([
|
||||
'url_key' => 'active-range',
|
||||
'activated_at' => now()->subDay(),
|
||||
'deactivated_at' => now()->addDay(),
|
||||
'track_visits' => false,
|
||||
]);
|
||||
|
||||
$this->get('/s/active-range')
|
||||
->assertRedirect('https://example.com');
|
||||
});
|
||||
|
||||
it('redirects to expiration fallback when max visits is reached', function () {
|
||||
$shortUrl = createShortUrl([
|
||||
'url_key' => 'fallback-max-visits',
|
||||
'activated_at' => now()->subDay(),
|
||||
'max_visits' => 2,
|
||||
'expiration_redirect_url' => 'https://fallback.example.com',
|
||||
'track_visits' => false,
|
||||
]);
|
||||
|
||||
$shortUrl->total_visits = 2;
|
||||
$shortUrl->save();
|
||||
|
||||
$this->get('/s/fallback-max-visits')
|
||||
->assertRedirect('https://fallback.example.com');
|
||||
});
|
||||
|
||||
it('redirects to destination when max visits is not reached', function () {
|
||||
$shortUrl = createShortUrl([
|
||||
'url_key' => 'under-max-visits',
|
||||
'max_visits' => 2,
|
||||
'expiration_redirect_url' => 'https://fallback.example.com',
|
||||
'track_visits' => false,
|
||||
]);
|
||||
|
||||
$shortUrl->total_visits = 1;
|
||||
$shortUrl->save();
|
||||
|
||||
$this->get('/s/under-max-visits')
|
||||
->assertRedirect('https://example.com');
|
||||
});
|
||||
|
||||
it('disables single-use url after first visit', function () {
|
||||
createShortUrl(['url_key' => 'single1', 'single_use' => true, 'track_visits' => false]);
|
||||
|
||||
$this->get('/s/single1')->assertRedirect();
|
||||
|
||||
$shortUrl = ShortUrl::where('url_key', 'single1')->first();
|
||||
expect($shortUrl->is_enabled)->toBeFalse();
|
||||
});
|
||||
|
||||
it('uses 302 redirect by default', function () {
|
||||
createShortUrl(['url_key' => 'temp302', 'redirect_status_code' => 302, 'track_visits' => false]);
|
||||
|
||||
$this->get('/s/temp302')->assertStatus(302);
|
||||
});
|
||||
|
||||
it('records visit data when tracking is enabled', function () {
|
||||
Queue::fake();
|
||||
|
||||
createShortUrl(['url_key' => 'track1', 'track_visits' => true]);
|
||||
|
||||
$this->get('/s/track1', ['User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0']);
|
||||
|
||||
Queue::assertPushed(
|
||||
TrackShortUrlVisitJob::class
|
||||
);
|
||||
});
|
||||
|
||||
it('does not record visit when tracking is disabled', function () {
|
||||
Queue::fake();
|
||||
|
||||
createShortUrl(['url_key' => 'notrack1', 'track_visits' => false]);
|
||||
|
||||
$this->get('/s/notrack1');
|
||||
|
||||
Queue::assertNotPushed(
|
||||
TrackShortUrlVisitJob::class
|
||||
);
|
||||
});
|
||||
|
||||
it('extracts proxy-resistant client IP address', function () {
|
||||
config(['filament-short-url.queue_connection' => 'sync']);
|
||||
config(['filament-short-url.geo_ip.enabled' => false]);
|
||||
config(['filament-short-url.trust_cdn_headers' => true]);
|
||||
|
||||
$shortUrl = createShortUrl(['url_key' => 'proxyip', 'track_visits' => true]);
|
||||
|
||||
$this->get('/s/proxyip', [
|
||||
'CF-Connecting-IP' => '1.2.3.4',
|
||||
'X-Real-IP' => '5.6.7.8',
|
||||
'X-Forwarded-For' => '9.10.11.12, 13.14.15.16',
|
||||
]);
|
||||
|
||||
$visit = $shortUrl->visits()->first();
|
||||
expect($visit)->not->toBeNull()
|
||||
->and($visit->ip_address)->toBe('1.2.3.4');
|
||||
|
||||
$shortUrl2 = createShortUrl(['url_key' => 'proxyip2', 'track_visits' => true]);
|
||||
$this->get('/s/proxyip2', [
|
||||
'X-Forwarded-For' => '9.10.11.12, 13.14.15.16',
|
||||
]);
|
||||
|
||||
$visit2 = $shortUrl2->visits()->first();
|
||||
expect($visit2->ip_address)->toBe('9.10.11.12');
|
||||
});
|
||||
|
||||
it('resolves country from edge CDN headers offline', function () {
|
||||
config(['filament-short-url.queue_connection' => 'sync']);
|
||||
config(['filament-short-url.geo_ip.enabled' => true]);
|
||||
config(['filament-short-url.geo_ip.driver' => 'headers']);
|
||||
config(['filament-short-url.trust_cdn_headers' => true]);
|
||||
|
||||
$shortUrl = createShortUrl(['url_key' => 'cdngeo', 'track_visits' => true]);
|
||||
|
||||
$this->get('/s/cdngeo', [
|
||||
'CF-IPCountry' => 'PL',
|
||||
]);
|
||||
|
||||
$visit = $shortUrl->visits()->first();
|
||||
expect($visit)->not->toBeNull()
|
||||
->and($visit->country_code)->toBe('PL')
|
||||
->and($visit->country)->toBe('Poland');
|
||||
});
|
||||
|
||||
it('caches stats page calculations', function () {
|
||||
$shortUrl = createShortUrl(['url_key' => 'cachestats']);
|
||||
|
||||
$page = new ViewShortUrlStats;
|
||||
$page->record = $shortUrl;
|
||||
|
||||
// First mount: should be 0 since no visits exist
|
||||
$page->mount($shortUrl);
|
||||
expect($page->totalVisits)->toBe(0);
|
||||
|
||||
// Create a visit in the database (cache is not cleared automatically for this manual action)
|
||||
$shortUrl->visits()->create([
|
||||
'ip_address' => '1.2.3.4',
|
||||
'visited_at' => now(),
|
||||
]);
|
||||
|
||||
// Second mount: should still be 0 because stats are cached
|
||||
$page->mount($shortUrl);
|
||||
expect($page->totalVisits)->toBe(0);
|
||||
|
||||
// Clear the specific date-filtered cache key
|
||||
$dateFrom = now()->subDays(29)->format('Y-m-d');
|
||||
$dateTo = now()->format('Y-m-d');
|
||||
Cache::forget("short_url_stats_{$shortUrl->id}_{$dateFrom}_{$dateTo}");
|
||||
|
||||
// Third mount: should now be 1 because cache is cleared and recalculated
|
||||
$page->mount($shortUrl);
|
||||
expect($page->totalVisits)->toBe(1);
|
||||
});
|
||||
|
||||
it('records visit data with UTM parameters and resolved referer host', function () {
|
||||
config(['filament-short-url.queue_connection' => 'sync']);
|
||||
config(['filament-short-url.geo_ip.enabled' => false]);
|
||||
|
||||
$shortUrl = createShortUrl(['url_key' => 'utmtrack', 'track_visits' => true, 'track_referer_url' => true]);
|
||||
|
||||
$this->get('/s/utmtrack?utm_source=google&utm_medium=cpc&utm_campaign=summer_sale&utm_term=shoes&utm_content=banner', [
|
||||
'Referer' => 'https://m.facebook.com/some/path?query=string',
|
||||
]);
|
||||
|
||||
$visit = $shortUrl->visits()->first();
|
||||
expect($visit)->not->toBeNull();
|
||||
expect($visit->utm_source)->toBe('google');
|
||||
expect($visit->utm_medium)->toBe('cpc');
|
||||
expect($visit->utm_campaign)->toBe('summer_sale');
|
||||
expect($visit->utm_term)->toBe('shoes');
|
||||
expect($visit->utm_content)->toBe('banner');
|
||||
expect($visit->referer_url)->toBe('https://m.facebook.com/some/path?query=string');
|
||||
expect($visit->referer_host)->toBe('facebook.com');
|
||||
});
|
||||
|
||||
it('resolves city from edge CDN headers offline', function () {
|
||||
config(['filament-short-url.queue_connection' => 'sync']);
|
||||
config(['filament-short-url.geo_ip.enabled' => true]);
|
||||
config(['filament-short-url.geo_ip.driver' => 'headers']);
|
||||
config(['filament-short-url.trust_cdn_headers' => true]);
|
||||
|
||||
$shortUrl = createShortUrl(['url_key' => 'cdncity', 'track_visits' => true]);
|
||||
|
||||
$this->get('/s/cdncity', [
|
||||
'CF-IPCountry' => 'US',
|
||||
'CF-IPCity' => 'New York',
|
||||
]);
|
||||
|
||||
$visit = $shortUrl->visits()->first();
|
||||
expect($visit)->not->toBeNull()
|
||||
->and($visit->country_code)->toBe('US')
|
||||
->and($visit->country)->toBe('United States')
|
||||
->and($visit->city)->toBe('New York');
|
||||
});
|
||||
|
||||
it('resolves country and city from ip-api driver', function () {
|
||||
Http::fake([
|
||||
'http://ip-api.com/*' => Http::response([
|
||||
'status' => 'success',
|
||||
'country' => 'Germany',
|
||||
'countryCode' => 'DE',
|
||||
'city' => 'Berlin',
|
||||
], 200),
|
||||
]);
|
||||
|
||||
config(['filament-short-url.geo_ip.enabled' => true]);
|
||||
config(['filament-short-url.geo_ip.driver' => 'ip-api']);
|
||||
|
||||
$geoIpService = app(GeoIpService::class);
|
||||
$result = $geoIpService->resolve('8.8.8.8');
|
||||
|
||||
expect($result)->toBe([
|
||||
'country' => 'Germany',
|
||||
'country_code' => 'DE',
|
||||
'city' => 'Berlin',
|
||||
]);
|
||||
});
|
||||
|
||||
it('applies rate limiting on redirects when enabled', function () {
|
||||
config(['filament-short-url.rate_limiting.enabled' => true]);
|
||||
config(['filament-short-url.rate_limiting.max_attempts' => 2]);
|
||||
config(['filament-short-url.rate_limiting.decay_seconds' => 10]);
|
||||
|
||||
$shortUrl = createShortUrl(['url_key' => 'ratelimit1', 'track_visits' => false]);
|
||||
|
||||
// Attempt 1: Success
|
||||
$this->get('/s/ratelimit1')->assertRedirect('https://example.com');
|
||||
|
||||
// Attempt 2: Success
|
||||
$this->get('/s/ratelimit1')->assertRedirect('https://example.com');
|
||||
|
||||
// Attempt 3: Rate limited (429)
|
||||
$this->get('/s/ratelimit1')->assertStatus(429);
|
||||
});
|
||||
|
||||
it('requires password to redirect when protected', function () {
|
||||
$shortUrl = createShortUrl([
|
||||
'url_key' => 'password123',
|
||||
'password' => 'secret-key',
|
||||
'track_visits' => false,
|
||||
]);
|
||||
|
||||
// Unauthenticated request should render password prompt view
|
||||
$response = $this->get('/s/password123');
|
||||
$response->assertStatus(200);
|
||||
$response->assertSee('Password Required');
|
||||
|
||||
// Send incorrect password
|
||||
$response = $this->post('/s/password123', ['password' => 'wrong']);
|
||||
$response->assertStatus(200);
|
||||
$response->assertSee('Incorrect password');
|
||||
|
||||
// Send correct password
|
||||
$response = $this->post('/s/password123', ['password' => 'secret-key']);
|
||||
$response->assertRedirect('/s/password123');
|
||||
|
||||
// Following request should redirect to target
|
||||
$this->get('/s/password123')->assertRedirect('https://example.com');
|
||||
});
|
||||
|
||||
it('shows warning page before redirecting when enabled', function () {
|
||||
$shortUrl = createShortUrl([
|
||||
'url_key' => 'warn1',
|
||||
'show_warning_page' => true,
|
||||
'track_visits' => false,
|
||||
]);
|
||||
|
||||
// Unconfirmed visit should render warning page
|
||||
$response = $this->get('/s/warn1');
|
||||
$response->assertStatus(200);
|
||||
$response->assertSee('Security Redirect Warning');
|
||||
$response->assertSee('https://example.com');
|
||||
|
||||
// Confirmed visit should redirect
|
||||
$this->get('/s/warn1?confirmed=1')->assertRedirect('https://example.com');
|
||||
});
|
||||
|
||||
it('requires password first, then shows warning page when both are enabled', function () {
|
||||
$shortUrl = createShortUrl([
|
||||
'url_key' => 'both-secure',
|
||||
'password' => 'secret-combination',
|
||||
'show_warning_page' => true,
|
||||
'track_visits' => false,
|
||||
]);
|
||||
|
||||
// 1. Visit without password -> password prompt page, not warning page
|
||||
$response = $this->get('/s/both-secure');
|
||||
$response->assertStatus(200);
|
||||
$response->assertSee('Password Required');
|
||||
$response->assertDontSee('Security Redirect Warning');
|
||||
|
||||
// 2. Submit wrong password -> password prompt page with error
|
||||
$response = $this->post('/s/both-secure', ['password' => 'wrong']);
|
||||
$response->assertStatus(200);
|
||||
$response->assertSee('Incorrect password');
|
||||
$response->assertDontSee('Security Redirect Warning');
|
||||
|
||||
// 3. Submit correct password -> redirects back to the short URL path
|
||||
$response = $this->post('/s/both-secure', ['password' => 'secret-combination']);
|
||||
$response->assertRedirect('/s/both-secure');
|
||||
|
||||
// 4. Follow redirect (GET request with authenticated session, but without confirmed parameter)
|
||||
// -> shows redirect warning page, not direct redirect
|
||||
$response = $this->get('/s/both-secure');
|
||||
$response->assertStatus(200);
|
||||
$response->assertSee('Security Redirect Warning');
|
||||
$response->assertSee('https://example.com');
|
||||
|
||||
// 5. Follow the confirmed link (confirmed=1) -> redirects to destination
|
||||
$this->get('/s/both-secure?confirmed=1')
|
||||
->assertRedirect('https://example.com');
|
||||
});
|
||||
|
||||
it('targets redirect by device type', function () {
|
||||
$shortUrl = createShortUrl([
|
||||
'url_key' => 'device-target',
|
||||
'track_visits' => false,
|
||||
'targeting_rules' => [
|
||||
'type' => 'device',
|
||||
'device' => [
|
||||
'ios' => 'https://ios.example.com',
|
||||
'android' => 'https://android.example.com',
|
||||
'desktop' => 'https://desktop.example.com',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
// iOS
|
||||
$this->get('/s/device-target', ['User-Agent' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)'])
|
||||
->assertRedirect('https://ios.example.com');
|
||||
|
||||
// Android
|
||||
$this->get('/s/device-target', ['User-Agent' => 'Mozilla/5.0 (Linux; Android 10)'])
|
||||
->assertRedirect('https://android.example.com');
|
||||
|
||||
// Desktop
|
||||
$this->get('/s/device-target', ['User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'])
|
||||
->assertRedirect('https://desktop.example.com');
|
||||
});
|
||||
|
||||
it('targets redirect by country code', function () {
|
||||
config(['filament-short-url.trust_cdn_headers' => true]);
|
||||
|
||||
$shortUrl = createShortUrl([
|
||||
'url_key' => 'geo-target',
|
||||
'track_visits' => false,
|
||||
'targeting_rules' => [
|
||||
'type' => 'geo',
|
||||
'geo' => [
|
||||
['country_code' => 'PL', 'url' => 'https://pl.example.com'],
|
||||
['country_code' => 'US', 'url' => 'https://us.example.com'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
// PL
|
||||
$this->get('/s/geo-target', ['CF-IPCountry' => 'PL'])
|
||||
->assertRedirect('https://pl.example.com');
|
||||
|
||||
// US
|
||||
$this->get('/s/geo-target', ['CF-IPCountry' => 'US'])
|
||||
->assertRedirect('https://us.example.com');
|
||||
|
||||
// Other (fallback)
|
||||
$this->get('/s/geo-target', ['CF-IPCountry' => 'DE'])
|
||||
->assertRedirect('https://example.com');
|
||||
});
|
||||
|
||||
it('targets redirect by browser language', function () {
|
||||
$shortUrl = createShortUrl([
|
||||
'url_key' => 'lang-target',
|
||||
'track_visits' => false,
|
||||
'targeting_rules' => [
|
||||
'type' => 'language',
|
||||
'language' => [
|
||||
['language_code' => 'PL', 'url' => 'https://pl.example.com'],
|
||||
['language_code' => 'en-US', 'url' => 'https://enus.example.com'],
|
||||
['language_code' => 'de', 'url' => 'https://de.example.com'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
// PL (matching base pl)
|
||||
$this->get('/s/lang-target', ['Accept-Language' => 'pl-PL,pl;q=0.9'])
|
||||
->assertRedirect('https://pl.example.com');
|
||||
|
||||
// en-US (matching exact locale en-US)
|
||||
$this->get('/s/lang-target', ['Accept-Language' => 'en-US,en;q=0.8'])
|
||||
->assertRedirect('https://enus.example.com');
|
||||
|
||||
// de (matching base de from de-DE)
|
||||
$this->get('/s/lang-target', ['Accept-Language' => 'de-DE,de;q=0.8'])
|
||||
->assertRedirect('https://de.example.com');
|
||||
|
||||
// Other (fallback)
|
||||
$this->get('/s/lang-target', ['Accept-Language' => 'fr-FR,fr;q=0.9'])
|
||||
->assertRedirect('https://example.com');
|
||||
});
|
||||
|
||||
it('targets redirect by rotation rules', function () {
|
||||
$shortUrl = createShortUrl([
|
||||
'url_key' => 'rotation-target',
|
||||
'track_visits' => false,
|
||||
'targeting_rules' => [
|
||||
'type' => 'rotation',
|
||||
'rotation' => [
|
||||
['url' => 'https://a.example.com', 'weight' => 100],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->get('/s/rotation-target')->assertRedirect('https://a.example.com');
|
||||
});
|
||||
|
||||
it('aggregates old visits and prunes logs', function () {
|
||||
config(['filament-short-url.pruning.enabled' => true]);
|
||||
config(['filament-short-url.pruning.retention_days' => 7]);
|
||||
|
||||
$shortUrl = createShortUrl(['url_key' => 'agg1']);
|
||||
|
||||
// Create raw visit from yesterday
|
||||
$visitYesterday = $shortUrl->visits()->create([
|
||||
'ip_address' => '1.1.1.1',
|
||||
'ip_hash' => hash('sha256', '1.1.1.1'),
|
||||
'visited_at' => now()->subDay(),
|
||||
'device_type' => 'desktop',
|
||||
'browser' => 'Chrome',
|
||||
'operating_system' => 'Windows',
|
||||
'country_code' => 'PL',
|
||||
'country' => 'Poland',
|
||||
]);
|
||||
|
||||
// Create raw visit older than retention window (8 days ago)
|
||||
$visitOld = $shortUrl->visits()->create([
|
||||
'ip_address' => '2.2.2.2',
|
||||
'ip_hash' => hash('sha256', '2.2.2.2'),
|
||||
'visited_at' => now()->subDays(8),
|
||||
'device_type' => 'mobile',
|
||||
'browser' => 'Safari',
|
||||
'operating_system' => 'iOS',
|
||||
'country_code' => 'US',
|
||||
'country' => 'United States',
|
||||
]);
|
||||
|
||||
// Run aggregation command
|
||||
$this->artisan('short-url:aggregate-and-prune')->assertSuccessful();
|
||||
|
||||
// Verify stats were aggregated for yesterday (Eloquent-based to handle date cast differences across DBs)
|
||||
$statsYesterday = ShortUrlDailyStats::where('short_url_id', $shortUrl->id)
|
||||
->whereDate('date', now()->subDay()->toDateString())
|
||||
->first();
|
||||
|
||||
expect($statsYesterday)->not->toBeNull();
|
||||
expect($statsYesterday->visits_count)->toBe(1);
|
||||
expect($statsYesterday->unique_visits_count)->toBe(1);
|
||||
|
||||
// Verify stats were aggregated for 8 days ago
|
||||
$statsOld = ShortUrlDailyStats::where('short_url_id', $shortUrl->id)
|
||||
->whereDate('date', now()->subDays(8)->toDateString())
|
||||
->first();
|
||||
|
||||
expect($statsOld)->not->toBeNull();
|
||||
expect($statsOld->visits_count)->toBe(1);
|
||||
expect($statsOld->unique_visits_count)->toBe(1);
|
||||
|
||||
// Check pruning: visit from 8 days ago should be deleted, yesterday should remain
|
||||
$this->assertDatabaseMissing('short_url_visits', ['id' => $visitOld->id]);
|
||||
$this->assertDatabaseHas('short_url_visits', ['id' => $visitYesterday->id]);
|
||||
});
|
||||
|
||||
it('tracks QR code scan visits via query parameters', function () {
|
||||
config(['filament-short-url.queue_connection' => 'sync']);
|
||||
|
||||
$shortUrl = createShortUrl(['url_key' => 'qrtest1', 'track_visits' => true]);
|
||||
|
||||
// Test ?source=qr
|
||||
$this->get('/s/qrtest1?source=qr');
|
||||
$visit1 = $shortUrl->visits()->latest('id')->first();
|
||||
expect($visit1)->not->toBeNull()
|
||||
->and($visit1->is_qr_scan)->toBeTrue();
|
||||
|
||||
// Test ?qr=1
|
||||
$this->get('/s/qrtest1?qr=1');
|
||||
$visit2 = $shortUrl->visits()->latest('id')->first();
|
||||
expect($visit2)->not->toBeNull()
|
||||
->and($visit2->is_qr_scan)->toBeTrue();
|
||||
|
||||
// Test direct visit
|
||||
$this->get('/s/qrtest1');
|
||||
$visit3 = $shortUrl->visits()->latest('id')->first();
|
||||
expect($visit3)->not->toBeNull()
|
||||
->and($visit3->is_qr_scan)->toBeFalse();
|
||||
|
||||
$shortUrl->refresh();
|
||||
expect($shortUrl->qr_scans)->toBe(2);
|
||||
});
|
||||
|
||||
it('extracts and tracks visitor browser language from headers', function () {
|
||||
config(['filament-short-url.queue_connection' => 'sync']);
|
||||
|
||||
$shortUrl = createShortUrl(['url_key' => 'langtest1', 'track_visits' => true]);
|
||||
|
||||
$this->get('/s/langtest1', [
|
||||
'Accept-Language' => 'pl-PL,pl;q=0.9,en-US;q=0.8,en;q=0.7',
|
||||
]);
|
||||
|
||||
$visit = $shortUrl->visits()->first();
|
||||
expect($visit)->not->toBeNull()
|
||||
->and($visit->browser_language)->toBe('pl');
|
||||
});
|
||||
|
||||
it('does not track browser language if track_browser_language is disabled', function () {
|
||||
config(['filament-short-url.queue_connection' => 'sync']);
|
||||
|
||||
$shortUrl = createShortUrl([
|
||||
'url_key' => 'langtest2',
|
||||
'track_visits' => true,
|
||||
'track_browser_language' => false,
|
||||
]);
|
||||
|
||||
$this->get('/s/langtest2', [
|
||||
'Accept-Language' => 'pl-PL,pl;q=0.9,en-US;q=0.8,en;q=0.7',
|
||||
]);
|
||||
|
||||
$visit = $shortUrl->visits()->first();
|
||||
expect($visit)->not->toBeNull()
|
||||
->and($visit->browser_language)->toBeNull();
|
||||
});
|
||||
|
||||
it('tracks browser language if track_browser_language is enabled', function () {
|
||||
config(['filament-short-url.queue_connection' => 'sync']);
|
||||
|
||||
$shortUrl = createShortUrl([
|
||||
'url_key' => 'langtest3',
|
||||
'track_visits' => true,
|
||||
'track_browser_language' => true,
|
||||
]);
|
||||
|
||||
$this->get('/s/langtest3', [
|
||||
'Accept-Language' => 'fr-FR,fr;q=0.9',
|
||||
]);
|
||||
|
||||
$visit = $shortUrl->visits()->first();
|
||||
expect($visit)->not->toBeNull()
|
||||
->and($visit->browser_language)->toBe('fr');
|
||||
});
|
||||
|
||||
it('enforces max_visits in real-time even when model caching is active', function () {
|
||||
config([
|
||||
'filament-short-url.cache_ttl' => 3600,
|
||||
'filament-short-url.queue_connection' => 'sync',
|
||||
]);
|
||||
|
||||
$shortUrl = createShortUrl([
|
||||
'url_key' => 'max-visits-cache',
|
||||
'max_visits' => 2,
|
||||
'track_visits' => true,
|
||||
]);
|
||||
|
||||
// First visit: gets cached, redirects
|
||||
$this->get('/s/max-visits-cache')->assertRedirect('https://example.com');
|
||||
|
||||
// Second visit: loaded from cache, redirects
|
||||
$this->get('/s/max-visits-cache')->assertRedirect('https://example.com');
|
||||
|
||||
// Third visit: should detect limit reached and return 410
|
||||
$this->get('/s/max-visits-cache')->assertStatus(410);
|
||||
});
|
||||
|
||||
it('enforces single_use in real-time even when model caching is active', function () {
|
||||
config([
|
||||
'filament-short-url.cache_ttl' => 3600,
|
||||
'filament-short-url.queue_connection' => 'sync',
|
||||
]);
|
||||
|
||||
$shortUrl = createShortUrl([
|
||||
'url_key' => 'single-use-cache',
|
||||
'single_use' => true,
|
||||
'track_visits' => true,
|
||||
]);
|
||||
|
||||
// First visit: redirects and disables the URL
|
||||
$this->get('/s/single-use-cache')->assertRedirect('https://example.com');
|
||||
|
||||
// Second visit: should return 410, even if the model exists in the cache as enabled
|
||||
$this->get('/s/single-use-cache')->assertStatus(410);
|
||||
});
|
||||
|
||||
it('renders custom branded expired view on deactivated URL', function () {
|
||||
createShortUrl(['url_key' => 'disabled-view', 'is_enabled' => false, 'track_visits' => false]);
|
||||
|
||||
$response = $this->get('/s/disabled-view');
|
||||
$response->assertStatus(410);
|
||||
$response->assertSee('Link Inactive or Expired');
|
||||
$response->assertSee('Go to Homepage');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user