34 Commits

Author SHA1 Message Date
Bartłomiej Janczak
aa05f026b5 feat: speed optimization, portability fixes, and documentation update for DNS resolution 2026-06-05 09:15:04 +02:00
Bartłomiej Janczak
15d992c5ad v3.3.0: Advanced Multi-Filter Targeting, Strict API Validation, Clean Configs, UX Improvements 2026-06-04 15:45:14 +02:00
Bartłomiej Janczak
719901d4c6 feat: expand developer REST API, modularize logo controller, and update README for v3.2.0 2026-06-04 14:29:07 +02:00
Bartłomiej Janczak
3b9411808e feat: Release v3.1.0 - Performance, Security, and Scalability updates 2026-06-04 12:53:54 +02:00
Bartłomiej Janczak
3a9575f5b1 Optimize redirect performance and protect password validation against brute force 2026-06-04 12:26:21 +02:00
Bartłomiej Janczak
325a38bfe8 Update license badge color to violet in README 2026-06-04 12:09:40 +02:00
Bartłomiej Janczak
1cd85af0a1 Update license to proprietary source-available and add copyright headers 2026-06-04 12:07:31 +02:00
Bartłomiej Janczak
137a81dcab feat: Release v3.0.0 — Native App Linking, Deep Linking, Pixels Registry, and Custom Expiry Pages 2026-06-03 17:21:03 +02:00
Bartłomiej Janczak
276b75ad9f docs: Update README for v2.1.0 registry and language features 2026-06-03 14:57:45 +02:00
Bartłomiej Janczak
d8d6b1b22f feat: Implement Centralized Retargeting Pixels Registry (v2.1.0) 2026-06-03 14:55:46 +02:00
Bartłomiej Janczak
0d4eab2016 docs/feat: Add targeting and security fields support to API and README docs 2026-06-03 14:28:57 +02:00
Bartłomiej Janczak
0349825890 Add browser language-based redirect targeting strategy, localize language names, and clean up translations 2026-06-03 14:24:08 +02:00
Bartłomiej Janczak
e1e6ded015 Update README to document empty route prefix and automatic scheduler registration 2026-06-03 14:12:05 +02:00
Bartłomiej Janczak
c5147ac170 Optimize visit aggregation, prevent stale cache in limits, fix Redis exist check, edge mobile detection, and scheduling 2026-06-03 14:10:14 +02:00
Bartłomiej Janczak
0cdc4ad0db Update helper texts for route prefix and redirect status code 2026-06-03 14:01:19 +02:00
Bartłomiej Janczak
5e2b266793 Allow route prefix to be empty for root domain short URLs 2026-06-03 13:58:09 +02:00
Bartłomiej Janczak
26247fd94a Release v2.0.0: SVG QR Code Designer, visit counter buffering, Geo-IP and browser language tracking, dashboard redesign 2026-06-03 13:52:31 +02:00
Bartłomiej Janczak
f3623df340 feat: show share modal after creating a new short URL 2026-06-02 20:54:33 +02:00
Bartłomiej Janczak
48ecb8ed28 docs: add authorizeSettingsUsing documentation and v1.7.0 changelog entry 2026-06-02 20:39:19 +02:00
Bartłomiej Janczak
d67bd56f68 fix: canAccess signature compatible with Filament Page parent class 2026-06-02 20:37:29 +02:00
Bartłomiej Janczak
d075ee83b0 feat: hide settings button in table header for unauthorized users 2026-06-02 20:34:03 +02:00
Bartłomiej Janczak
67ce293685 Optimize counter buffering, add reactive settings callouts, and resolve audit bugs 2026-06-02 19:33:19 +02:00
Bartłomiej Janczak
ce8062036a Configure global webhook toggle, emerald prefix padlock, and key length range 3-20 2026-06-02 18:52:46 +02:00
Bartłomiej Janczak
e8e9f26c9b Add queue worker placeholder info callout 2026-06-02 18:39:39 +02:00
Bartłomiej Janczak
da810db511 Make queue_name dynamically required based on queue connection 2026-06-02 18:35:43 +02:00
Bartłomiej Janczak
8de83e3973 Hide Queue Name field when sync connection is selected 2026-06-02 18:27:59 +02:00
Bartłomiej Janczak
a5421a2d30 Add missing security v2 and world map translation keys (EN and PL) 2026-06-02 18:25:41 +02:00
Bartłomiej Janczak
d1eb322cb6 Add visit logs screenshot (5.png) to README 2026-06-02 18:21:56 +02:00
Bartłomiej Janczak
3d62dbcbb7 Improve tooltip hover triggers using event delegation 2026-06-02 18:17:27 +02:00
Bartłomiej Janczak
c650c1f205 Fix world map visual display, dark mode support and tooltips 2026-06-02 18:13:38 +02:00
Bartłomiej Janczak
34e4339c7c Release v1.6.0: Google Safe Browsing integration, VPN/Proxy/Bot filtering, and Visitor World Map widget 2026-06-02 17:56:16 +02:00
Bartłomiej Janczak
e847328b9b UX: Dynamically show/hide Developer API Keys section based on REST API enable state 2026-06-02 13:22:35 +02:00
Bartłomiej Janczak
911e98a2e3 Release v1.5.1: Rest API toggle, webhook fault tolerance, time-boundary aggregation fix, pixel loading UI, and resolveRedirectUrl targeting rules alignment 2026-06-02 13:17:42 +02:00
Bartłomiej Janczak
bdef0b6687 feat: add api_enabled on/off toggle in settings + update README for v1.5 2026-06-02 11:10:10 +02:00
149 changed files with 32528 additions and 3258 deletions

30
CONTRIBUTING.md Normal file
View 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
```

33
LICENSE
View File

@@ -1,21 +1,22 @@
MIT License
Custom Source-Available 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:
TERMS AND CONDITIONS
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
1. Grant of License
Subject to the terms of this License, the Copyright Holder hereby grants you a worldwide, royalty-free, non-exclusive, non-sublicensable license to:
- Use the Software for personal, internal, or commercial purposes.
- Modify the Software for your own personal, internal, or commercial use.
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.
2. Restrictions
You are strictly prohibited from:
- Redistributing the Software, in whole or in part, or any modified versions thereof, in any public repository (including but not limited to public repositories on GitHub, GitLab, Bitbucket) or package registry (such as Packagist).
- Selling, renting, leasing, or sublicensing the Software or any modified versions thereof as a standalone package or library.
- Publishing, distributing, or sharing the Software under a different name or package name.
3. Copyright Notice
The above copyright notice and this permission notice must be included in all copies or substantial portions of the Software.
4. Disclaimer of Warranty
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.

1160
README.md

File diff suppressed because it is too large Load Diff

BIN
art/1.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

BIN
art/2.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

BIN
art/3.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 362 KiB

BIN
art/4.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 KiB

BIN
art/v2_1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

BIN
art/v2_2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

BIN
art/v2_3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 451 KiB

BIN
art/v2_4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

BIN
art/v2_5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 484 KiB

View File

@@ -4,11 +4,11 @@
"description": "A professional Short URL manager plugin for Filament v5 with QR code design, visit tracking, geo-IP detection, and analytics.",
"keywords": ["filament", "short-url", "qr-code", "analytics", "tracking", "laravel"],
"homepage": "https://github.com/janczakb/filament-short-url",
"license": "MIT",
"license": "proprietary",
"authors": [
{
"name": "Bartek Janczak",
"email": "hello@bjanczak.dev",
"email": "barek122@gmail.com",
"role": "Developer"
}
],

12190
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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
@@ -32,6 +42,24 @@ return [
*/
'key_length' => 6,
/*
|--------------------------------------------------------------------------
| Lock URL Key
|--------------------------------------------------------------------------
| Globally disable changing the short key after a link is created.
|
*/
'lock_url_key' => env('SHORT_URL_LOCK_KEY', false),
/*
|--------------------------------------------------------------------------
| Disable Default Domain
|--------------------------------------------------------------------------
| Disable the default app domain for short links.
|
*/
'disable_default_domain' => env('SHORT_URL_DISABLE_DEFAULT_DOMAIN', false),
/*
|--------------------------------------------------------------------------
| Geo-IP Settings
@@ -60,6 +88,7 @@ return [
*/
'tracking' => [
'enabled' => true,
'anonymize_ips' => env('SHORT_URL_ANONYMIZE_IPS', false),
'fields' => [
'ip_address' => true,
'browser' => true,
@@ -68,6 +97,7 @@ return [
'operating_system_version' => true,
'referer_url' => true,
'device_type' => true,
'browser_language' => true,
],
],
@@ -193,6 +223,15 @@ return [
'decay_seconds' => env('SHORT_URL_RATE_LIMIT_DECAY', 60),
],
/*
|--------------------------------------------------------------------------
| Enable Fallback Route
|--------------------------------------------------------------------------
| Register a fallback route to catch redirects for custom domains.
| Set to false if you wish to define custom domain routing manually.
*/
'enable_fallback_route' => env('SHORT_URL_ENABLE_FALLBACK', true),
/*
|--------------------------------------------------------------------------
| Redirect Route Middleware
@@ -206,4 +245,31 @@ return [
'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),
],
/*
|--------------------------------------------------------------------------
| User Integration Settings
|--------------------------------------------------------------------------
| Configure how the package interacts with the User model for showing avatars
| and user details (name, email) in the Filament admin panel.
|
*/
'user' => [
'model' => \App\Models\User::class,
'name_column' => 'name',
'email_column' => 'email',
'avatar_column' => 'avatar_url', // can be attribute/method on model or null to auto-detect HasAvatar
],
];

View File

@@ -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::create('short_url_settings', function (Blueprint $table) {
$table->string('key')->primary();
$table->longText('value')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('short_url_settings');
}
};

View File

@@ -0,0 +1,33 @@
<?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::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']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('short_url_pixels');
}
};

View File

@@ -1,52 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('short_url_visits', function (Blueprint $table): void {
$table->id();
$table->foreignId('short_url_id')
->constrained('short_urls')
->cascadeOnDelete();
// Visitor fingerprint
$table->ipAddress('ip_address')->nullable();
$table->string('ip_hash', 64)->nullable()->index(); // SHA-256 of IP for unique counting
// Browser detection
$table->string('browser', 100)->nullable();
$table->string('browser_version', 50)->nullable();
// OS detection
$table->string('operating_system', 100)->nullable();
$table->string('operating_system_version', 50)->nullable();
// Device classification ('desktop', 'mobile', 'tablet', 'robot') — validated at PHP level
$table->string('device_type', 20)->nullable()->index();
// Traffic source
$table->text('referer_url')->nullable();
// Geo-location
$table->string('country', 100)->nullable()->index();
$table->char('country_code', 2)->nullable()->index();
$table->timestamp('visited_at')->useCurrent()->index();
// Composite indexes for performance on millions of visits
$table->index(['short_url_id', 'ip_hash']);
$table->index(['short_url_id', 'visited_at']);
});
}
public function down(): void
{
Schema::dropIfExists('short_url_visits');
}
};

View File

@@ -6,13 +6,18 @@ use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('short_urls', function (Blueprint $table): void {
Schema::create('short_urls', function (Blueprint $table) {
$table->id();
// Core
$table->text('destination_url');
$table->string('destination_type', 20)->default('single'); // single, split
$table->text('destination_url')->nullable();
$table->json('rotation_variants')->nullable();
$table->string('url_key', 32)->unique()->index();
$table->string('notes')->nullable();
@@ -22,10 +27,19 @@ return new class extends Migration
$table->timestamp('activated_at')->nullable();
$table->timestamp('deactivated_at')->nullable();
$table->timestamp('expires_at')->nullable()->index();
$table->unsignedInteger('max_visits')->nullable();
$table->string('expiration_redirect_url')->nullable();
// Behaviour
$table->boolean('single_use')->default(false);
$table->boolean('forward_query_params')->default(false);
$table->boolean('auto_open_app_mobile')->default(false);
// Security, Webhooks & QR Counters
$table->string('password')->nullable();
$table->boolean('show_warning_page')->default(false);
$table->string('webhook_url', 2048)->nullable();
$table->integer('qr_scans')->default(0);
// Tracking — master switch
$table->boolean('track_visits')->default(true);
@@ -38,23 +52,38 @@ return new class extends Migration
$table->boolean('track_operating_system_version')->default(true);
$table->boolean('track_device_type')->default(true);
$table->boolean('track_referer_url')->default(true);
$table->boolean('track_browser_language')->default(true);
// QR code design (JSON blob)
// QR code options & branding logo
$table->json('qr_options')->nullable();
$table->string('qr_logo')->nullable();
// Google Analytics 4 integration
$table->string('ga_tracking_id', 50)->nullable();
// Advanced targeting rules (AND/OR, Multi-filter)
$table->json('targeting_rules')->nullable();
// Denormalized counters for fast reads (updated atomically)
$table->unsignedBigInteger('total_visits')->default(0);
$table->unsignedBigInteger('unique_visits')->default(0);
$table->timestamps();
});
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']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('short_url_pixel');
Schema::dropIfExists('short_urls');
}
};

View File

@@ -0,0 +1,63 @@
<?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::create('short_url_visits', function (Blueprint $table) {
$table->id();
$table->foreignId('short_url_id')->constrained('short_urls')->cascadeOnDelete();
// Client info
$table->ipAddress('ip_address')->nullable();
$table->string('ip_hash', 64)->nullable()->index();
$table->char('country_code', 2)->nullable()->index();
$table->string('country', 100)->nullable();
$table->string('city', 100)->nullable();
$table->string('browser', 100)->nullable();
$table->string('browser_version', 50)->nullable();
$table->string('operating_system', 100)->nullable();
$table->string('operating_system_version', 50)->nullable();
$table->string('device_type', 50)->nullable()->index();
$table->string('browser_language', 10)->nullable();
// Referrer
$table->text('referer_url')->nullable();
$table->string('referer_host', 150)->nullable()->index();
// Marketing UTM Tracing
$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();
// Context flags
$table->boolean('is_qr_scan')->default(false)->index();
$table->boolean('is_bot')->default(false)->index();
$table->boolean('is_proxy')->default(false)->index();
// A/B Testing chosen variant label/URL
$table->string('selected_variant', 255)->nullable()->index();
$table->timestamp('visited_at')->useCurrent()->index();
$table->index(['short_url_id', 'is_bot', 'is_proxy', 'visited_at'], 'visits_performance_index');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('short_url_visits');
}
};

View File

@@ -6,31 +6,48 @@ use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('short_url_daily_stats', function (Blueprint $table): void {
Schema::create('short_url_daily_stats', function (Blueprint $table) {
$table->id();
$table->foreignId('short_url_id')
->constrained('short_urls')
->cascadeOnDelete();
$table->date('date');
$table->foreignId('short_url_id')->constrained('short_urls')->cascadeOnDelete();
$table->date('date')->index();
// Aggregated click counts
$table->integer('visits_count')->default(0);
$table->integer('unique_visits_count')->default(0);
$table->integer('qr_visits_count')->default(0);
// JSON dimensions aggregates
$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('language_stats')->nullable();
// Marketing aggregates
$table->json('utm_source_stats')->nullable();
$table->json('utm_medium_stats')->nullable();
$table->json('utm_campaign_stats')->nullable();
// A/B testing variant aggregates
$table->json('variant_stats')->nullable();
$table->timestamps();
// Enforce unique record per short URL and date
$table->unique(['short_url_id', 'date']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('short_url_daily_stats');

View File

@@ -1,36 +0,0 @@
<?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',
]);
});
}
};

View File

@@ -1,28 +0,0 @@
<?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',
]);
});
}
};

View File

@@ -1,23 +0,0 @@
<?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']);
});
}
};

View File

@@ -12,10 +12,7 @@ return new class extends Migration
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();
$table->unsignedBigInteger('user_id')->nullable()->after('id')->index();
});
}
@@ -25,12 +22,7 @@ return new class extends Migration
public function down(): void
{
Schema::table('short_urls', function (Blueprint $table) {
$table->dropColumn([
'pixel_meta_id',
'pixel_google_id',
'pixel_linkedin_id',
'webhook_url',
]);
$table->dropColumn('user_id');
});
}
};

View File

@@ -0,0 +1,31 @@
<?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::create('short_url_custom_domains', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('user_id')->nullable()->index();
$table->string('domain', 191)->unique()->index();
$table->boolean('is_verified')->default(false);
$table->boolean('is_active')->default(true)->index();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('short_url_custom_domains');
}
};

View File

@@ -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->unsignedBigInteger('custom_domain_id')->nullable()->after('user_id')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('short_urls', function (Blueprint $table) {
$table->dropColumn('custom_domain_id');
});
}
};

View File

@@ -7,6 +7,14 @@
position: relative !important;
}
.short-url-card:hover {
z-index: 20 !important;
}
.short-url-card:focus-within {
z-index: 30 !important;
}
.short-url-card .fi-ta-actions {
position: absolute !important;
top: 1.25rem !important;
@@ -15,3 +23,66 @@
margin: 0 !important;
padding: 0 !important;
}
.ab-test-slider {
margin-top: 0;
}
.ab-test-slider .noUi-tooltip {
position: absolute;
top: -24px;
width: 30px;
height: 20px;
bottom: inherit !important;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 100px;
font-size: 12px;
background: #ffffff26;
backdrop-filter: blur(4px);
}
/* 3-dot button trigger styling */
.action-trigger-btn {
border: 1px solid transparent !important;
background-color: transparent !important;
transition: all 0.2s ease-in-out;
}
/* On card hover, show a subtle border */
.short-url-card:hover .action-trigger-btn {
border-color: #e5e7eb !important;
background-color: #f9fafb !important;
}
.dark .short-url-card:hover .action-trigger-btn {
border-color: #374151 !important;
background-color: #1f2937 !important;
}
/* When the dropdown is open (trigger has focus or aria-expanded="true"), show active border */
.action-trigger-btn[aria-expanded="true"] {
border-color: #9ca3af !important;
background-color: #f3f4f6 !important;
box-shadow: 0 0 0 4px rgba(229, 230, 235, 0.5) !important;
}
.dark .action-trigger-btn[aria-expanded="true"] {
border-color: #4b5563 !important;
background-color: #111827 !important;
box-shadow: 0 0 0 4px rgba(55, 65, 81, 0.5) !important;
}
.custom-fused .fi-input-wrp {
border: 0 !important;
}
/* Ukrycie strzałki rozwijania selecta, gdy jest zablokowany */
select.fi-select-input:disabled,
.fi-select-input .fi-select-input-btn[disabled],
.fi-select-input .fi-select-input-btn.fi-disabled {
background-image: none !important;
padding-right: 0.75rem !important;
}

File diff suppressed because one or more lines are too long

View File

@@ -1,16 +1,35 @@
<?php
return [
'navigation_label' => 'Short Links',
'navigation_label' => 'Links',
'navigation_group' => 'Tools',
'resource_title' => 'Short URL',
'empty_state_heading' => 'No links yet',
'empty_state_description' => 'Start creating short links for your marketing campaigns, referral programs, and more.',
'empty_state_action' => 'Create link',
'empty_state_pixel_heading' => 'No pixels yet',
'empty_state_pixel_description' => 'Add tracking pixels from Meta, Google, TikTok and more to retarget visitors who click your links.',
'empty_state_pixel_action' => 'Create pixel',
// Form Tabs
'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_type' => 'Destination Type',
'destination_type_single' => 'Single URL',
'destination_type_split' => 'A/B Split Test',
'destination_url' => 'Destination URL',
'destination_url_helper' => 'The original URL you want to redirect visitors to.',
'url_key' => 'Short Key',
@@ -58,6 +77,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)',
@@ -98,8 +118,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',
@@ -115,11 +153,22 @@ return [
'action_qr' => 'QR Code',
'action_edit' => 'Edit URL',
'action_delete' => 'Delete URL',
'url_key_change_confirmation_heading' => 'Replace link?',
'url_key_change_confirmation' => "You've modified the back-half of this link's URL. Saving these changes will update the key. Any references to the original link will break.",
'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',
@@ -128,6 +177,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',
@@ -145,6 +195,8 @@ return [
'stats_table_title' => 'Visit Logs',
'stats_btn_back' => 'Back to list',
'stats_btn_copy' => 'Copy Short URL',
'stats_btn_refresh' => 'Refresh',
'stats_refresh_success' => 'Statistics refreshed in real-time!',
'stats_col_time' => 'Time',
'stats_col_country' => 'Country',
'stats_col_device' => 'Device',
@@ -167,8 +219,15 @@ 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_lock_url_key' => 'Lock Short Key',
'settings_lock_url_key_helper' => 'Globally disable changing the short key after a link is created.',
'settings_disable_default_domain' => 'Disable Default Domain',
'settings_disable_default_domain_helper' => 'Prevent users from using the default app domain for new short links.',
'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',
@@ -184,11 +243,12 @@ 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="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm.75-11.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm0 2.5a.75.75 0 0 1 .75.75v4a.75.75 0 0 1-1.5 0v-4a.75.75 0 0 1 .75-.75Z"></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',
@@ -207,10 +267,13 @@ return [
'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="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm.75-11.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm0 2.5a.75.75 0 0 1 .75.75v4a.75.75 0 0 1-1.5 0v-4a.75.75 0 0 1 .75-.75Z"></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',
@@ -223,6 +286,15 @@ return [
'stats_csv_browser' => 'Browser',
'stats_csv_os' => 'OS',
'stats_csv_referer' => 'Referer',
'stats_csv_utm_source' => 'UTM Source',
'stats_csv_utm_medium' => 'UTM Medium',
'stats_csv_utm_campaign' => 'UTM Campaign',
'stats_csv_utm_term' => 'UTM Term',
'stats_csv_utm_content' => 'UTM Content',
'stats_csv_variant' => 'A/B Variant',
'stats_csv_qr_scan' => 'QR Scan',
'stats_csv_bot' => 'Bot',
'stats_csv_proxy' => 'Proxy/VPN',
// Bulk Actions Localization
'action_enable_selected' => 'Enable selected',
@@ -246,9 +318,11 @@ return [
'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_breakdown_utm_source' => 'Source',
'stats_breakdown_utm_medium' => 'Medium',
'stats_breakdown_utm_campaign' => 'Campaign',
'stats_breakdown_utm_term' => 'Term',
'stats_breakdown_utm_content' => 'Content',
'stats_no_utm_data' => 'No UTM data recorded.',
// New Targeting & Security Tab and fields
@@ -256,6 +330,16 @@ return [
'security_section_title' => 'Security Controls',
'password' => 'Access Password',
'password_helper' => 'Require visitors to enter a password before being redirected.',
'confirm_password' => 'Confirm Password',
'new_password' => 'New Password',
'change_password' => 'Change Password',
'remove_password' => 'Remove Password',
'password_status_active' => 'Password protection is enabled.',
'set_password' => 'Set Password',
'cancel' => 'Cancel',
'confirm' => 'Confirm',
'password_required_error' => 'Password is required.',
'password_mismatch_error' => 'Passwords do not match.',
'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',
@@ -263,16 +347,44 @@ return [
'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.',
'rotation_variants' => 'A/B Split Variants',
'rotation_variants_helper' => 'Define the URLs and weight distribution for the A/B Split Test.',
'variant_label' => 'Variant Label',
'variant_url' => 'Variant URL',
'variant_weight' => 'Traffic Share (%)',
'safe_browsing_error' => 'This URL has been flagged by Google Safe Browsing as unsafe.',
// New Advanced Targeting Builder
'targeting_rules' => 'Targeting Rules',
'add_filter' => 'Add a filter',
'match' => 'Match',
'match_or' => 'Any of the following filters (OR)',
'match_and' => 'All the following filters (AND)',
'filter_device' => 'Device',
'filter_platform' => 'Platform',
'filter_country' => 'Country',
'filter_language' => 'Browser Language',
'direct_to_url' => 'Direct to URL',
'select_devices' => 'Select devices',
'select_platforms' => 'Select operating systems',
'select_countries' => 'Select countries',
'select_languages' => 'Select browser languages',
'device_desktop_label' => 'Desktop',
'device_mobile_label' => 'Smartphone',
'device_tablet_label' => 'Tablet',
// New Settings Page Fields
'settings_tab_advanced' => 'Performance & Security',
@@ -298,8 +410,9 @@ return [
// 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="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm.75-11.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm0 2.5a.75.75 0 0 1 .75.75v4a.75.75 0 0 1-1.5 0v-4a.75.75 0 0 1 .75-.75Z"></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' => 'Number of seconds to cache query metrics on the dashboard statistics page (prevents database load). Default: 300 (5 minutes).',
'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',
@@ -313,6 +426,10 @@ return [
'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',
'settings_track_anonymize_ips' => 'IP Address Anonymization (GDPR/RODO)',
'settings_track_anonymize_ips_helper' => 'Replace the last octet of IPv4 addresses or last 80 bits of IPv6 addresses with zeros before saving them to the database to comply with GDPR/RODO regulations. The raw IP address is still hashed privately to ensure unique click statistics remain accurate.',
'stats_filter_limit_exceeded' => 'Maximum of 5 filters can be applied simultaneously.',
// QR Defaults Tab
'settings_tab_qr' => 'QR Defaults',
@@ -338,6 +455,8 @@ return [
'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',
@@ -349,10 +468,17 @@ return [
'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',
'webhook_helper_alert' => 'Once configured, each visit to this short link triggers a real-time HTTP POST request to this URL. The payload contains detailed click metadata in JSON format (including URL key, IP address, country, browser, operating system, referrer, and UTM parameters).',
'webhook_show_payload' => 'Show example JSON payload',
// 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',
@@ -367,4 +493,186 @@ return [
'api_key_name' => 'Key Name (Description)',
'api_key' => 'API Key',
'active' => 'Active',
'api_key_generated' => 'API Key Generated',
'api_key_warning' => "Please copy this key now. You won't be able to see it again!",
// 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_card_qr_rate' => 'QR Scan Conversion Rate',
'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',
'weights_sum_error' => 'The sum of all variant weights must be exactly 100%. Current sum: :sum%.',
'balance_weights' => 'Adjust to 100%',
'url_key_locked_error' => 'The URL key is locked and cannot be changed.',
'custom_domain_locked_error' => 'The custom domain is locked and cannot be changed.',
// Stats View & Granularity & PoP Trends
'stats_prev_period' => 'prev period',
'stats_granularity_hourly' => 'Hourly',
'stats_granularity_daily' => 'Daily',
'stats_granularity_weekly' => 'Weekly',
'stats_granularity_monthly' => 'Monthly',
// Stats Security Widget
'stats_security_title' => 'Security & Bot Analytics',
'stats_security_real_users' => 'Real Users',
'stats_security_bots' => 'Bots & Crawlers',
'stats_security_vpn_blocked' => 'Blocked VPN/Proxy Traffic',
'stats_security_no_vpn_blocks' => 'No blocked VPN visits',
'stats_security_bot_ratio' => 'Bot Traffic Ratio',
'stats_security_human_clicks' => 'human clicks',
'stats_security_bot_clicks' => 'bot clicks',
'stats_security_proxy_clicks' => 'VPN/Proxy detected',
// Visit Logs Column & Modal translation enhancements
'stats_col_link' => 'Link',
'stats_col_referrer' => 'Referrer',
'stats_col_language' => 'Language',
'stats_col_utm_source' => 'UTM Source',
'stats_col_utm_medium' => 'UTM Medium',
'stats_col_utm_campaign' => 'UTM Campaign',
'stats_col_utm_term' => 'UTM Term',
'stats_col_utm_content' => 'UTM Content',
'stats_col_variant' => 'A/B Variant',
'stats_col_qr_scan' => 'QR Scan',
'stats_col_bot' => 'Bot',
'stats_col_proxy' => 'Proxy/VPN',
'stats_referer_direct' => '(direct)',
'stats_device_desktop' => 'Desktop',
'stats_device_mobile' => 'Mobile',
'stats_device_tablet' => 'Tablet',
'stats_device_bot' => 'Robot / Bot',
'stats_action_view_details' => 'View Details',
'stats_modal_visit_details' => 'Visit Details',
'stats_detail_location' => 'Location',
'stats_detail_is_bot' => 'Is Bot?',
'stats_detail_is_proxy' => 'Is Proxy/VPN?',
'stats_detail_is_qr' => 'Is QR Scan?',
'stats_detail_visited_at' => 'Visited At',
'stats_yes' => 'Yes',
'stats_no' => 'No',
'add_url' => 'Add URL',
'traffic_split' => 'Traffic Split',
'delete_confirmation_desc' => 'Are you sure you want to delete the following link? Deleting these links will remove all of their analytics. This action cannot be undone proceed with caution.',
'delete_verification_label' => 'To verify, type <strong>:short_url</strong> below',
'delete_verification_error' => 'The entered URL does not match.',
// Custom Domains
'empty_state_domain_heading' => 'No custom domains yet',
'empty_state_domain_description' => 'Add your own custom domains to serve short links using your brand identity (e.g. links.acme.com) instead of the default app domain.',
'empty_state_domain_action' => 'Add domain',
'dns_configuration_guide' => 'DNS Configuration Guide',
'dns_for_subdomains' => 'For Subdomains (CNAME)',
'dns_for_root_domains' => 'For Root Domains (A Record)',
'copied' => 'Copied!',
'domains_navigation_label' => 'Custom Domains',
'domain_resource_title' => 'Custom Domain',
'domain_label' => 'Domain Name',
'domain_status_active' => 'Active',
'domain_status_verified' => 'DNS Verified',
'domain_verification_status' => 'Verification Status',
'action_verify_dns' => 'Verify DNS',
'dns_verify_success' => 'DNS record verified successfully!',
'dns_verify_fail' => 'DNS verification failed. Please check your configuration and try again.',
'default_domain_label' => 'Default App Domain',
'dns_tab_a' => 'A Record (recommended)',
'dns_tab_cname' => 'CNAME Record',
'dns_instructions_apex' => 'To configure your apex domain :domain, set the following A record on your DNS provider:',
'dns_instructions_subdomain' => 'To configure your subdomain :domain, set the following CNAME record on your DNS provider:',
'dns_table_type' => 'Type',
'dns_table_name' => 'Name',
'dns_table_value' => 'Value',
'dns_table_ttl' => 'TTL',
'dns_warning_ttl' => 'If a TTL value of 86400 is not available, choose the highest available value. Domain propagation may take up to 12 hours.',
'dns_setup_title' => ':domain - DNS Setup',
'domain_status_valid' => 'Valid',
'domain_status_invalid' => 'Invalid',
'domain_no_redirects' => 'No redirects configured',
'domain_mapped_links' => 'Mapped to :count short link|Mapped to :count short links',
'action_delete_domain' => 'Delete Domain',
'delete_domain_confirmation_desc' => 'Are you sure you want to delete this custom domain? This action cannot be undone proceed with caution.',
'short_link_label' => 'Short Link',
];

View 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',
];

View File

@@ -1,16 +1,31 @@
<?php
return [
'navigation_label' => 'Krótkie linki',
'navigation_label' => 'Linki',
'navigation_group' => 'Narzędzia',
'resource_title' => 'Krótki URL',
'empty_state_heading' => 'Brak linków',
'empty_state_description' => 'Zacznij tworzyć krótkie linki dla swoich kampanii marketingowych, programów partnerskich i nie tylko.',
'empty_state_action' => 'Utwórz link',
'empty_state_pixel_heading' => 'Brak pikseli',
'empty_state_pixel_description' => 'Dodaj piksele śledzące z Meta, Google, TikTok i innych platform, aby kierować reklamy do użytkowników klikających Twoje linki.',
'empty_state_pixel_action' => 'Utwórz piksel',
// Form Tabs
'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_type' => 'Typ docelowy',
'destination_type_single' => 'Pojedynczy URL',
'destination_type_split' => 'Rotator / Test A/B',
'destination_url' => 'Docelowy URL',
'destination_url_helper' => 'Oryginalny adres URL, na który chcesz przekierować odwiedzających.',
'url_key' => 'Krótki klucz',
@@ -58,6 +73,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)',
@@ -98,8 +114,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',
@@ -115,11 +149,22 @@ return [
'action_qr' => 'Kod QR',
'action_edit' => 'Edytuj URL',
'action_delete' => 'Usuń URL',
'url_key_change_confirmation_heading' => 'Zastąpić link?',
'url_key_change_confirmation' => 'Zmieniłeś krótki klucz (Short Key) tego linku. Zapisanie tych zmian spowoduje, że dotychczasowy krótki URL przestanie działać, a wszelkie udostępnione wcześniej linki z poprzednim kluczem wygasną.',
'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',
@@ -128,6 +173,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',
@@ -145,6 +191,8 @@ return [
'stats_table_title' => 'Logi odwiedzin',
'stats_btn_back' => 'Wróć do listy',
'stats_btn_copy' => 'Kopiuj krótki URL',
'stats_btn_refresh' => 'Odśwież',
'stats_refresh_success' => 'Statystyki zostały zaktualizowane w czasie rzeczywistym!',
'stats_col_time' => 'Czas',
'stats_col_country' => 'Kraj',
'stats_col_device' => 'Urządzenie',
@@ -168,8 +216,15 @@ 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_lock_url_key' => 'Blokuj krótki klucz (Short Key)',
'settings_lock_url_key_helper' => 'Globalnie wyłącz możliwość edycji krótkiego klucza po utworzeniu linku.',
'settings_disable_default_domain' => 'Wyłącz domyślną domenę',
'settings_disable_default_domain_helper' => 'Ukryj domyślną domenę aplikacji na liście wyboru podczas tworzenia i edycji linków.',
'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',
@@ -184,12 +239,13 @@ 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="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm.75-11.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm0 2.5a.75.75 0 0 1 .75.75v4a.75.75 0 0 1-1.5 0v-4a.75.75 0 0 1 .75-.75Z"></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',
@@ -208,10 +264,13 @@ return [
'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="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm.75-11.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm0 2.5a.75.75 0 0 1 .75.75v4a.75.75 0 0 1-1.5 0v-4a.75.75 0 0 1 .75-.75Z"></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',
@@ -224,6 +283,15 @@ return [
'stats_csv_browser' => 'Przeglądarka',
'stats_csv_os' => 'System operacyjny',
'stats_csv_referer' => 'Źródło (Referer)',
'stats_csv_utm_source' => 'Źródło UTM (utm_source)',
'stats_csv_utm_medium' => 'Medium UTM (utm_medium)',
'stats_csv_utm_campaign' => 'Kampania UTM (utm_campaign)',
'stats_csv_utm_term' => 'Słowo kluczowe UTM (utm_term)',
'stats_csv_utm_content' => 'Treść UTM (utm_content)',
'stats_csv_variant' => 'Wariant A/B',
'stats_csv_qr_scan' => 'Skan QR',
'stats_csv_bot' => 'Bot',
'stats_csv_proxy' => 'Proxy/VPN',
// Bulk Actions Localization
'action_enable_selected' => 'Włącz zaznaczone',
@@ -247,9 +315,11 @@ return [
'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_breakdown_utm_source' => 'Źródło',
'stats_breakdown_utm_medium' => 'Medium',
'stats_breakdown_utm_campaign' => 'Kampania',
'stats_breakdown_utm_term' => 'Słowo kluczowe',
'stats_breakdown_utm_content' => 'Treść',
'stats_no_utm_data' => 'Brak danych o parametrach UTM.',
// New Targeting & Security Tab and fields
@@ -257,6 +327,16 @@ return [
'security_section_title' => 'Kontrola bezpieczeństwa',
'password' => 'Hasło dostępu',
'password_helper' => 'Wymagaj od odwiedzających wprowadzenia hasła przed przekierowaniem.',
'confirm_password' => 'Potwierdź hasło',
'new_password' => 'Nowe hasło',
'change_password' => 'Zmień hasło',
'remove_password' => 'Usuń hasło',
'password_status_active' => 'Ochrona hasłem jest włączona.',
'set_password' => 'Ustaw hasło',
'cancel' => 'Anuluj',
'confirm' => 'Potwierdź',
'password_required_error' => 'Hasło jest wymagane.',
'password_mismatch_error' => 'Hasła nie są identyczne.',
'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',
@@ -264,16 +344,44 @@ return [
'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.',
'rotation_variants' => 'Warianty rotacji A/B',
'rotation_variants_helper' => 'Zdefiniuj adresy URL oraz podział ruchu dla testów split A/B.',
'variant_label' => 'Etykieta wariantu',
'variant_url' => 'Docelowy URL wariantu',
'variant_weight' => 'Udział w ruchu (%)',
'safe_browsing_error' => 'Ten adres URL został oznaczony przez Google Safe Browsing jako niebezpieczny.',
// New Advanced Targeting Builder
'targeting_rules' => 'Reguły targetowania',
'add_filter' => 'Dodaj filtr',
'match' => 'Warunek dopasowania',
'match_or' => 'Dowolny z poniższych filtrów (OR)',
'match_and' => 'Wszystkie poniższe filtry (AND)',
'filter_device' => 'Urządzenie',
'filter_platform' => 'Platforma (System operacyjny)',
'filter_country' => 'Kraj',
'filter_language' => 'Język przeglądarki',
'direct_to_url' => 'Przekieruj na URL',
'select_devices' => 'Wybierz urządzenia',
'select_platforms' => 'Wybierz systemy operacyjne',
'select_countries' => 'Wybierz kraje',
'select_languages' => 'Wybierz języki przeglądarki',
'device_desktop_label' => 'Komputer stacjonarny',
'device_mobile_label' => 'Smartfon',
'device_tablet_label' => 'Tablet',
// New Settings Page Fields
'settings_tab_advanced' => 'Wydajność i bezpieczeństwo',
@@ -299,8 +407,9 @@ return [
// 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_geoip_stats_cache_ttl' => 'TTL cache statystyk panelu',
'settings_geoip_stats_cache_ttl_helper' => 'Liczba sekund buforowania wyników zapytań na stronie statystyk (zapobiega obciążeniu bazy danych). Domyślnie: 300 (5 minut).',
'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="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm.75-11.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm0 2.5a.75.75 0 0 1 .75.75v4a.75.75 0 0 1-1.5 0v-4a.75.75 0 0 1 .75-.75Z"></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',
@@ -314,6 +423,10 @@ return [
'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',
'settings_track_anonymize_ips' => 'Anonimizacja adresów IP (RODO/GDPR)',
'settings_track_anonymize_ips_helper' => 'Zastępuje ostatni oktet adresów IPv4 lub ostatnie 80 bitów adresów IPv6 zerami przed zapisaniem ich w bazie danych w celu zapewnienia zgodności z RODO/GDPR. Surowy adres IP jest nadal prywatnie haszowany (SHA-256), co gwarantuje poprawność zliczania unikalnych odwiedzin.',
'stats_filter_limit_exceeded' => 'Maksymalnie 5 filtrów może być nałożonych jednocześnie.',
// QR Defaults Tab
'settings_tab_qr' => 'Domyślne QR',
@@ -339,6 +452,8 @@ return [
'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',
@@ -350,10 +465,17 @@ return [
'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',
'webhook_helper_alert' => 'Po skonfigurowaniu adresu URL webhooka, przy każdym kliknięciu w ten skrócony link wyślemy żądanie HTTP POST w tle w czasie rzeczywistym. Przekazuje ono szczegółowe metadane wizyty w formacie JSON (klucz linku, adres IP, kraj, przeglądarka, system operacyjny, referer i parametry UTM).',
'webhook_show_payload' => 'Pokaż przykładowy payload JSON',
// 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',
@@ -368,4 +490,187 @@ return [
'api_key_name' => 'Nazwa klucza (Opis)',
'api_key' => 'Klucz API',
'active' => 'Aktywny',
'api_key_generated' => 'Wygenerowano klucz API',
'api_key_warning' => 'Skopiuj ten klucz teraz. Nie będzie możliwości ponownego jego wyświetlenia!',
// 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_card_qr_rate' => 'Konwersja kodów 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',
'weights_sum_error' => 'Suma udziałów w ruchu musi wynosić dokładnie 100%. Obecna suma: :sum%.',
'balance_weights' => 'Wyreguluj do 100%',
'url_key_locked_error' => 'Klucz URL jest zablokowany i nie może być zmieniony.',
'custom_domain_locked_error' => 'Domena niestandardowa jest zablokowana i nie może być zmieniona.',
// Stats View & Granularity & PoP Trends
'stats_prev_period' => 'poprzedni okres',
'stats_granularity_hourly' => 'Godzinowo',
'stats_granularity_daily' => 'Dziennie',
'stats_granularity_weekly' => 'Tygodniowo',
'stats_granularity_monthly' => 'Miesięcznie',
// Stats Security Widget
'stats_security_title' => 'Bezpieczeństwo i ruch botów',
'stats_security_real_users' => 'Prawdziwi użytkownicy',
'stats_security_bots' => 'Boty i skanery',
'stats_security_vpn_blocked' => 'Zablokowany ruch VPN/Proxy',
'stats_security_no_vpn_blocks' => 'Brak zablokowanych wejść z VPN',
'stats_security_bot_ratio' => 'Wskaźnik ruchu botów',
'stats_security_human_clicks' => 'kliknięć ludzi',
'stats_security_bot_clicks' => 'kliknięć botów',
'stats_security_proxy_clicks' => 'wykrytych VPN/Proxy',
// Visit Logs Column & Modal translation enhancements
'stats_col_link' => 'Link',
'stats_col_referrer' => 'Źródło (Referer)',
'stats_col_language' => 'Język',
'stats_col_utm_source' => 'Źródło UTM (utm_source)',
'stats_col_utm_medium' => 'Medium UTM (utm_medium)',
'stats_col_utm_campaign' => 'Kampania UTM (utm_campaign)',
'stats_col_utm_term' => 'Słowo kluczowe UTM (utm_term)',
'stats_col_utm_content' => 'Treść UTM (utm_content)',
'stats_col_variant' => 'Wariant A/B',
'stats_col_qr_scan' => 'Skan QR',
'stats_col_bot' => 'Bot',
'stats_col_proxy' => 'Proxy/VPN',
'stats_referer_direct' => '(bezpośrednie)',
'stats_device_desktop' => 'Komputer stacjonarny',
'stats_device_mobile' => 'Smartfon',
'stats_device_tablet' => 'Tablet',
'stats_device_bot' => 'Robot / Bot',
'stats_action_view_details' => 'Szczegóły',
'stats_modal_visit_details' => 'Szczegóły wizyty',
'stats_detail_location' => 'Lokalizacja',
'stats_detail_is_bot' => 'Czy bot?',
'stats_detail_is_proxy' => 'Czy Proxy/VPN?',
'stats_detail_is_qr' => 'Czy skan QR?',
'stats_detail_visited_at' => 'Czas wizyty',
'stats_yes' => 'Tak',
'stats_no' => 'Nie',
'add_url' => 'Dodaj URL',
'traffic_split' => 'Podział ruchu',
'delete_confirmation_desc' => 'Czy na pewno chcesz usunąć ten link? Usunięcie spowoduje bezpowrotne skasowanie wszystkich statystyk i analiz. Tej operacji nie można cofnąć postępuj ostrożnie.',
'delete_verification_label' => 'Aby zweryfikować, wpisz poniżej <strong>:short_url</strong>',
'delete_verification_error' => 'Wpisany adres URL się nie zgadza.',
// Custom Domains
'empty_state_domain_heading' => 'Brak własnych domen',
'empty_state_domain_description' => 'Dodaj własne domeny, aby skrócone linki były serwowane pod Twoją marką (np. links.acme.com) zamiast domyślnej domeny.',
'empty_state_domain_action' => 'Dodaj domenę',
'dns_configuration_guide' => 'Przewodnik konfiguracji DNS',
'dns_for_subdomains' => 'Dla subdomen (CNAME)',
'dns_for_root_domains' => 'Dla domen głównych (Rekord A)',
'copied' => 'Skopiowano!',
'domains_navigation_label' => 'Własne domeny',
'domain_resource_title' => 'Własna domena',
'domain_label' => 'Nazwa domeny',
'domain_status_active' => 'Aktywna',
'domain_status_verified' => 'DNS Zweryfikowany',
'domain_verification_status' => 'Status weryfikacji',
'action_verify_dns' => 'Zweryfikuj DNS',
'dns_verify_success' => 'Rekord DNS zweryfikowany pomyślnie!',
'dns_verify_fail' => 'Weryfikacja DNS nie powiodła się. Sprawdź konfigurację i spróbuj ponownie.',
'dns_instructions_title' => 'Instrukcje DNS',
'default_domain_label' => 'Domyślna domena aplikacji',
'dns_tab_a' => 'Rekord A (rekomendowany)',
'dns_tab_cname' => 'Rekord CNAME',
'dns_instructions_apex' => 'Aby skonfigurować domenę główną (apex) :domain, ustaw następujący rekord A u swojego dostawcy DNS:',
'dns_instructions_subdomain' => 'Aby skonfigurować subdomenę :domain, ustaw następujący rekord CNAME u swojego dostawcy DNS:',
'dns_table_type' => 'Typ',
'dns_table_name' => 'Nazwa',
'dns_table_value' => 'Wartość',
'dns_table_ttl' => 'TTL',
'dns_warning_ttl' => 'Jeśli wartość TTL 86400 nie jest dostępna, wybierz najwyższą dostępną wartość. Propagacja domeny może potrwać do 12 godzin.',
'dns_setup_title' => ':domain - Konfiguracja DNS',
'domain_status_valid' => 'Zweryfikowana',
'domain_status_invalid' => 'Niezweryfikowana',
'domain_no_redirects' => 'Brak przypisanych linków',
'domain_mapped_links' => 'Przypisano :count krótki link|Przypisano :count krótkie linki|Przypisano :count krótkich linków',
'action_delete_domain' => 'Usuń domenę',
'delete_domain_confirmation_desc' => 'Czy na pewno chcesz usunąć tę własną domenę? Ta akcja nie może zostać cofnięta postępuj ostrożnie.',
'short_link_label' => 'Krótki link',
];

View 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',
];

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

View 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') }}</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]) }}
</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') }}
</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') }}</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') }}</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') }}</span>
</p>
<p class="text-[11px] leading-relaxed opacity-95">
{{ __('filament-short-url::default.app_redirect_in_app_warning_desc') }}
</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>

View 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') }}</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') }}
</p>
<p class="text-sm text-neutral-400 dark:text-neutral-500 mt-1">
{{ __('filament-short-url::default.expired_description') }}
</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') }}</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>

View File

@@ -0,0 +1,159 @@
<x-dynamic-component
:component="$getFieldWrapperView()"
:field="$field"
>
<div
x-data="{
state: $wire.{{ $applyStateBindingModifiers("\$entangle('{$getTargetStatePath()}')") }},
get items() {
if (!this.state) return [];
if (Array.isArray(this.state)) {
return this.state;
}
return Object.entries(this.state).map(([key, value]) => ({
key,
...value
}));
},
get totalSum() {
return this.items.reduce((sum, item) => sum + parseInt(item.weight || 0), 0);
},
init() {
this.$watch('state', () => {
this.checkAndNormalize();
});
this.checkAndNormalize();
},
checkAndNormalize() {
const items = this.items;
if (items.length < 2) return;
const sum = items.reduce((s, i) => s + parseInt(i.weight || 0), 0);
if (sum !== 100) {
const m = items.length;
const base = Math.floor(100 / m);
const remainder = 100 % m;
// Re-balance equally using 1% resolution
items.forEach((item, index) => {
const newWeight = base + (index < remainder ? 1 : 0);
this.updateWeight(item, newWeight);
});
}
},
updateWeight(item, newWeight) {
if (Array.isArray(this.state)) {
const index = this.state.findIndex(i => i === item || (i.key && i.key === item.key));
if (index !== -1) {
this.state[index].weight = newWeight;
}
} else if (this.state && typeof this.state === 'object') {
if (this.state[item.key]) {
this.state[item.key].weight = newWeight;
}
}
},
startDrag(index, event) {
event.preventDefault();
const isTouch = event.type.startsWith('touch');
const startX = isTouch ? event.touches[0].clientX : event.clientX;
const rect = this.$refs.container.getBoundingClientRect();
const containerWidth = rect.width;
const containerLeft = rect.left;
if (!isTouch) {
document.body.style.cursor = 'col-resize';
}
// Get cumulative weights up to index
let sumBefore = 0;
const items = this.items;
for (let j = 0; j < index; j++) {
sumBefore += parseInt(items[j].weight || 0);
}
// Total sum of current and next segment
const segmentSum = parseInt(items[index].weight || 0) + parseInt(items[index+1].weight || 0);
const onMove = (e) => {
const currentX = e.type.startsWith('touch') ? e.touches[0].clientX : e.clientX;
const offsetPercent = ((currentX - containerLeft) / containerWidth) * 100;
const minWeight = 1;
const minPercent = sumBefore + minWeight;
const maxPercent = sumBefore + segmentSum - minWeight;
let targetPercent = Math.round(offsetPercent);
targetPercent = Math.max(minPercent, Math.min(maxPercent, targetPercent));
const newWeightCurrent = targetPercent - sumBefore;
const newWeightNext = segmentSum - newWeightCurrent;
this.updateWeight(items[index], newWeightCurrent);
this.updateWeight(items[index+1], newWeightNext);
};
const onEnd = () => {
if (!isTouch) {
document.body.style.cursor = '';
}
if (isTouch) {
window.removeEventListener('touchmove', onMove);
window.removeEventListener('touchend', onEnd);
} else {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onEnd);
}
};
if (isTouch) {
window.addEventListener('touchmove', onMove, { passive: true });
window.addEventListener('touchend', onEnd);
} else {
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onEnd);
}
}
}"
class="traffic-splitter-component mt-2"
>
<div
x-ref="container"
class="relative h-10 w-full select-none"
x-show="items.length >= 2"
>
<div class="absolute inset-0 flex h-full">
<template x-for="(item, index) in items" :key="item.key || index">
<!-- Segment container -->
<div
class="@container pointer-events-none relative flex h-full"
:style="'width: ' + item.weight + '%;'"
>
<!-- Left Spacer (only if not first item) -->
<div class="w-1.5" x-show="index > 0"></div>
<!-- Segment Box -->
<div class="flex h-full grow items-center justify-center gap-2 rounded-md border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 text-xs shadow-sm select-none">
<span class="text-xs font-semibold text-neutral-900 dark:text-neutral-100" x-text="index + 1"></span>
<span class="font-medium text-neutral-600 dark:text-neutral-400" x-show="item.weight >= 12" x-text="item.weight + '%'"></span>
</div>
<!-- Right Spacer (only if not last item) -->
<div class="w-1.5" x-show="index < items.length - 1"></div>
<!-- Drag Handle (visible only if not the last item) -->
<div
x-show="index < items.length - 1"
class="group pointer-events-auto absolute -right-1.5 flex h-full w-3 cursor-col-resize items-center px-1 z-30"
@mousedown.stop="startDrag(index, $event)"
@touchstart.stop="startDrag(index, $event)"
>
<div class="h-2/3 w-1 rounded-full bg-neutral-300 dark:bg-neutral-600 group-hover:bg-neutral-400 dark:group-hover:bg-neutral-400 group-active:bg-neutral-500 transition-colors duration-150"></div>
</div>
</div>
</template>
</div>
</div>
</div>
</x-dynamic-component>

View File

@@ -3,7 +3,7 @@
<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>
<title>{{ __('filament-short-url::default.password_title') }}</title>
<!-- Premium Google Fonts: Bricolage Grotesque -->
<link rel="preconnect" href="https://fonts.googleapis.com">
@@ -37,7 +37,7 @@
@php
$logoPath = function_exists('setting') ? setting('logo_path') : null;
$logoUrl = $logoPath ? \Illuminate\Support\Facades\Storage::disk('public')->url($logoPath) : null;
$siteName = config('app.name', 'Laravel');
$siteName = config('filament-short-url.site_name') ?: config('app.name', 'Laravel');
@endphp
{{-- Main Sign-in Box --}}
@@ -49,10 +49,10 @@
<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' }}
{{ __('filament-short-url::default.password_title') }}
</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.' }}
{{ __('filament-short-url::default.password_description') }}
</p>
</div>
@@ -63,7 +63,7 @@
<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' }}
{{ __('filament-short-url::default.password_placeholder') }}
</label>
<input
type="password"
@@ -85,7 +85,7 @@
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>
<span>{{ __('filament-short-url::default.password_btn_unlock') }}</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>

View File

@@ -1,17 +1,48 @@
<!DOCTYPE html>
<html lang="en" class="h-full">
<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>Connecting...</title>
<!-- Premium Fonts -->
<title>{{ __('filament-short-url::default.pixel_loading_title') }}</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=Outfit:wght@300;400;600&display=swap" rel="stylesheet">
<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(!empty($pixelMetaId))
@if($pixelMetaIds->isNotEmpty())
<script>
!function(f,b,e,v,n,t,s)
{if(f.fbq)return;n=f.fbq=function(){n.callMethod?
@@ -21,29 +52,37 @@
t.src=v;s=b.getElementsByTagName(e)[0];
s.parentNode.insertBefore(t,s)}(window, document,'script',
'https://connect.facebook.net/en_US/fbevents.js');
fbq('init', '{{ $pixelMetaId }}');
@foreach($pixelMetaIds as $id)
fbq('init', '{{ $id }}');
fbq('track', 'PageView');
@endforeach
</script>
<noscript><img height="1" width="1" style="display:none" src="https://www.facebook.com/tr?id={{ $pixelMetaId }}&ev=PageView&noscript=1" /></noscript>
@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(!empty($pixelGoogleId))
<script async src="https://www.googletagmanager.com/gtag/js?id={{ $pixelGoogleId }}"></script>
@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());
gtag('config', '{{ $pixelGoogleId }}');
@foreach($pixelGoogleIds as $id)
gtag('config', '{{ $id }}');
@endforeach
</script>
@endif
<!-- LinkedIn Insight -->
@if(!empty($pixelLinkedinId))
@if($pixelLinkedinIds->isNotEmpty())
<script type="text/javascript">
_linkedin_data_partner_id = "{{ $pixelLinkedinId }}";
window._linkedin_data_partner_ids = window._linkedin_data_partner_ids || [];
window._linkedin_data_partner_ids.push(_linkedin_data_partner_id);
@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=[]}
@@ -53,105 +92,87 @@
b.src = "https://snap.licdn.com/li.lms-analytics/insight.min.js";
s.parentNode.insertBefore(b, s);})(window.lintrk);
</script>
<noscript><img height="1" width="1" style="display:none;" alt="" src="https://px.ads.linkedin.com/collect/?pid={{ $pixelLinkedinId }}&fmt=gif" /></noscript>
@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
<!-- Premium Styling -->
<style>
body {
background: linear-gradient(135deg, #0f172a 0%, #1e1b4b 100%);
color: #f1f5f9;
font-family: 'Outfit', -apple-system, sans-serif;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
overflow: hidden;
}
<!-- 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
.container {
text-align: center;
padding: 2.5rem;
border-radius: 1.5rem;
background: rgba(255, 255, 255, 0.03);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
max-width: 400px;
width: 90%;
animation: fadeIn 0.6s ease-out;
}
.spinner {
position: relative;
width: 72px;
height: 72px;
margin: 0 auto 2rem;
}
.spinner-ring {
position: absolute;
width: 100%;
height: 100%;
border: 4px solid transparent;
border-top-color: #6366f1;
border-radius: 50%;
animation: spin 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
}
.spinner-ring:nth-child(2) {
animation-delay: -0.3s;
border-top-color: #a855f7;
}
.spinner-ring:nth-child(3) {
animation-delay: -0.6s;
border-top-color: #ec4899;
}
h1 {
font-size: 1.5rem;
font-weight: 600;
margin: 0 0 0.5rem;
background: linear-gradient(to right, #818cf8, #f472b6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: -0.025em;
}
p {
font-size: 0.95rem;
color: #94a3b8;
margin: 0;
font-weight: 300;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
<!-- 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>
<div class="container">
<div class="spinner">
<div class="spinner-ring"></div>
<div class="spinner-ring"></div>
<div class="spinner-ring"></div>
<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') }}
</p>
<p class="text-sm text-neutral-400 dark:text-neutral-500 mt-1">
{{ __('filament-short-url::default.pixel_loading_description') }}
</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>
<h1>Connecting Safely</h1>
<p>Securing connection & forwarding you now...</p>
</div>
<!-- Async non-blocking redirect -->
{{-- 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);

File diff suppressed because it is too large Load Diff

View File

@@ -20,6 +20,8 @@
$dateFrom = $this->filterData['date_from'] ?? null;
$dateTo = $this->filterData['date_to'] ?? null;
$isCustom = ($this->filterData['preset'] ?? '') === 'custom';
$activeFilters = $this->activeFilters ?? [];
$filtersHash = md5(json_encode($activeFilters));
@endphp
<div class="space-y-6">
@@ -31,33 +33,97 @@
</div>
</div>
@if (! empty($activeFilters))
<div class="flex flex-wrap items-center gap-2 p-3 bg-gray-50/50 dark:bg-white/5 border border-gray-100 dark:border-white/10 rounded-xl">
<span class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mr-1">Active Filters:</span>
@foreach ($activeFilters as $key => $value)
@php
$displayValue = $value;
if ($key === 'country_code') {
$translatedCountry = __('filament-short-url::countries.' . strtoupper($value));
if ($translatedCountry && $translatedCountry !== 'filament-short-url::countries.' . strtoupper($value)) {
$displayValue = $translatedCountry;
}
}
@endphp
<div class="flex items-center gap-1.5 px-3 py-1 bg-indigo-50 dark:bg-indigo-950/40 border border-indigo-100 dark:border-indigo-900/60 rounded-full text-xs font-semibold text-indigo-700 dark:text-indigo-300">
<span class="opacity-75 uppercase text-[10px] tracking-wider">{{ $key === 'country_code' ? 'country' : str_replace('_', ' ', $key) }}:</span>
<span>{{ $displayValue }}</span>
<button type="button"
wire:click="clearStatsFilter('{{ $key }}')"
class="hover:text-indigo-900 dark:hover:text-indigo-100 transition-colors focus:outline-none ml-1">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
@endforeach
<button type="button"
wire:click="clearAllStatsFilters"
class="text-xs font-medium text-gray-500 hover:text-indigo-600 dark:text-gray-400 dark:hover:text-indigo-400 transition-colors focus:outline-none ml-auto px-2.5 py-1 rounded-md hover:bg-gray-100 dark:hover:bg-white/5">
Clear all filters
</button>
</div>
@endif
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlStatsOverview::class, [
'record' => $record,
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
], key('stats-overview-' . $dateFrom . '-' . $dateTo))
'filters' => $activeFilters,
], key('stats-overview-' . $dateFrom . '-' . $dateTo . '-' . $filtersHash))
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div class="lg:col-span-2">
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsChart::class, [
'record' => $record,
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
], key('stats-chart-' . $dateFrom . '-' . $dateTo))
</div>
<div>
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsRightBreakdown::class, [
'record' => $record,
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
], key('stats-right-breakdown-' . $dateFrom . '-' . $dateTo))
</div>
</div>
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsBottomBreakdown::class, [
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsChart::class, [
'record' => $record,
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
], key('stats-bottom-breakdown-' . $dateFrom . '-' . $dateTo))
'filters' => $activeFilters,
], key('stats-chart-' . $dateFrom . '-' . $dateTo . '-' . $filtersHash))
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlGeoBreakdownWidget::class, [
'record' => $record,
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
'filters' => $activeFilters,
], key('stats-geo-breakdown-' . $dateFrom . '-' . $dateTo . '-' . $filtersHash))
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlDeviceBreakdownWidget::class, [
'record' => $record,
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
'filters' => $activeFilters,
], key('stats-device-breakdown-' . $dateFrom . '-' . $dateTo . '-' . $filtersHash))
</div>
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlWorldMapWidget::class, [
'record' => $record,
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
'filters' => $activeFilters,
], key('stats-world-map-' . $dateFrom . '-' . $dateTo . '-' . $filtersHash))
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlSourcesBreakdownWidget::class, [
'record' => $record,
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
'filters' => $activeFilters,
], key('stats-sources-breakdown-' . $dateFrom . '-' . $dateTo . '-' . $filtersHash))
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlSecurityBreakdownWidget::class, [
'record' => $record,
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
'filters' => $activeFilters,
], key('stats-security-breakdown-' . $dateFrom . '-' . $dateTo . '-' . $filtersHash))
</div>
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVariantsWidget::class, [
'record' => $record,
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
'filters' => $activeFilters,
], key('stats-variants-' . $dateFrom . '-' . $dateTo . '-' . $filtersHash))
</div>
</x-filament-panels::page>

View File

@@ -0,0 +1,247 @@
@php
$record = $getRecord();
$appHost = parse_url(config('app.url'), PHP_URL_HOST) ?: request()->getHost();
// Dynamically resolve target IP for A Record
$serverIp = null;
try {
$serverIp = gethostbyname($appHost);
if ($serverIp === $appHost) {
$serverIp = $_SERVER['SERVER_ADDR'] ?? null;
}
} catch (\Throwable $e) {
$serverIp = $_SERVER['SERVER_ADDR'] ?? null;
}
if (!$serverIp || $serverIp === '127.0.0.1') {
$serverIp = '76.76.21.21'; // Dummy IP for demonstration if local env has loopback
}
// Extract subdomain name
$domainParts = explode('.', $record->domain);
$isSubdomain = count($domainParts) > 2;
$subdomainName = $isSubdomain ? $domainParts[0] : '@';
$shortUrlsCount = $record->short_urls_count ?? 0;
$clicksCount = $record->total_clicks ?? 0;
@endphp
<div x-data="{ open: false, activeTab: 'A' }" class="w-full text-left">
<!-- Header / Collapsed Row -->
<div class="px-5 py-4 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<!-- Left Section: Icon & Info -->
<div class="flex items-center gap-3.5 min-w-0">
<!-- Globe Icon -->
<div class="flex items-center justify-center w-10 h-10 rounded-full border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-950/40 flex-shrink-0">
<svg class="w-5 h-5 text-neutral-500 dark:text-neutral-450" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-.778.099-1.533.284-2.253" />
</svg>
</div>
<div class="min-w-0">
<!-- Domain and Primary Badge -->
<div class="flex items-center gap-2">
<span class="text-[15px] font-bold text-neutral-800 dark:text-neutral-200 truncate leading-tight">
{{ $record->domain }}
</span>
@if($record->is_active)
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-blue-50/80 dark:bg-blue-950/30 text-blue-650 dark:text-blue-400 border border-blue-100/60 dark:border-blue-900/40">
Primary
</span>
@endif
</div>
<!-- Subtitle -->
<div class="text-[12px] text-neutral-450 dark:text-neutral-500 mt-1 flex items-center gap-1.5">
<span></span>
@if($shortUrlsCount === 0)
<span>No redirect configured</span>
@else
<span>Mapped to {{ $shortUrlsCount }} short {{ Str::plural('link', $shortUrlsCount) }}</span>
@endif
</div>
</div>
</div>
<!-- Middle Section: Clicks & Verification Status -->
<div class="flex items-center gap-3 sm:ml-auto">
<!-- Clicks Badge -->
<span class="inline-flex items-center gap-1.5 px-3 py-1 rounded-xl border border-neutral-200/60 dark:border-neutral-800 bg-neutral-50/50 dark:bg-neutral-900/50 text-[12px] font-semibold text-neutral-700 dark:text-neutral-300">
<svg class="w-3.5 h-3.5 text-neutral-450 dark:text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.042 21.672 13.684 16.6m0 0-2.51 2.225.569-9.47 5.227 7.917-3.286-.672ZM12 2.25V4.5m5.303.197-1.593 1.593M21.75 12H19.5m-.197 5.303-1.593-1.593M12 21.75V19.5m-5.303-.197 1.593-1.593M2.25 12H4.5m.197-5.303 1.593 1.593" />
</svg>
{{ $clicksCount }} clicks
</span>
<!-- Status Badge -->
@if($record->is_verified)
<span class="inline-flex items-center gap-1.5 px-3 py-1 rounded-xl bg-emerald-50 dark:bg-emerald-950/20 border border-emerald-150 dark:border-emerald-900/40 text-[12px] font-semibold text-emerald-650 dark:text-emerald-450">
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12c0 1.268-.63 2.39-1.593 3.068a3.745 3.745 0 0 1-1.043 3.296 3.745 3.745 0 0 1-3.296 1.043A3.745 3.745 0 0 1 12 21c-1.268 0-2.39-.63-3.068-1.593a3.746 3.746 0 0 1-3.296-1.043 3.745 3.745 0 0 1-1.043-3.296A3.745 3.745 0 0 1 3 12c0-1.268.63-2.39 1.593-3.068a3.745 3.745 0 0 1 1.043-3.296 3.746 3.746 0 0 1 3.296-1.043A3.746 3.746 0 0 1 12 3c1.268 0 2.39.63 3.068 1.593a3.746 3.746 0 0 1 3.296 1.043 3.746 3.746 0 0 1 1.043 3.296A3.745 3.745 0 0 1 21 12Z" />
</svg>
Valid
</span>
@else
<span class="inline-flex items-center gap-1.5 px-3 py-1 rounded-xl bg-red-50 dark:bg-red-950/20 border border-red-150 dark:border-red-900/40 text-[12px] font-semibold text-red-650 dark:text-red-400">
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z" />
</svg>
Invalid
</span>
@endif
</div>
<!-- Right Section: Actions Buttons -->
<div class="flex items-center gap-2">
<!-- Settings Toggle Button with red indicator when invalid -->
<button @click="open = !open" type="button" class="relative inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold rounded-lg border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-50 dark:hover:bg-neutral-950 transition-all shadow-sm focus:outline-none cursor-pointer">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.43l-1.003.828c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.43l1.004-.827c.292-.24.437-.613.43-.991a6.936 6.936 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.33-.183.58-.495.643-.869L9.594 3.94Z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
<!-- Live Red Dot Indicator -->
@if(!$record->is_verified)
<span class="absolute top-0.5 right-0.5 flex h-2 w-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-red-500"></span>
</span>
@endif
<!-- Chevron -->
<svg class="w-3 h-3 text-neutral-400 transition-transform duration-200" :class="open ? 'rotate-180' : ''" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</button>
<!-- Actions Dropdown -->
<div x-data="{ dropdownOpen: false }" class="relative">
<button @click="dropdownOpen = !dropdownOpen" type="button" class="inline-flex items-center justify-center w-8 h-8 rounded-lg border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-950 transition-all shadow-sm focus:outline-none cursor-pointer">
<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="M12 6.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5ZM12 12.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5ZM12 18.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5Z" />
</svg>
</button>
<div x-show="dropdownOpen" @click.away="dropdownOpen = false" x-cloak class="absolute right-0 mt-2 w-48 rounded-lg shadow-lg bg-white dark:bg-neutral-850 border border-neutral-200/80 dark:border-neutral-800 z-50 py-1.5 text-left transition-all">
<!-- Verify DNS / Refresh -->
<button @click="$wire.callTableAction('verify', {{ $record->id }}); dropdownOpen = false" type="button" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-900 focus:outline-none cursor-pointer">
<svg class="w-4 h-4 text-neutral-450 dark:text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
Verify / Refresh DNS
</button>
<!-- Edit Action -->
<button @click="$wire.mountTableAction('edit', {{ $record->id }}); dropdownOpen = false" type="button" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-900 focus:outline-none cursor-pointer">
<svg class="w-4 h-4 text-neutral-450 dark:text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" />
</svg>
Edit Domain
</button>
<hr class="border-neutral-100 dark:border-neutral-800 my-1">
<!-- Delete Action -->
<button @click="$wire.mountTableAction('delete', {{ $record->id }}); dropdownOpen = false" type="button" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-red-655 dark:text-red-400 hover:bg-neutral-50 dark:hover:bg-neutral-900 focus:outline-none cursor-pointer">
<svg class="w-4 h-4 text-red-450 dark:text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
</svg>
Delete Domain
</button>
</div>
</div>
</div>
</div>
<!-- Expanded DNS Panel -->
<div x-show="open" x-collapse x-cloak class="border-t border-neutral-100 dark:border-neutral-800/80 px-6 py-5 bg-neutral-50/20 dark:bg-neutral-950/5">
<div class="pt-2">
<!-- Tabs Menu -->
<div class="-ml-1.5 border-b border-neutral-200 dark:border-neutral-800">
<div class="flex text-sm">
<!-- Tab A -->
<div class="relative">
<button @click="activeTab = 'A'" type="button" class="p-4 transition-colors duration-75 text-neutral-400 hover:text-neutral-700 dark:text-neutral-500 dark:hover:text-neutral-300 font-medium focus:outline-none cursor-pointer" :class="activeTab === 'A' ? 'text-neutral-900 dark:text-white font-semibold' : ''">
A Record (recommended)
</button>
<div x-show="activeTab === 'A'" class="absolute bottom-0 w-full px-1.5 text-neutral-900 dark:text-white">
<div class="h-0.5 rounded-t-full bg-current"></div>
</div>
</div>
<!-- Tab CNAME -->
<div class="relative">
<button @click="activeTab = 'CNAME'" type="button" class="p-4 transition-colors duration-75 text-neutral-400 hover:text-neutral-700 dark:text-neutral-500 dark:hover:text-neutral-300 font-medium focus:outline-none cursor-pointer" :class="activeTab === 'CNAME' ? 'text-neutral-900 dark:text-white font-semibold' : ''">
CNAME Record
</button>
<div x-show="activeTab === 'CNAME'" x-cloak class="absolute bottom-0 w-full px-1.5 text-neutral-900 dark:text-white">
<div class="h-0.5 rounded-t-full bg-current"></div>
</div>
</div>
</div>
</div>
<!-- Tab Content -->
<div class="mt-3 text-left text-neutral-600">
<!-- Instruction Paragraph -->
<div class="my-5">
<p class="prose-sm prose-code:rounded-md prose-code:bg-neutral-100 dark:prose-code:bg-neutral-800 prose-code:p-1 prose-code:text-[.8125rem] prose-code:font-medium prose-code:font-mono prose-code:text-neutral-900 dark:prose-code:text-neutral-150 max-w-none">
To configure your <span x-text="activeTab === 'A' ? 'apex domain' : 'subdomain'"></span> <code>{{ $record->domain }}</code>, set the following <span x-text="activeTab === 'A' ? 'A record' : 'CNAME record'"></span> on your DNS provider:
</p>
</div>
<!-- Parameters Table (CSS Grid format) -->
<div class="scrollbar-hide grid items-end gap-x-10 gap-y-1 overflow-x-auto rounded-lg bg-neutral-100/80 dark:bg-neutral-950/40 p-4 text-sm grid-cols-[repeat(4,max-content)]">
<!-- Table Headers -->
<p class="font-medium text-neutral-950 dark:text-white">Type</p>
<p class="font-medium text-neutral-950 dark:text-white">Name</p>
<p class="font-medium text-neutral-950 dark:text-white">Value</p>
<p class="font-medium text-neutral-950 dark:text-white">TTL</p>
<!-- Tab A content -->
<div x-show="activeTab === 'A'" class="contents">
<p class="font-mono text-neutral-600 dark:text-neutral-400">A</p>
<p class="font-mono text-neutral-600 dark:text-neutral-400">@</p>
<p class="flex items-center gap-1 font-mono text-neutral-900 dark:text-white" x-data="{ copied: false }">
<span>{{ $serverIp }}</span>
<button @click="navigator.clipboard.writeText('{{ $serverIp }}'); copied = true; setTimeout(() => copied = false, 2000)" class="relative group rounded-full p-1.5 transition-all duration-75 bg-transparent hover:bg-neutral-200 dark:hover:bg-neutral-800 active:bg-neutral-300 dark:active:bg-neutral-700 -mb-0.5 cursor-pointer focus:outline-none" type="button">
<span class="sr-only">Copy</span>
<svg x-show="!copied" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" width="14" height="14" class="h-3.5 w-3.5 text-neutral-450 dark:text-neutral-500">
<path d="M8 17.929H6c-1.105 0-2-.912-2-2.036V5.036C4 3.91 4.895 3 6 3h8c1.105 0 2 .911 2 2.036v1.866m-6 .17h8c1.105 0 2 .91 2 2.035v10.857C20 21.09 19.105 22 18 22h-8c-1.105 0-2-.911-2-2.036V9.107c0-1.124.895-2.036 2-2.036z"></path>
</svg>
<svg x-show="copied" x-cloak class="h-3.5 w-3.5 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
</button>
</p>
<p class="font-mono text-neutral-600 dark:text-neutral-400">86400</p>
</div>
<!-- Tab CNAME content -->
<div x-show="activeTab === 'CNAME'" x-cloak class="contents">
<p class="font-mono text-neutral-600 dark:text-neutral-400">CNAME</p>
<p class="font-mono text-neutral-600 dark:text-neutral-400">{{ $subdomainName }}</p>
<p class="flex items-center gap-1 font-mono text-neutral-900 dark:text-white" x-data="{ copied: false }">
<span>{{ $appHost }}</span>
<button @click="navigator.clipboard.writeText('{{ $appHost }}'); copied = true; setTimeout(() => copied = false, 2000)" class="relative group rounded-full p-1.5 transition-all duration-75 bg-transparent hover:bg-neutral-200 dark:hover:bg-neutral-800 active:bg-neutral-300 dark:active:bg-neutral-700 -mb-0.5 cursor-pointer focus:outline-none" type="button">
<span class="sr-only">Copy</span>
<svg x-show="!copied" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" width="14" height="14" class="h-3.5 w-3.5 text-neutral-450 dark:text-neutral-500">
<path d="M8 17.929H6c-1.105 0-2-.912-2-2.036V5.036C4 3.91 4.895 3 6 3h8c1.105 0 2 .911 2 2.036v1.866m-6 .17h8c1.105 0 2 .91 2 2.035v10.857C20 21.09 19.105 22 18 22h-8c-1.105 0-2-.911-2-2.036V9.107c0-1.124.895-2.036 2-2.036z"></path>
</svg>
<svg x-show="copied" x-cloak class="h-3.5 w-3.5 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
</button>
</p>
<p class="font-mono text-neutral-600 dark:text-neutral-400">86400</p>
</div>
</div>
<!-- Info Alert Block -->
<div class="mt-4 flex items-center gap-2 rounded-lg p-3 bg-indigo-50 dark:bg-indigo-950/20 text-indigo-650 dark:text-indigo-400 border border-indigo-100/30 dark:border-indigo-900/30">
<svg height="18" width="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0">
<g fill="currentColor">
<circle cx="9" cy="9" fill="none" r="7.25" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></circle>
<line fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" x1="9" x2="9" y1="12.819" y2="8.25"></line>
<path d="M9,6.75c-.552,0-1-.449-1-1s.448-1,1-1,1,.449,1,1-.448,1-1,1Z" fill="currentColor" stroke="none"></path>
</g>
</svg>
<p class="prose-sm prose-code:rounded-md prose-code:bg-neutral-100 dark:prose-code:bg-neutral-800 prose-code:p-1 prose-code:text-[.8125rem] prose-code:font-medium prose-code:font-mono prose-code:text-neutral-900 dark:prose-code:text-neutral-150 max-w-none">
If a TTL value of 86400 is not available, choose the highest available value. Domain propagation may take up to 12 hours.
</p>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,22 @@
<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>
@if(($record->destination_type ?? 'single') === 'split')
<span class="truncate max-w-[50ch] text-[#273144] dark:text-gray-300 text-[15px] leading-[16px] font-semibold italic">
{{ __('filament-short-url::default.destination_type_split') }}
</span>
@else
<span onclick="event.preventDefault(); event.stopPropagation(); window.open('{{ e($record->destination_url) }}', '_blank', 'noopener,noreferrer');"
class="truncate max-w-[50ch] hover:underline text-[#273144] dark:text-gray-350 text-[15px] leading-[16px] font-medium cursor-pointer"
title="{{ e($record->destination_url) }}">
{{ $record->destination_url }}
</span>
@endif
</div>

View File

@@ -0,0 +1,122 @@
@php
$appHost = parse_url(config('app.url'), PHP_URL_HOST) ?: request()->getHost();
// Dynamically resolve target IP for A Record
$serverIp = null;
try {
$serverIp = gethostbyname($appHost);
if ($serverIp === $appHost) {
$serverIp = $_SERVER['SERVER_ADDR'] ?? null;
}
} catch (\Throwable $e) {
$serverIp = $_SERVER['SERVER_ADDR'] ?? null;
}
if (!$serverIp || $serverIp === '127.0.0.1') {
$serverIp = '76.76.21.21'; // Dummy IP for demonstration
}
// Extract subdomain name
$domainParts = explode('.', $record->domain);
$isSubdomain = count($domainParts) > 2;
$subdomainName = $isSubdomain ? $domainParts[0] : '@';
@endphp
<div x-data="{ activeTab: 'A' }" class="p-6 bg-neutral-50/50 dark:bg-neutral-950/20 border-t border-neutral-100 dark:border-neutral-800 text-left">
<!-- Tabs Menu -->
<div class="-ml-1.5 border-b border-neutral-200 dark:border-neutral-800">
<div class="flex text-sm">
<!-- Tab A -->
<div class="relative">
<button @click="activeTab = 'A'" type="button" class="p-4 transition-colors duration-75 text-neutral-400 hover:text-neutral-700 dark:text-neutral-500 dark:hover:text-neutral-300 font-medium focus:outline-none cursor-pointer" :class="activeTab === 'A' ? 'text-neutral-900 dark:text-white font-semibold' : ''">
{{ __('filament-short-url::default.dns_tab_a') }}
</button>
<div x-show="activeTab === 'A'" class="absolute bottom-0 w-full px-1.5 text-neutral-900 dark:text-white">
<div class="h-0.5 rounded-t-full bg-current"></div>
</div>
</div>
<!-- Tab CNAME -->
<div class="relative">
<button @click="activeTab = 'CNAME'" type="button" class="p-4 transition-colors duration-75 text-neutral-400 hover:text-neutral-700 dark:text-neutral-500 dark:hover:text-neutral-300 font-medium focus:outline-none cursor-pointer" :class="activeTab === 'CNAME' ? 'text-neutral-900 dark:text-white font-semibold' : ''">
{{ __('filament-short-url::default.dns_tab_cname') }}
</button>
<div x-show="activeTab === 'CNAME'" x-cloak class="absolute bottom-0 w-full px-1.5 text-neutral-900 dark:text-white">
<div class="h-0.5 rounded-t-full bg-current"></div>
</div>
</div>
</div>
</div>
<!-- Tab Content -->
<div class="mt-3 text-left text-neutral-600">
<!-- Instruction Paragraph -->
<div class="my-5">
<p x-show="activeTab === 'A'" class="prose-sm prose-code:rounded-md prose-code:bg-neutral-100 dark:prose-code:bg-neutral-800 prose-code:p-1 prose-code:text-[.8125rem] prose-code:font-medium prose-code:font-mono prose-code:text-neutral-900 dark:prose-code:text-neutral-150 max-w-none">
{!! __('filament-short-url::default.dns_instructions_apex', ['domain' => '<code>' . e($record->domain) . '</code>']) !!}
</p>
<p x-show="activeTab === 'CNAME'" x-cloak class="prose-sm prose-code:rounded-md prose-code:bg-neutral-100 dark:prose-code:bg-neutral-800 prose-code:p-1 prose-code:text-[.8125rem] prose-code:font-medium prose-code:font-mono prose-code:text-neutral-900 dark:prose-code:text-neutral-150 max-w-none">
{!! __('filament-short-url::default.dns_instructions_subdomain', ['domain' => '<code>' . e($record->domain) . '</code>']) !!}
</p>
</div>
<!-- Parameters Table (CSS Grid format) -->
<div class="scrollbar-hide grid items-end gap-x-10 gap-y-1 overflow-x-auto rounded-lg bg-neutral-100/80 dark:bg-neutral-950/40 p-4 text-sm grid-cols-[repeat(4,max-content)]">
<!-- Table Headers -->
<p class="font-medium text-neutral-950 dark:text-white">{{ __('filament-short-url::default.dns_table_type') }}</p>
<p class="font-medium text-neutral-950 dark:text-white">{{ __('filament-short-url::default.dns_table_name') }}</p>
<p class="font-medium text-neutral-950 dark:text-white">{{ __('filament-short-url::default.dns_table_value') }}</p>
<p class="font-medium text-neutral-950 dark:text-white">{{ __('filament-short-url::default.dns_table_ttl') }}</p>
<!-- Tab A content -->
<div x-show="activeTab === 'A'" class="contents">
<p class="font-mono text-neutral-600 dark:text-neutral-400">A</p>
<p class="font-mono text-neutral-600 dark:text-neutral-400">@</p>
<p class="flex items-end gap-1 font-mono text-neutral-900 dark:text-white" x-data="{ copied: false }">
<span>{{ $serverIp }}</span>
<button @click="navigator.clipboard.writeText('{{ $serverIp }}'); copied = true; setTimeout(() => copied = false, 2000)" class="relative group rounded-full p-1.5 transition-all duration-75 bg-transparent hover:bg-neutral-100 dark:hover:bg-neutral-800 active:bg-neutral-200 dark:active:bg-neutral-700 -mb-0.5 cursor-pointer focus:outline-none" type="button">
<span class="sr-only">Copy</span>
<svg x-show="!copied" fill="none" shape-rendering="geometricPrecision" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" width="14" height="14" class="h-3.5 w-3.5 text-neutral-400 dark:text-neutral-500">
<path d="M8 17.929H6c-1.105 0-2-.912-2-2.036V5.036C4 3.91 4.895 3 6 3h8c1.105 0 2 .911 2 2.036v1.866m-6 .17h8c1.105 0 2 .91 2 2.035v10.857C20 21.09 19.105 22 18 22h-8c-1.105 0-2-.911-2-2.036V9.107c0-1.124.895-2.036 2-2.036z"></path>
</svg>
<svg x-show="copied" x-cloak class="h-3.5 w-3.5 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
</button>
</p>
<p class="font-mono text-neutral-600 dark:text-neutral-400">86400</p>
</div>
<!-- Tab CNAME content -->
<div x-show="activeTab === 'CNAME'" x-cloak class="contents">
<p class="font-mono text-neutral-600 dark:text-neutral-400">CNAME</p>
<p class="font-mono text-neutral-600 dark:text-neutral-400">{{ $subdomainName }}</p>
<p class="flex items-end gap-1 font-mono text-neutral-900 dark:text-white" x-data="{ copied: false }">
<span>{{ $appHost }}</span>
<button @click="navigator.clipboard.writeText('{{ $appHost }}'); copied = true; setTimeout(() => copied = false, 2000)" class="relative group rounded-full p-1.5 transition-all duration-75 bg-transparent hover:bg-neutral-100 dark:hover:bg-neutral-800 active:bg-neutral-200 dark:active:bg-neutral-700 -mb-0.5 cursor-pointer focus:outline-none" type="button">
<span class="sr-only">Copy</span>
<svg x-show="!copied" fill="none" shape-rendering="geometricPrecision" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" width="14" height="14" class="h-3.5 w-3.5 text-neutral-400 dark:text-neutral-500">
<path d="M8 17.929H6c-1.105 0-2-.912-2-2.036V5.036C4 3.91 4.895 3 6 3h8c1.105 0 2 .911 2 2.036v1.866m-6 .17h8c1.105 0 2 .91 2 2.035v10.857C20 21.09 19.105 22 18 22h-8c-1.105 0-2-.911-2-2.036V9.107c0-1.124.895-2.036 2-2.036z"></path>
</svg>
<svg x-show="copied" x-cloak class="h-3.5 w-3.5 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
</button>
</p>
<p class="font-mono text-neutral-600 dark:text-neutral-400">86400</p>
</div>
</div>
<!-- Warning Alert Block -->
<div class="mt-4 flex items-center gap-2 rounded-lg p-3 bg-orange-50 dark:bg-orange-950/20 text-orange-600 dark:text-orange-400 border border-orange-100/30 dark:border-orange-900/30">
<svg height="18" width="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0">
<g fill="currentColor">
<circle cx="9" cy="9" fill="none" r="7.25" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></circle>
<line fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" x1="9" x2="9" y1="12.819" y2="8.25"></line>
<path d="M9,6.75c-.552,0-1-.449-1-1s.448-1,1-1,1,.449,1,1-.448,1-1,1Z" fill="currentColor" stroke="none"></path>
</g>
</svg>
<p class="prose-sm prose-code:rounded-md prose-code:bg-neutral-100 dark:prose-code:bg-neutral-800 prose-code:p-1 prose-code:text-[.8125rem] prose-code:font-medium prose-code:font-mono prose-code:text-neutral-900 dark:prose-code:text-neutral-150 max-w-none">
{{ __('filament-short-url::default.dns_warning_ttl') }}
</p>
</div>
</div>
</div>

View File

@@ -0,0 +1,38 @@
@php
$shortUrlsCount = $record->short_urls_count ?? $record->shortUrls()->count();
@endphp
<div class="flex items-center gap-3.5 min-w-0 py-2">
<!-- Globe Icon -->
<div class="flex items-center justify-center w-10 h-10 rounded-full border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-950/40 flex-shrink-0">
<svg class="w-5 h-5 text-neutral-500 dark:text-neutral-450" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-.778.099-1.533.284-2.253" />
</svg>
</div>
<div class="min-w-0">
<!-- Domain -->
<div class="flex items-center gap-2">
<span class="text-[15px] font-bold text-neutral-800 dark:text-neutral-200 truncate leading-tight">
{{ $record->domain }}
</span>
</div>
<!-- Subtitle -->
<div class="text-[12px] text-neutral-450 dark:text-neutral-500 mt-1 flex items-center gap-1.5">
<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>
@if($shortUrlsCount === 0)
<span>{{ __('filament-short-url::default.domain_no_redirects') }}</span>
@else
<span>{{ trans_choice('filament-short-url::default.domain_mapped_links', $shortUrlsCount, ['count' => $shortUrlsCount]) }}</span>
@endif
</div>
</div>
</div>

View File

@@ -0,0 +1,67 @@
<div class="flex flex-col items-center justify-center gap-6 px-4 py-10 md:min-h-[550px]">
<!-- Custom Domain Visual / Logo Grid -->
<div class="relative flex items-center justify-center h-28 w-full max-w-xs md:max-w-md overflow-hidden rounded-2xl border border-neutral-200/60 dark:border-neutral-800 bg-neutral-50/50 dark:bg-neutral-900/30 p-6 shadow-inner">
<div class="absolute inset-0 bg-[linear-gradient(to_right,#8080800a_1px,transparent_1px),linear-gradient(to_bottom,#8080800a_1px,transparent_1px)] bg-[size:14px_24px] [mask-image:radial-gradient(ellipse_60%_50%_at_50%_0%,#000_70%,transparent_100%)]"></div>
<div class="relative flex items-center gap-4 z-10">
<!-- Icon 1: Domain Globe -->
<div class="relative flex items-center justify-center w-12 h-12 rounded-xl bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 shadow-sm transition-transform duration-300 hover:-translate-y-1">
<!-- Ping green dot in the corner to show activity -->
<span class="absolute -top-1 -right-1 flex h-2.5 w-2.5">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500"></span>
</span>
<svg class="w-6 h-6 text-neutral-600 dark:text-neutral-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-.778.099-1.533.284-2.253" />
</svg>
</div>
<!-- Connection Line with flowing green dash animation -->
<div class="flex items-center justify-center w-12">
<svg class="w-full h-2" viewBox="0 0 48 8" fill="none">
<!-- Base grey dashes -->
<line x1="0" y1="4" x2="48" y2="4" stroke="#e5e5e5" class="dark:stroke-neutral-800" stroke-width="2" stroke-linecap="round" stroke-dasharray="4 4" />
<!-- Green flowing dashes on top -->
<line x1="0" y1="4" x2="48" y2="4" stroke="#10b981" stroke-width="2.5" stroke-linecap="round" stroke-dasharray="4 8" style="animation: fsu-flow-green 1.4s linear infinite;" />
</svg>
<style>
@keyframes fsu-flow-green {
to {
stroke-dashoffset: -12;
}
}
</style>
</div>
<!-- Icon 2: Verification Badge -->
<div class="relative flex items-center justify-center w-12 h-12 rounded-xl bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 shadow-sm transition-transform duration-300 hover:-translate-y-1">
<svg class="w-6 h-6 text-emerald-500 dark:text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12c0 1.268-.63 2.39-1.593 3.068a3.745 3.745 0 0 1-1.043 3.296 3.745 3.745 0 0 1-3.296 1.043A3.745 3.745 0 0 1 12 21c-1.268 0-2.39-.63-3.068-1.593a3.746 3.746 0 0 1-3.296-1.043 3.745 3.745 0 0 1-1.043-3.296A3.745 3.745 0 0 1 3 12c0-1.268.63-2.39 1.593-3.068a3.745 3.745 0 0 1 1.043-3.296 3.746 3.746 0 0 1 3.296-1.043A3.746 3.746 0 0 1 12 3c1.268 0 2.39.63 3.068 1.593a3.746 3.746 0 0 1 3.296 1.043 3.746 3.746 0 0 1 1.043 3.296A3.745 3.745 0 0 1 21 12Z" />
</svg>
</div>
</div>
</div>
<!-- Title and Description -->
<div class="max-w-md text-pretty text-center px-4">
<span class="text-base font-semibold text-neutral-900 dark:text-neutral-100">
{{ __('filament-short-url::default.empty_state_domain_heading') }}
</span>
<div class="mt-2 text-pretty text-sm text-neutral-500 dark:text-neutral-400">
{{ __('filament-short-url::default.empty_state_domain_description') }}
</div>
</div>
<!-- Create Button -->
@if(!isset($hideCreateButton) || !$hideCreateButton)
<div class="flex items-center gap-2">
<button type="button"
x-on:click="$wire.mountAction('create')"
class="group flex h-10 items-center justify-center gap-2 whitespace-nowrap rounded-lg border border-black bg-black text-white hover:bg-neutral-800 dark:border-white dark:bg-white dark:text-black dark:hover:bg-neutral-100 hover:ring-4 hover:ring-neutral-200 dark:hover:ring-neutral-800/50 px-4 text-sm font-semibold transition-all cursor-pointer">
<svg class="size-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
<span>{{ __('filament-short-url::default.empty_state_domain_action') }}</span>
</button>
</div>
@endif
</div>

View File

@@ -0,0 +1,173 @@
<div class="flex flex-col items-center justify-center gap-6 px-4 py-10 md:min-h-[500px]">
<style>
@keyframes infinite-scroll-y {
0% {
transform: translateY(0);
}
100% {
transform: translateY(-50%);
}
}
.animate-infinite-scroll-y {
animation: infinite-scroll-y 22s linear infinite;
}
.animate-infinite-scroll-y:hover {
animation-play-state: paused;
}
</style>
<!-- Scrolling Mock Pixels Container -->
<div class="animate-fade-in h-40 w-full max-w-xs md:max-w-sm overflow-hidden px-4 [mask-image:linear-gradient(transparent,black_15%,black_85%,transparent)] select-none pointer-events-auto">
<div class="animate-infinite-scroll-y flex flex-col">
<!-- Set 1 -->
<!-- Card 1: Meta -->
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
<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/facebook.com.ico"
class="w-full h-full object-contain"
onerror="this.src='https://heroicons.com/24/outline/funnel.svg'" />
</div>
<div class="min-w-0 flex-1 text-left">
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">Meta Ads Pixel</div>
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-mono truncate whitespace-nowrap mt-0.5">ID: 9876543210</div>
</div>
<span class="text-[10px] font-bold bg-blue-50 dark:bg-blue-950/30 text-blue-600 dark:text-blue-400 px-2.5 py-0.5 rounded-full ml-auto flex-shrink-0">
Meta
</span>
</div>
<!-- Card 2: Google -->
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
<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/google.com.ico"
class="w-full h-full object-contain"
onerror="this.src='https://heroicons.com/24/outline/funnel.svg'" />
</div>
<div class="min-w-0 flex-1 text-left">
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">Google Analytics 4</div>
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-mono truncate whitespace-nowrap mt-0.5">ID: G-9X8Y7Z6W5V</div>
</div>
<span class="text-[10px] font-bold bg-amber-50 dark:bg-amber-950/30 text-amber-600 dark:text-amber-400 px-2.5 py-0.5 rounded-full ml-auto flex-shrink-0">
Google
</span>
</div>
<!-- Card 3: LinkedIn -->
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
<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/linkedin.com.ico"
class="w-full h-full object-contain"
onerror="this.src='https://heroicons.com/24/outline/funnel.svg'" />
</div>
<div class="min-w-0 flex-1 text-left">
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">LinkedIn Insight Tag</div>
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-mono truncate whitespace-nowrap mt-0.5">ID: 1029384756</div>
</div>
<span class="text-[10px] font-bold bg-indigo-50 dark:bg-indigo-950/30 text-indigo-600 dark:text-indigo-400 px-2.5 py-0.5 rounded-full ml-auto flex-shrink-0">
LinkedIn
</span>
</div>
<!-- Card 4: TikTok -->
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
<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/tiktok.com.ico"
class="w-full h-full object-contain"
onerror="this.src='https://heroicons.com/24/outline/funnel.svg'" />
</div>
<div class="min-w-0 flex-1 text-left">
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">TikTok Pixel</div>
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-mono truncate whitespace-nowrap mt-0.5">ID: C1234567890</div>
</div>
<span class="text-[10px] font-bold bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 px-2.5 py-0.5 rounded-full ml-auto flex-shrink-0">
TikTok
</span>
</div>
<!-- Set 2 (Duplicated for infinite looping) -->
<!-- Card 1: Meta -->
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
<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/facebook.com.ico"
class="w-full h-full object-contain"
onerror="this.src='https://heroicons.com/24/outline/funnel.svg'" />
</div>
<div class="min-w-0 flex-1 text-left">
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">Meta Ads Pixel</div>
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-mono truncate whitespace-nowrap mt-0.5">ID: 9876543210</div>
</div>
<span class="text-[10px] font-bold bg-blue-50 dark:bg-blue-950/30 text-blue-600 dark:text-blue-400 px-2.5 py-0.5 rounded-full ml-auto flex-shrink-0">
Meta
</span>
</div>
<!-- Card 2: Google -->
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
<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/google.com.ico"
class="w-full h-full object-contain"
onerror="this.src='https://heroicons.com/24/outline/funnel.svg'" />
</div>
<div class="min-w-0 flex-1 text-left">
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">Google Analytics 4</div>
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-mono truncate whitespace-nowrap mt-0.5">ID: G-9X8Y7Z6W5V</div>
</div>
<span class="text-[10px] font-bold bg-amber-50 dark:bg-amber-950/30 text-amber-600 dark:text-amber-400 px-2.5 py-0.5 rounded-full ml-auto flex-shrink-0">
Google
</span>
</div>
<!-- Card 3: LinkedIn -->
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
<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/linkedin.com.ico"
class="w-full h-full object-contain"
onerror="this.src='https://heroicons.com/24/outline/funnel.svg'" />
</div>
<div class="min-w-0 flex-1 text-left">
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">LinkedIn Insight Tag</div>
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-mono truncate whitespace-nowrap mt-0.5">ID: 1029384756</div>
</div>
<span class="text-[10px] font-bold bg-indigo-50 dark:bg-indigo-950/30 text-indigo-600 dark:text-indigo-400 px-2.5 py-0.5 rounded-full ml-auto flex-shrink-0">
LinkedIn
</span>
</div>
<!-- Card 4: TikTok -->
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
<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/tiktok.com.ico"
class="w-full h-full object-contain"
onerror="this.src='https://heroicons.com/24/outline/funnel.svg'" />
</div>
<div class="min-w-0 flex-1 text-left">
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">TikTok Pixel</div>
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-mono truncate whitespace-nowrap mt-0.5">ID: C1234567890</div>
</div>
<span class="text-[10px] font-bold bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 px-2.5 py-0.5 rounded-full ml-auto flex-shrink-0">
TikTok
</span>
</div>
</div>
</div>
<!-- Title and Description -->
<div class="max-w-sm text-pretty text-center px-4">
<span class="text-base font-semibold text-neutral-900 dark:text-neutral-100">{{ __('filament-short-url::default.empty_state_pixel_heading') }}</span>
<div class="mt-2 text-pretty text-sm text-neutral-500 dark:text-neutral-400">
{{ __('filament-short-url::default.empty_state_pixel_description') }}
</div>
</div>
<!-- Create Button -->
<div class="flex items-center gap-2">
<button type="button"
x-on:click="$wire.mountAction('create')"
class="group flex h-10 items-center justify-center gap-2 whitespace-nowrap rounded-lg border border-black bg-black text-white hover:bg-neutral-800 dark:border-white dark:bg-white dark:text-black dark:hover:bg-neutral-100 hover:ring-4 hover:ring-neutral-200 dark:hover:ring-neutral-800/50 px-4 text-sm font-semibold transition-all cursor-pointer">
<svg class="size-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
<span>{{ __('filament-short-url::default.empty_state_pixel_action') }}</span>
</button>
</div>
</div>

View File

@@ -0,0 +1,245 @@
<div class="flex flex-col items-center justify-center gap-6 px-4 py-10 md:min-h-[500px]">
<style>
@keyframes infinite-scroll-y {
0% {
transform: translateY(0);
}
100% {
transform: translateY(-50%);
}
}
.animate-infinite-scroll-y {
animation: infinite-scroll-y 25s linear infinite;
}
.animate-infinite-scroll-y:hover {
animation-play-state: paused;
}
</style>
<!-- Scrolling Mock Cards Container -->
<div class="animate-fade-in h-40 w-full max-w-xs md:max-w-sm overflow-hidden px-4 [mask-image:linear-gradient(transparent,black_15%,black_85%,transparent)] select-none pointer-events-auto">
<div class="animate-infinite-scroll-y flex flex-col">
<!-- Set 1 -->
<!-- Card 1: YouTube -->
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
<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/youtube.com.ico"
class="w-full h-full object-contain"
onerror="this.src='https://heroicons.com/24/outline/link.svg'" />
</div>
<div class="min-w-0 flex-1 text-left">
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">short.io/youtube-tour</div>
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-medium truncate whitespace-nowrap max-w-[130px] md:max-w-[180px] mt-0.5">youtube.com/watch...</div>
</div>
<span class="text-[10px] font-bold bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 px-2 py-0.5 rounded-full flex items-center gap-1 ml-auto flex-shrink-0">
<svg class="size-3 text-neutral-400 dark:text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<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>
1.4k
</span>
</div>
<!-- Card 2: Maps -->
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
<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/google.com.ico"
class="w-full h-full object-contain"
onerror="this.src='https://heroicons.com/24/outline/link.svg'" />
</div>
<div class="min-w-0 flex-1 text-left">
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">demo.to/monaco-map</div>
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-medium truncate whitespace-nowrap max-w-[130px] md:max-w-[180px] mt-0.5">maps.google.com/place...</div>
</div>
<span class="text-[10px] font-bold bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 px-2 py-0.5 rounded-full flex items-center gap-1 ml-auto flex-shrink-0">
<svg class="size-3 text-neutral-400 dark:text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<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>
890
</span>
</div>
<!-- Card 3: WhatsApp -->
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
<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/whatsapp.com.ico"
class="w-full h-full object-contain"
onerror="this.src='https://heroicons.com/24/outline/link.svg'" />
</div>
<div class="min-w-0 flex-1 text-left">
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">link.xyz/whatsapp-chat</div>
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-medium truncate whitespace-nowrap max-w-[130px] md:max-w-[180px] mt-0.5">wa.me/385912345678</div>
</div>
<span class="text-[10px] font-bold bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 px-2 py-0.5 rounded-full flex items-center gap-1 ml-auto flex-shrink-0">
<svg class="size-3 text-neutral-400 dark:text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<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>
340
</span>
</div>
<!-- Card 4: Brochure PDF -->
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
<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/dropbox.com.ico"
class="w-full h-full object-contain"
onerror="this.src='https://heroicons.com/24/outline/link.svg'" />
</div>
<div class="min-w-0 flex-1 text-left">
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">example.com/brochure-pdf</div>
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-medium truncate whitespace-nowrap max-w-[130px] md:max-w-[180px] mt-0.5">dropbox.com/s/spec-brochure...</div>
</div>
<span class="text-[10px] font-bold bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 px-2 py-0.5 rounded-full flex items-center gap-1 ml-auto flex-shrink-0">
<svg class="size-3 text-neutral-400 dark:text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<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>
620
</span>
</div>
<!-- Card 5: A/B Split Test -->
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
<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/facebook.com.ico"
class="w-full h-full object-contain"
onerror="this.src='https://heroicons.com/24/outline/link.svg'" />
</div>
<div class="min-w-0 flex-1 text-left">
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">short.io/promo-campaign</div>
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-medium italic truncate whitespace-nowrap max-w-[130px] md:max-w-[180px] mt-0.5">A/B Split Traffic</div>
</div>
<span class="text-[10px] font-bold bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 px-2 py-0.5 rounded-full flex items-center gap-1 ml-auto flex-shrink-0">
<svg class="size-3 text-neutral-400 dark:text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<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>
2.1k
</span>
</div>
<!-- Set 2 (Duplicated for infinite looping) -->
<!-- Card 1: YouTube -->
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
<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/youtube.com.ico"
class="w-full h-full object-contain"
onerror="this.src='https://heroicons.com/24/outline/link.svg'" />
</div>
<div class="min-w-0 flex-1 text-left">
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">short.io/youtube-tour</div>
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-medium truncate whitespace-nowrap max-w-[130px] md:max-w-[180px] mt-0.5">youtube.com/watch...</div>
</div>
<span class="text-[10px] font-bold bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 px-2 py-0.5 rounded-full flex items-center gap-1 ml-auto flex-shrink-0">
<svg class="size-3 text-neutral-400 dark:text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<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>
1.4k
</span>
</div>
<!-- Card 2: Maps -->
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
<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/google.com.ico"
class="w-full h-full object-contain"
onerror="this.src='https://heroicons.com/24/outline/link.svg'" />
</div>
<div class="min-w-0 flex-1 text-left">
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">demo.to/monaco-map</div>
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-medium truncate whitespace-nowrap max-w-[130px] md:max-w-[180px] mt-0.5">maps.google.com/place...</div>
</div>
<span class="text-[10px] font-bold bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 px-2 py-0.5 rounded-full flex items-center gap-1 ml-auto flex-shrink-0">
<svg class="size-3 text-neutral-400 dark:text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<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>
890
</span>
</div>
<!-- Card 3: WhatsApp -->
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
<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/whatsapp.com.ico"
class="w-full h-full object-contain"
onerror="this.src='https://heroicons.com/24/outline/link.svg'" />
</div>
<div class="min-w-0 flex-1 text-left">
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">link.xyz/whatsapp-chat</div>
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-medium truncate whitespace-nowrap max-w-[130px] md:max-w-[180px] mt-0.5">wa.me/385912345678</div>
</div>
<span class="text-[10px] font-bold bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 px-2 py-0.5 rounded-full flex items-center gap-1 ml-auto flex-shrink-0">
<svg class="size-3 text-neutral-400 dark:text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<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>
340
</span>
</div>
<!-- Card 4: Brochure PDF -->
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
<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/dropbox.com.ico"
class="w-full h-full object-contain"
onerror="this.src='https://heroicons.com/24/outline/link.svg'" />
</div>
<div class="min-w-0 flex-1 text-left">
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">example.com/brochure-pdf</div>
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-medium truncate whitespace-nowrap max-w-[130px] md:max-w-[180px] mt-0.5">dropbox.com/s/spec-brochure...</div>
</div>
<span class="text-[10px] font-bold bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 px-2 py-0.5 rounded-full flex items-center gap-1 ml-auto flex-shrink-0">
<svg class="size-3 text-neutral-400 dark:text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<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>
620
</span>
</div>
<!-- Card 5: A/B Split Test -->
<div class="flex items-center gap-3.5 p-3 rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 shadow-sm hover:scale-[1.02] hover:border-neutral-300 dark:hover:border-neutral-700 transition-all duration-200 mb-3">
<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/facebook.com.ico"
class="w-full h-full object-contain"
onerror="this.src='https://heroicons.com/24/outline/link.svg'" />
</div>
<div class="min-w-0 flex-1 text-left">
<div class="text-[13px] font-bold text-neutral-800 dark:text-neutral-200 leading-tight truncate whitespace-nowrap">short.io/promo-campaign</div>
<div class="text-[11px] text-neutral-450 dark:text-neutral-500 font-medium italic truncate whitespace-nowrap max-w-[130px] md:max-w-[180px] mt-0.5">A/B Split Traffic</div>
</div>
<span class="text-[10px] font-bold bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 px-2 py-0.5 rounded-full flex items-center gap-1 ml-auto flex-shrink-0">
<svg class="size-3 text-neutral-400 dark:text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<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>
2.1k
</span>
</div>
</div>
</div>
<!-- Title and Description -->
<div class="max-w-sm text-pretty text-center px-4">
<span class="text-base font-semibold text-neutral-900 dark:text-neutral-100">{{ __('filament-short-url::default.empty_state_heading') }}</span>
<div class="mt-2 text-pretty text-sm text-neutral-500 dark:text-neutral-400">
{{ __('filament-short-url::default.empty_state_description') }}
</div>
</div>
<!-- Create Button -->
<div class="flex items-center gap-2">
<button type="button"
x-on:click="$wire.mountAction('create')"
class="group flex h-10 items-center justify-center gap-2 whitespace-nowrap rounded-lg border border-black bg-black text-white hover:bg-neutral-800 dark:border-white dark:bg-white dark:text-black dark:hover:bg-neutral-100 hover:ring-4 hover:ring-neutral-200 dark:hover:ring-neutral-800/50 px-4 text-sm font-semibold transition-all cursor-pointer">
<svg class="size-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
<span>{{ __('filament-short-url::default.empty_state_action') }}</span>
</button>
</div>
</div>

View File

@@ -0,0 +1,229 @@
@php
// Get the user relationship config
$userModel = config('filament-short-url.user.model', \App\Models\User::class);
$nameColumn = config('filament-short-url.user.name_column', 'name');
$emailColumn = config('filament-short-url.user.email_column', 'email');
$avatarColumn = config('filament-short-url.user.avatar_column', 'avatar_url');
$user = $record->user;
$userName = null;
$userEmail = null;
$avatarUrl = null;
if ($user) {
$userName = $user->{$nameColumn} ?? null;
$userEmail = $user->{$emailColumn} ?? null;
// Try to get avatar URL
if ($avatarColumn && method_exists($user, $avatarColumn)) {
$avatarUrl = $user->{$avatarColumn}();
} elseif ($avatarColumn && isset($user->{$avatarColumn})) {
$avatarUrl = $user->{$avatarColumn};
} elseif ($user instanceof \Filament\Models\Contracts\HasAvatar) {
$avatarUrl = $user->getFilamentAvatarUrl();
} elseif (method_exists($user, 'getFilamentAvatarUrl')) {
$avatarUrl = $user->getFilamentAvatarUrl();
}
// Fallback to Gravatar if no avatar URL found
if (empty($avatarUrl) && !empty($userEmail)) {
$avatarUrl = 'https://www.gravatar.com/avatar/' . md5(strtolower(trim($userEmail))) . '?d=mp&s=80';
}
}
// Relative Date Calculations for "1h", "2d" etc.
$diff = $record->created_at->diff(now());
if ($diff->y > 0) {
$shortTime = $diff->y . 'y';
} elseif ($diff->m > 0) {
$shortTime = $diff->m . 'mo';
} elseif ($diff->d > 0) {
$shortTime = $diff->d . 'd';
} elseif ($diff->h > 0) {
$shortTime = $diff->h . 'h';
} elseif ($diff->i > 0) {
$shortTime = $diff->i . 'm';
} else {
$shortTime = max(1, $diff->s) . 's';
}
// Detailed relative format for hover: "1 hour, 52 minutes, 16 seconds ago"
$parts = [];
if ($diff->y > 0) {
$parts[] = $diff->y . ' ' . str('year')->plural($diff->y);
}
if ($diff->m > 0) {
$parts[] = $diff->m . ' ' . str('month')->plural($diff->m);
}
if ($diff->d > 0) {
$parts[] = $diff->d . ' ' . str('day')->plural($diff->d);
}
if ($diff->h > 0) {
$parts[] = $diff->h . ' ' . str('hour')->plural($diff->h);
}
if ($diff->i > 0) {
$parts[] = $diff->i . ' ' . str('minute')->plural($diff->i);
}
if ($diff->s > 0) {
$parts[] = $diff->s . ' ' . str('second')->plural($diff->s);
}
// Slice to top 3 units for a readable detailed string
$detailedParts = array_slice($parts, 0, 3);
$detailedRelative = !empty($detailedParts)
? implode(', ', $detailedParts) . ' ago'
: 'just now';
// Timezone offset badge (e.g. GMT+2)
$offsetHours = $record->created_at->offsetHours;
$timezoneBadge = 'GMT' . ($offsetHours >= 0 ? '+' : '') . $offsetHours;
// Absolute date (e.g. Jun 5, 2026, 1:09:29 AM)
$absoluteDate = $record->created_at->format('M j, Y, g:i:s A');
@endphp
<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>
{{ number_format($record->total_visits) }} {{ __('filament-short-url::default.badge_clicks') }}
</span>
<!-- 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>
{{ number_format($record->unique_visits) }} {{ __('filament-short-url::default.badge_unique') }}
</span>
<!-- 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>
{{ number_format($record->qr_scans) }} {{ __('filament-short-url::default.badge_qr_scans') }}
</span>
<!-- Date Added Badge (with Hover Card) -->
<div class="relative inline-block" x-data="{ open: false }">
<div @mouseenter="open = true" @mouseleave="open = false"
class="cursor-pointer 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-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
{{ $shortTime }}
</div>
<!-- Popover Card for Date -->
<div x-show="open"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 translate-y-1 scale-95"
x-transition:enter-end="opacity-100 translate-y-0 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 translate-y-0 scale-100"
x-transition:leave-end="opacity-0 translate-y-1 scale-95"
class="absolute z-[100] bottom-full left-1/2 -translate-x-1/2 mb-2 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 pointer-events-none overflow-hidden rounded-xl shadow-sm min-w-max"
x-cloak>
<div class="flex max-w-[360px] flex-col gap-2 px-2.5 py-2 text-left text-xs">
<span class="text-neutral-500 dark:text-neutral-400 cursor-default">{{ $detailedRelative }}</span>
<table>
<tbody>
<tr class="before:bg-bg-emphasis relative select-none before:absolute before:-inset-x-1 before:inset-y-0 before:rounded before:opacity-0 before:content-[''] hover:cursor-copy hover:before:opacity-60 active:before:opacity-100">
<td class="relative py-0.5">
<span class="text-neutral-500 dark:text-neutral-400 truncate bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-300 rounded px-1 font-mono text-[11px]" title="{{ config('app.timezone') }}">{{ $timezoneBadge }}</span>
</td>
<td class="text-neutral-800 dark:text-neutral-200 relative whitespace-nowrap py-0.5 pl-2">{{ $absoluteDate }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
@if ($user)
<!-- User Avatar with Hover Card -->
<div class="relative inline-block" x-data="{ open: false }">
<div @mouseenter="open = true" @mouseleave="open = false" class="cursor-pointer flex items-center justify-center">
<img src="{{ $avatarUrl }}"
alt="{{ $userName }}"
class="w-6 h-6 rounded-full border border-neutral-200 dark:border-neutral-700 bg-neutral-100 object-cover" />
</div>
<!-- Popover Card for User -->
<div x-show="open"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 translate-y-1 scale-95"
x-transition:enter-end="opacity-100 translate-y-0 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 translate-y-0 scale-100"
x-transition:leave-end="opacity-0 translate-y-1 scale-95"
class="absolute z-[100] bottom-full left-1/2 -translate-x-1/2 mb-2 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 pointer-events-none overflow-hidden rounded-xl shadow-sm min-w-max"
x-cloak>
<div class="w-full p-3 text-left">
<img alt="Avatar for {{ $userName }}"
referrerpolicy="no-referrer"
class="rounded-full border border-neutral-300 h-8 w-8 object-cover"
draggable="false"
src="{{ $avatarUrl }}">
<div class="mt-2 flex items-center gap-1.5">
<p class="text-sm font-semibold text-neutral-700 dark:text-neutral-200">{{ $userName }}</p>
</div>
<div class="flex flex-col gap-1 text-xs text-neutral-500 dark:text-neutral-400">
<p>{{ $userEmail }}</p>
</div>
</div>
</div>
</div>
@endif
<!-- Expiry / Single Use Badge -->
@if ($record->expires_at)
<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-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')]) }}
</span>
@endif
{{-- Keyboard Shortcuts Handler (Alpine.js) --}}
<div x-data="{
hovered: false,
init() {
const card = this.$el.closest('.short-url-card');
if (card) {
this.hovered = card.matches(':hover');
card.addEventListener('mouseenter', () => this.hovered = true);
card.addEventListener('mouseleave', () => this.hovered = false);
}
},
handleKeyup(event) {
if (!this.hovered) return;
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement.tagName) || document.activeElement.isContentEditable) return;
if (document.activeElement.closest('.fi-modal-window') || document.activeElement.closest('.fi-modal')) return;
const key = event.key.toLowerCase();
if (event.ctrlKey || event.metaKey || event.altKey) return;
if (key === 'e') {
this.$wire.mountTableAction('edit', '{{ $record->id }}');
} else if (key === 'q') {
this.$wire.mountTableAction('qrCode', '{{ $record->id }}');
} else if (key === 'i') {
this.$wire.mountTableAction('share', '{{ $record->id }}');
} else if (key === 's') {
window.location.href = '{{ \Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource::getUrl('stats', ['record' => $record]) }}';
} else if (key === 'x') {
this.$wire.mountTableAction('delete', '{{ $record->id }}');
}
}
}"
@keyup.window="handleKeyup($event)"
class="hidden">
</div>
</div>

View File

@@ -0,0 +1,261 @@
@php
$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');
$downloadSvgText = __('filament-short-url::default.qr_download_svg');
$downloadPngText = __('filament-short-url::default.qr_download_png');
$closeButtonText = __('filament-short-url::default.close_button');
$copyLinkText = __('filament-short-url::default.action_copy');
$openLinkText = __('filament-short-url::default.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'],
]);
@endphp
<div data-qr-options="{{ $qrOptionsJson }}" x-data="{
init() {
const el = this.$el;
const eid = '{{ $eid }}';
const qrTargetUrl = '{{ $qrTargetUrl }}';
this.$nextTick(() => {
this.loadScript().then(() => {
const canvas = document.getElementById(eid + '_qr_canvas');
if (canvas && !canvas.innerHTML) {
const opts = JSON.parse(el.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.onerror = () => {
opts.image = null;
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();
}
}
});
});
},
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="{{ e($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/{{ e($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="{{ e($eid) }}_copy" type="button"
onclick="
event.preventDefault();
event.stopPropagation();
const u='{{ e($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('{{ e($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="{{ e($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-2V6a2 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="{{ e($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="{{ e($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="{{ e($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_{{ e($eid) }}']?.download({ name: '{{ e($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_{{ e($eid) }}']?.download({ name: '{{ e($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>

View File

@@ -0,0 +1,36 @@
@php
$shortUrl = $record->getShortUrl();
$shareCopiedMsg = __('filament-short-url::default.share_copied');
$shareCopyBtnText = __('filament-short-url::default.share_copy');
@endphp
<div class="flex items-center gap-2 relative mt-2">
<input type="text"
readonly
value="{{ e($shortUrl) }}"
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($shareCopiedMsg) }}')
.success()
.send();
} else if (typeof Alpine !== 'undefined') {
Alpine.store('filament-notifications')?.send({
status: 'success',
title: '{{ e($shareCopiedMsg) }}'
});
}
"
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>{{ $shareCopyBtnText }}</span>
</button>
</div>

View File

@@ -0,0 +1,61 @@
@php
$shortUrl = $record->getShortUrl();
$encodedUrl = urlencode($shortUrl);
@endphp
<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={{ $encodedUrl }}" 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={{ $encodedUrl }}" 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={{ $encodedUrl }}" 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-550 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={{ $encodedUrl }}" 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.5 h-5.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={{ $encodedUrl }}" 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={{ $encodedUrl }}" 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>

View File

@@ -0,0 +1,53 @@
@php
$shortUrl = $record->getShortUrl();
$copiedMsg = __('filament-short-url::default.qr_copied');
$tooltipCopy = __('filament-short-url::default.action_copy');
$destHost = parse_url($record->destination_url, PHP_URL_HOST);
@endphp
<div onclick="
event.preventDefault();
event.stopPropagation();
const text = '{{ e($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('{{ e($copiedMsg) }}')
.success()
.send();
} else if (window.Alpine) {
window.Alpine.store('filament-notifications')?.send({
status: 'success',
title: '{{ e($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/{{ e($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="{{ e($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>

View File

@@ -3,7 +3,7 @@
<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>
<title>{{ __('filament-short-url::default.warning_title') }}</title>
<!-- Premium Google Fonts: Bricolage Grotesque -->
<link rel="preconnect" href="https://fonts.googleapis.com">
@@ -37,7 +37,7 @@
@php
$logoPath = function_exists('setting') ? setting('logo_path') : null;
$logoUrl = $logoPath ? \Illuminate\Support\Facades\Storage::disk('public')->url($logoPath) : null;
$siteName = config('app.name', 'Laravel');
$siteName = config('filament-short-url.site_name') ?: config('app.name', 'Laravel');
@endphp
{{-- Main Warning Box --}}
@@ -49,10 +49,10 @@
<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' }}
{{ __('filament-short-url::default.warning_title') }}
</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:' }}
{{ __('filament-short-url::default.warning_description') }}
</p>
</div>
@@ -90,7 +90,7 @@
<!-- 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>
<span>{{ __('filament-short-url::default.warning_btn_continue') }}</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>
@@ -99,7 +99,7 @@
<!-- 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' }}
{{ __('filament-short-url::default.warning_btn_back') }}
</button>
</div>
</div>

View File

@@ -0,0 +1,108 @@
@php
$rawJson = '{
"event": "visited",
"timestamp": "2026-06-04T12:00:00+02:00",
"short_url": {
"id": 12,
"destination_url": "https://example.com/some-page",
"url_key": "promo26",
"short_url": "https://yoursite.com/s/promo26",
"total_visits": 150,
"unique_visits": 120
},
"visit": {
"id": 345,
"visited_at": "2026-06-04T12:00:00+02:00",
"device_type": "mobile",
"browser": "Chrome",
"browser_version": "120.0",
"operating_system": "Android",
"operating_system_version": "14",
"country": "Poland",
"country_code": "PL",
"city": "Warsaw",
"referer_url": "https://t.co/",
"referer_host": "t.co",
"utm_source": "twitter",
"utm_medium": "social",
"utm_campaign": "summer_sale",
"utm_term": null,
"utm_content": "banner_ad",
"is_qr_scan": false,
"browser_language": "pl"
}
}';
@endphp
<div class="space-y-2 mt-4">
<label class="text-sm font-medium leading-6 text-gray-950 dark:text-white">
{{ __('filament-short-url::default.webhook_show_payload') }}
</label>
<div
x-data="{
copied: false,
rawJson: @js($rawJson),
copy() {
navigator.clipboard.writeText(this.rawJson);
this.copied = true;
setTimeout(() => this.copied = false, 2000);
}
}"
style="position: relative; overflow: hidden; border-radius: 1rem; border: 1px solid rgba(255, 255, 255, 0.1); background-color: #18181b; padding: 1.25rem; box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);"
>
<!-- Copy Button in Top Right -->
<button
type="button"
x-on:click="copy"
x-on:mouseenter="$el.style.backgroundColor='rgba(255, 255, 255, 0.15)'; $el.style.color='#ffffff';"
x-on:mouseleave="$el.style.backgroundColor='rgba(255, 255, 255, 0.08)'; $el.style.color='#a1a1aa';"
style="position: absolute; top: 1rem; right: 1rem; display: flex; align-items: center; justify-content: center; height: 2rem; width: 2rem; border-radius: 0.5rem; background-color: rgba(255, 255, 255, 0.08); color: #a1a1aa; border: none; cursor: pointer; transition: all 0.2s;"
title="Copy payload to clipboard"
>
<!-- Copy Icon -->
<svg x-show="!copied" style="height: 1rem; width: 1rem;" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 7v8a2 2 0 002 2h6M8 7V5a2 2 0 012-2h4.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V15a2 2 0 01-2 2h-2M8 7H6a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2v-2" />
</svg>
<!-- Check Icon -->
<svg x-show="copied" x-cloak style="height: 1rem; width: 1rem; color: #34d399;" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</button>
<!-- Syntax Highlighted Payload -->
<pre style="margin: 0; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; font-size: 13px; line-height: 1.6; color: #d4d4d8; overflow-x: auto; white-space: pre-wrap; word-break: break-all; padding-right: 2.5rem;"><code style="font-family: inherit; font-size: inherit; color: inherit;">{
<span style="color: #f43f5e;">"event"</span>: <span style="color: #eab308;">"visited"</span>,
<span style="color: #f43f5e;">"timestamp"</span>: <span style="color: #eab308;">"2026-06-04T12:00:00+02:00"</span>,
<span style="color: #f43f5e;">"short_url"</span>: {
<span style="color: #f43f5e;">"id"</span>: <span style="color: #c084fc;">12</span>,
<span style="color: #f43f5e;">"destination_url"</span>: <span style="color: #eab308;">"https://example.com/some-page"</span>,
<span style="color: #f43f5e;">"url_key"</span>: <span style="color: #eab308;">"promo26"</span>,
<span style="color: #f43f5e;">"short_url"</span>: <span style="color: #eab308;">"https://yoursite.com/s/promo26"</span>,
<span style="color: #f43f5e;">"total_visits"</span>: <span style="color: #c084fc;">150</span>,
<span style="color: #f43f5e;">"unique_visits"</span>: <span style="color: #c084fc;">120</span>
},
<span style="color: #f43f5e;">"visit"</span>: {
<span style="color: #f43f5e;">"id"</span>: <span style="color: #c084fc;">345</span>,
<span style="color: #f43f5e;">"visited_at"</span>: <span style="color: #eab308;">"2026-06-04T12:00:00+02:00"</span>,
<span style="color: #f43f5e;">"device_type"</span>: <span style="color: #eab308;">"mobile"</span>,
<span style="color: #f43f5e;">"browser"</span>: <span style="color: #eab308;">"Chrome"</span>,
<span style="color: #f43f5e;">"browser_version"</span>: <span style="color: #eab308;">"120.0"</span>,
<span style="color: #f43f5e;">"operating_system"</span>: <span style="color: #eab308;">"Android"</span>,
<span style="color: #f43f5e;">"operating_system_version"</span>: <span style="color: #eab308;">"14"</span>,
<span style="color: #f43f5e;">"country"</span>: <span style="color: #eab308;">"Poland"</span>,
<span style="color: #f43f5e;">"country_code"</span>: <span style="color: #eab308;">"PL"</span>,
<span style="color: #f43f5e;">"city"</span>: <span style="color: #eab308;">"Warsaw"</span>,
<span style="color: #f43f5e;">"referer_url"</span>: <span style="color: #eab308;">"https://t.co/"</span>,
<span style="color: #f43f5e;">"referer_host"</span>: <span style="color: #eab308;">"t.co"</span>,
<span style="color: #f43f5e;">"utm_source"</span>: <span style="color: #eab308;">"twitter"</span>,
<span style="color: #f43f5e;">"utm_medium"</span>: <span style="color: #eab308;">"social"</span>,
<span style="color: #f43f5e;">"utm_campaign"</span>: <span style="color: #eab308;">"summer_sale"</span>,
<span style="color: #f43f5e;">"utm_term"</span>: <span style="color: #60a5fa;">null</span>,
<span style="color: #f43f5e;">"utm_content"</span>: <span style="color: #eab308;">"banner_ad"</span>,
<span style="color: #f43f5e;">"is_qr_scan"</span>: <span style="color: #60a5fa;">false</span>,
<span style="color: #f43f5e;">"browser_language"</span>: <span style="color: #eab308;">"pl"</span>
}
}</code></pre>
</div>
</div>

View File

@@ -0,0 +1,16 @@
<x-filament-widgets::widget>
<x-filament::section :icon="$icon">
<x-slot name="heading">
{{ $heading }}
</x-slot>
<div class="flex flex-col items-center justify-center py-12 text-center">
<div class="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-gray-50 text-gray-400 dark:bg-gray-800/30 dark:text-gray-500">
<x-filament::icon icon="heroicon-o-document-magnifying-glass" class="h-5 w-5" />
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ $message }}
</p>
</div>
</x-filament::section>
</x-filament-widgets::widget>

View File

@@ -0,0 +1,164 @@
<x-filament-widgets::widget>
<div class="fi-section rounded-xl border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-900 overflow-hidden">
<!-- Tabs Header -->
<div class="flex items-center justify-between border-b border-gray-200 dark:border-white/10 bg-gray-50/50 dark:bg-white/5 px-6">
<nav class="-mb-px flex space-x-6" aria-label="Tabs">
<button type="button"
wire:click="setActiveTab('devices')"
class="whitespace-nowrap border-b-2 py-4 px-1 text-sm font-semibold focus:outline-none transition-colors {{ $activeTab === 'devices' ? 'border-gray-900 text-gray-900 dark:border-white dark:text-white' : 'border-transparent text-gray-400 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300' }}">
{{ __('filament-short-url::default.stats_breakdown_devices') }}
</button>
<button type="button"
wire:click="setActiveTab('browsers')"
class="whitespace-nowrap border-b-2 py-4 px-1 text-sm font-semibold focus:outline-none transition-colors {{ $activeTab === 'browsers' ? 'border-gray-900 text-gray-900 dark:border-white dark:text-white' : 'border-transparent text-gray-400 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300' }}">
{{ __('filament-short-url::default.stats_breakdown_browsers') }}
</button>
<button type="button"
wire:click="setActiveTab('os')"
class="whitespace-nowrap border-b-2 py-4 px-1 text-sm font-semibold focus:outline-none transition-colors {{ $activeTab === 'os' ? 'border-gray-900 text-gray-900 dark:border-white dark:text-white' : 'border-transparent text-gray-400 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300' }}">
{{ __('filament-short-url::default.stats_breakdown_os') }}
</button>
</nav>
</div>
<!-- Body -->
<div class="relative p-6 min-h-[280px]">
<!-- Spinner -->
<div wire:loading class="absolute inset-0 bg-white/50 dark:bg-gray-900/50 backdrop-blur-[1px] z-10 transition-opacity">
<div class="flex items-center justify-center h-full w-full">
<x-filament::loading-indicator class="h-7 w-7 text-gray-900 dark:text-white" />
</div>
</div>
<!-- Tab Contents -->
<div wire:loading.class="opacity-40 pointer-events-none transition-opacity">
<!-- Devices List -->
@if ($activeTab === 'devices')
<div class="space-y-3.5">
@forelse ($visitsByDevice as $device => $count)
@php
$pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0;
$deviceLabel = match (strtolower($device)) {
'desktop' => __('filament-short-url::default.stats_device_desktop'),
'mobile' => __('filament-short-url::default.stats_device_mobile'),
'tablet' => __('filament-short-url::default.stats_device_tablet'),
default => ucfirst($device),
};
@endphp
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors" x-on:click="$wire.dispatch('set-stats-filter', { key: 'device_type', value: '{{ addslashes($device) }}' })">
<div class="flex items-center justify-between text-sm">
<span class="flex items-center gap-2 font-medium text-gray-700 dark:text-gray-300">
@if (strtolower($device) === 'desktop')
<x-filament::icon icon="heroicon-m-computer-desktop" class="h-4 w-4 text-gray-400 dark:text-gray-500" />
@elseif (strtolower($device) === 'mobile')
<x-filament::icon icon="heroicon-m-device-phone-mobile" class="h-4 w-4 text-gray-400 dark:text-gray-500" />
@elseif (strtolower($device) === 'tablet')
<x-filament::icon icon="heroicon-m-device-tablet" class="h-4 w-4 text-gray-400 dark:text-gray-500" />
@else
<x-filament::icon icon="heroicon-m-question-mark-circle" class="h-4 w-4 text-gray-400 dark:text-gray-500" />
@endif
<span>{{ $deviceLabel }}</span>
</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
<div class="flex flex-col items-center justify-center py-6 text-center">
<div class="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-gray-50 text-gray-400 dark:bg-gray-800/30 dark:text-gray-500">
<x-filament::icon icon="heroicon-o-document-magnifying-glass" class="h-5 w-5" />
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ __('filament-short-url::default.stats_no_device_data') }}
</p>
</div>
@endforelse
</div>
@endif
<!-- Browsers List -->
@if ($activeTab === 'browsers')
<div class="space-y-3.5">
@forelse ($visitsByBrowser as $browser => $count)
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors flex items-center justify-between text-sm" x-on:click="$wire.dispatch('set-stats-filter', { key: 'browser', value: '{{ addslashes($browser) }}' })">
<div class="flex flex-col min-w-0">
<div class="flex items-center gap-2">
<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>
@if (!empty($visitsByBrowserVersion[$browser]))
@php
$versionsList = [];
foreach ($visitsByBrowserVersion[$browser] as $ver => $vCount) {
$versionsList[] = "v{$ver} ({$vCount})";
}
@endphp
<span class="text-[10px] text-gray-400 dark:text-gray-500 ml-6 truncate">
{{ implode(', ', $versionsList) }}
</span>
@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
<div class="flex flex-col items-center justify-center py-6 text-center">
<div class="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-gray-50 text-gray-400 dark:bg-gray-800/30 dark:text-gray-500">
<x-filament::icon icon="heroicon-o-document-magnifying-glass" class="h-5 w-5" />
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ __('filament-short-url::default.stats_no_browser_data') }}
</p>
</div>
@endforelse
</div>
@endif
<!-- OS List -->
@if ($activeTab === 'os')
<div class="space-y-3.5">
@forelse ($visitsByOs as $os => $count)
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors flex items-center justify-between text-sm" x-on:click="$wire.dispatch('set-stats-filter', { key: 'operating_system', value: '{{ addslashes($os) }}' })">
<div class="flex flex-col min-w-0">
<div class="flex items-center gap-2">
<x-filament::icon :icon="$osIcons[$os] ?? 'heroicon-m-cpu-chip'" 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>
@if (!empty($visitsByOsVersion[$os]))
@php
$versionsList = [];
foreach ($visitsByOsVersion[$os] as $ver => $vCount) {
$versionsList[] = "v{$ver} ({$vCount})";
}
@endphp
<span class="text-[10px] text-gray-400 dark:text-gray-500 ml-6 truncate">
{{ implode(', ', $versionsList) }}
</span>
@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
<div class="flex flex-col items-center justify-center py-6 text-center">
<div class="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-gray-50 text-gray-400 dark:bg-gray-800/30 dark:text-gray-500">
<x-filament::icon icon="heroicon-o-document-magnifying-glass" class="h-5 w-5" />
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ __('filament-short-url::default.stats_no_os_data') }}
</p>
</div>
@endforelse
</div>
@endif
</div>
</div>
</div>
</x-filament-widgets::widget>

View File

@@ -0,0 +1 @@
<div></div>

View File

@@ -0,0 +1,133 @@
<x-filament-widgets::widget>
<div class="fi-section rounded-xl border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-900 overflow-hidden">
<!-- Tabs Header -->
<div class="flex items-center justify-between border-b border-gray-200 dark:border-white/10 bg-gray-50/50 dark:bg-white/5 px-6">
<nav class="-mb-px flex space-x-6" aria-label="Tabs">
<button type="button"
wire:click="setActiveTab('countries')"
class="whitespace-nowrap border-b-2 py-4 px-1 text-sm font-semibold focus:outline-none transition-colors {{ $activeTab === 'countries' ? 'border-gray-900 text-gray-900 dark:border-white dark:text-white' : 'border-transparent text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300' }}">
{{ __('filament-short-url::default.stats_breakdown_countries') }}
</button>
<button type="button"
wire:click="setActiveTab('cities')"
class="whitespace-nowrap border-b-2 py-4 px-1 text-sm font-semibold focus:outline-none transition-colors {{ $activeTab === 'cities' ? 'border-gray-900 text-gray-900 dark:border-white dark:text-white' : 'border-transparent text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300' }}">
{{ __('filament-short-url::default.stats_breakdown_cities') }}
</button>
<button type="button"
wire:click="setActiveTab('languages')"
class="whitespace-nowrap border-b-2 py-4 px-1 text-sm font-semibold focus:outline-none transition-colors {{ $activeTab === 'languages' ? 'border-gray-900 text-gray-900 dark:border-white dark:text-white' : 'border-transparent text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300' }}">
{{ __('filament-short-url::default.stats_breakdown_languages') }}
</button>
</nav>
</div>
<!-- Body -->
<div class="relative p-6 min-h-[280px]">
<!-- Spinner -->
<div wire:loading class="absolute inset-0 bg-white/50 dark:bg-gray-900/50 backdrop-blur-[1px] z-10 transition-opacity">
<div class="flex items-center justify-center h-full w-full">
<x-filament::loading-indicator class="h-7 w-7 text-gray-900 dark:text-white" />
</div>
</div>
<!-- Tab Contents -->
<div wire:loading.class="opacity-40 pointer-events-none transition-opacity">
<!-- Countries List -->
@if ($activeTab === 'countries')
<div class="space-y-3">
@forelse ($visitsByCountry as $code => $count)
@php
$pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0;
$translatedCountry = __('filament-short-url::countries.' . strtoupper($code));
if (!$translatedCountry || $translatedCountry === 'filament-short-url::countries.' . strtoupper($code)) {
$translatedCountry = strtoupper($code);
}
@endphp
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors" x-on:click="$wire.dispatch('set-stats-filter', { key: 'country_code', value: '{{ addslashes($code) }}' })">
<div class="flex items-center justify-between text-sm">
<span class="flex items-center gap-2 font-medium text-gray-700 dark:text-gray-300">
@if ($code)
<img src="https://flagcdn.com/h20/{{ strtolower($code) }}.webp" class="w-5 h-auto rounded-sm inline-block" alt="{{ $translatedCountry }}" />
@endif
<span>{{ $translatedCountry }}</span>
</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
<div class="flex flex-col items-center justify-center py-6 text-center">
<div class="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-gray-50 text-gray-400 dark:bg-gray-800/30 dark:text-gray-500">
<x-filament::icon icon="heroicon-o-document-magnifying-glass" class="h-5 w-5" />
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ __('filament-short-url::default.stats_no_country_data') }}
</p>
</div>
@endforelse
</div>
@endif
<!-- Cities List -->
@if ($activeTab === 'cities')
<div class="space-y-3.5">
@forelse ($visitsByCity as $city => $count)
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors" x-on:click="$wire.dispatch('set-stats-filter', { key: 'city', value: '{{ addslashes($city) }}' })">
<div class="flex items-center justify-between text-sm">
<span class="font-medium text-gray-700 dark:text-gray-300">{{ $city }}</span>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span></span>
</div>
<div class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div class="h-full rounded-full bg-emerald-500 transition-all duration-500" style="width: {{ $pct }}%"></div>
</div>
</div>
@empty
<div class="flex flex-col items-center justify-center py-6 text-center">
<div class="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-gray-50 text-gray-400 dark:bg-gray-800/30 dark:text-gray-500">
<x-filament::icon icon="heroicon-o-document-magnifying-glass" class="h-5 w-5" />
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ __('filament-short-url::default.stats_no_city_data') }}
</p>
</div>
@endforelse
</div>
@endif
<!-- Languages List -->
@if ($activeTab === 'languages')
<div class="space-y-3">
@forelse ($visitsByLanguage as $langCode => $count)
@php
$pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0;
$langName = \Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlLanguagesWidget::getLanguageTranslation($langCode);
@endphp
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors" x-on:click="$wire.dispatch('set-stats-filter', { key: 'browser_language', value: '{{ addslashes($langCode) }}' })">
<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
<div class="flex flex-col items-center justify-center py-6 text-center">
<div class="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-gray-50 text-gray-400 dark:bg-gray-800/30 dark:text-gray-500">
<x-filament::icon icon="heroicon-o-document-magnifying-glass" class="h-5 w-5" />
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ __('filament-short-url::default.stats_no_language_data') }}
</p>
</div>
@endforelse
</div>
@endif
</div>
</div>
</div>
</x-filament-widgets::widget>

View File

@@ -0,0 +1,107 @@
<x-filament-widgets::widget>
<div class="fi-section rounded-xl border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-900 overflow-hidden">
<!-- Tabs Header -->
<div class="flex items-center justify-between border-b border-gray-200 dark:border-white/10 bg-gray-50/50 dark:bg-white/5 px-6">
<nav class="-mb-px flex space-x-6" aria-label="Tabs">
<button type="button"
wire:click="setActiveTab('bots')"
class="whitespace-nowrap border-b-2 py-4 px-1 text-sm font-semibold focus:outline-none transition-colors {{ $activeTab === 'bots' ? 'border-gray-900 text-gray-900 dark:border-white dark:text-white' : 'border-transparent text-gray-400 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300' }}">
{{ __('filament-short-url::default.stats_security_bot_ratio') }}
</button>
<button type="button"
wire:click="setActiveTab('vpn')"
class="whitespace-nowrap border-b-2 py-4 px-1 text-sm font-semibold focus:outline-none transition-colors {{ $activeTab === 'vpn' ? 'border-gray-900 text-gray-900 dark:border-white dark:text-white' : 'border-transparent text-gray-400 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300' }}">
{{ __('filament-short-url::default.stats_security_vpn_blocked') }}
</button>
</nav>
</div>
<!-- Body -->
<div class="relative p-6 min-h-[280px]">
<!-- Spinner -->
<div wire:loading class="absolute inset-0 bg-white/50 dark:bg-gray-900/50 backdrop-blur-[1px] z-10 transition-opacity">
<div class="flex items-center justify-center h-full w-full">
<x-filament::loading-indicator class="h-7 w-7 text-gray-900 dark:text-white" />
</div>
</div>
<!-- Tab Contents -->
<div wire:loading.class="opacity-40 pointer-events-none transition-opacity">
<!-- Bot Clicks Tab -->
@if ($activeTab === 'bots')
<div class="space-y-4">
{{-- Human Clicks --}}
<div>
<div class="flex items-center justify-between text-sm">
<span class="flex items-center gap-2 font-medium text-gray-700 dark:text-gray-300">
<span class="inline-block h-2 w-2 rounded-full bg-emerald-500"></span>
<span>{{ __('filament-short-url::default.stats_security_real_users') }}</span>
</span>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">
{{ number_format($humanClicks) }}
<span class="text-gray-400 dark:text-gray-500">({{ $humanPercentage }}%)</span>
</span>
</div>
<div class="mt-2 h-2.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: {{ $humanPercentage }}%"></div>
</div>
</div>
{{-- Bot Clicks --}}
<div>
<div class="flex items-center justify-between text-sm">
<span class="flex items-center gap-2 font-medium text-gray-700 dark:text-gray-300">
<span class="inline-block h-2 w-2 rounded-full bg-purple-500"></span>
<span>{{ __('filament-short-url::default.stats_security_bots') }}</span>
</span>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">
{{ number_format($botClicks) }}
<span class="text-gray-400 dark:text-gray-500">({{ $botPercentage }}%)</span>
</span>
</div>
<div class="mt-2 h-2.5 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div class="h-full rounded-full bg-purple-500 transition-all duration-500" style="width: {{ $botPercentage }}%"></div>
</div>
</div>
</div>
@endif
<!-- VPN / Proxy Blocked Tab -->
@if ($activeTab === 'vpn')
<div class="mt-2">
@if ($proxyClicks === 0)
<div class="flex flex-col items-center justify-center py-6 text-center">
<div class="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-emerald-50 text-emerald-500 dark:bg-emerald-950/30 dark:text-emerald-400">
<x-filament::icon icon="heroicon-o-check-circle" class="h-6 w-6" />
</div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-300">
{{ __('filament-short-url::default.stats_security_no_vpn_blocks') }}
</p>
</div>
@else
<div class="flex items-center justify-between py-2">
<div>
<p class="text-2xl font-bold text-rose-600 dark:text-rose-400 font-mono">
{{ number_format($proxyClicks) }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ __('filament-short-url::default.stats_security_proxy_clicks') }}
</p>
</div>
<div class="rounded-lg bg-rose-50 px-3 py-2 text-rose-700 dark:bg-rose-950/30 dark:text-rose-400">
<div class="flex items-center gap-1.5 text-xs font-semibold">
<x-filament::icon icon="heroicon-o-exclamation-triangle" class="h-4 w-4" />
<span>VPN / Proxy Traffic</span>
</div>
</div>
</div>
<div class="mt-4 h-1.5 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div class="h-full rounded-full bg-rose-500 transition-all duration-500" style="width: {{ $proxyPercentage }}%"></div>
</div>
@endif
</div>
@endif
</div>
</div>
</div>
</x-filament-widgets::widget>

View File

@@ -0,0 +1,205 @@
<x-filament-widgets::widget>
<div class="fi-section rounded-xl border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-900 overflow-hidden">
<!-- Tabs Header -->
<div class="flex items-center justify-between border-b border-gray-200 dark:border-white/10 bg-gray-50/50 dark:bg-white/5 px-6">
<nav class="-mb-px flex space-x-6" aria-label="Tabs">
<button type="button"
wire:click="setActiveTab('referrers')"
class="whitespace-nowrap border-b-2 py-4 px-1 text-sm font-semibold focus:outline-none transition-colors {{ $activeTab === 'referrers' ? 'border-gray-900 text-gray-900 dark:border-white dark:text-white' : 'border-transparent text-gray-400 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300' }}">
{{ __('filament-short-url::default.stats_breakdown_referers') }}
</button>
<button type="button"
wire:click="setActiveTab('utm')"
class="whitespace-nowrap border-b-2 py-4 px-1 text-sm font-semibold focus:outline-none transition-colors {{ $activeTab === 'utm' ? 'border-gray-900 text-gray-900 dark:border-white dark:text-white' : 'border-transparent text-gray-400 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300' }}">
UTM Parameters
</button>
</nav>
</div>
<!-- Submenu Header (only for UTM) -->
@if ($activeTab === 'utm')
<div class="flex items-center gap-1.5 border-b border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900 px-6 py-2.5">
<button type="button"
wire:click="setActiveSubTab('sources')"
class="px-2.5 py-1 text-xs font-semibold rounded-lg transition-all focus:outline-none {{ $activeSubTab === 'sources' ? 'bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-white' : 'bg-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300' }}">
{{ __('filament-short-url::default.stats_breakdown_utm_source') }}
</button>
<button type="button"
wire:click="setActiveSubTab('mediums')"
class="px-2.5 py-1 text-xs font-semibold rounded-lg transition-all focus:outline-none {{ $activeSubTab === 'mediums' ? 'bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-white' : 'bg-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300' }}">
{{ __('filament-short-url::default.stats_breakdown_utm_medium') }}
</button>
<button type="button"
wire:click="setActiveSubTab('campaigns')"
class="px-2.5 py-1 text-xs font-semibold rounded-lg transition-all focus:outline-none {{ $activeSubTab === 'campaigns' ? 'bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-white' : 'bg-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300' }}">
{{ __('filament-short-url::default.stats_breakdown_utm_campaign') }}
</button>
<button type="button"
wire:click="setActiveSubTab('terms')"
class="px-2.5 py-1 text-xs font-semibold rounded-lg transition-all focus:outline-none {{ $activeSubTab === 'terms' ? 'bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-white' : 'bg-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300' }}">
{{ __('filament-short-url::default.stats_breakdown_utm_term') }}
</button>
<button type="button"
wire:click="setActiveSubTab('contents')"
class="px-2.5 py-1 text-xs font-semibold rounded-lg transition-all focus:outline-none {{ $activeSubTab === 'contents' ? 'bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-white' : 'bg-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300' }}">
{{ __('filament-short-url::default.stats_breakdown_utm_content') }}
</button>
</div>
@endif
<!-- Body -->
<div class="relative p-6 min-h-[280px]">
<!-- Spinner -->
<div wire:loading class="absolute inset-0 bg-white/50 dark:bg-gray-900/50 backdrop-blur-[1px] z-10 transition-opacity">
<div class="flex items-center justify-center h-full w-full">
<x-filament::loading-indicator class="h-7 w-7 text-gray-900 dark:text-white" />
</div>
</div>
<!-- Tab Contents -->
<div wire:loading.class="opacity-40 pointer-events-none transition-opacity">
<!-- Referrers Tab -->
@if ($activeTab === 'referrers')
<div class="space-y-3.5">
@forelse ($visitsByReferer as $referer => $count)
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors" x-on:click="$wire.dispatch('set-stats-filter', { key: 'referer_host', value: '{{ addslashes($referer) }}' })">
<div class="flex items-center justify-between text-sm">
<span class="font-medium text-gray-700 dark:text-gray-300">{{ $referer }}</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
<div class="flex flex-col items-center justify-center py-6 text-center">
<div class="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-gray-50 text-gray-400 dark:bg-gray-800/30 dark:text-gray-500">
<x-filament::icon icon="heroicon-o-document-magnifying-glass" class="h-5 w-5" />
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ __('filament-short-url::default.stats_no_referer_data') }}
</p>
</div>
@endforelse
</div>
@endif
<!-- UTM Parameters Tab -->
@if ($activeTab === 'utm')
<div class="space-y-4">
<!-- Sub-tab Content -->
<div>
@if ($activeSubTab === 'sources')
<div class="space-y-3.5 mt-2">
@forelse ($utmSources as $source => $count)
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1 rounded-lg transition-colors flex items-center justify-between text-sm" x-on:click="$wire.dispatch('set-stats-filter', { key: 'utm_source', value: '{{ addslashes($source) }}' })">
<span class="font-medium text-gray-700 dark:text-gray-300 truncate mr-2">{{ $source }}</span>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0">
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
</span>
</div>
@empty
<div class="flex flex-col items-center justify-center py-6 text-center">
<div class="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-gray-50 text-gray-400 dark:bg-gray-800/30 dark:text-gray-500">
<x-filament::icon icon="heroicon-o-document-magnifying-glass" class="h-5 w-5" />
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ __('filament-short-url::default.stats_no_utm_data') }}
</p>
</div>
@endforelse
</div>
@elseif ($activeSubTab === 'mediums')
<div class="space-y-3.5 mt-2">
@forelse ($utmMediums as $medium => $count)
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1 rounded-lg transition-colors flex items-center justify-between text-sm" x-on:click="$wire.dispatch('set-stats-filter', { key: 'utm_medium', value: '{{ addslashes($medium) }}' })">
<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
<div class="flex flex-col items-center justify-center py-6 text-center">
<div class="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-gray-50 text-gray-400 dark:bg-gray-800/30 dark:text-gray-500">
<x-filament::icon icon="heroicon-o-document-magnifying-glass" class="h-5 w-5" />
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ __('filament-short-url::default.stats_no_utm_data') }}
</p>
</div>
@endforelse
</div>
@elseif ($activeSubTab === 'campaigns')
<div class="space-y-3.5 mt-2">
@forelse ($utmCampaigns as $campaign => $count)
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1 rounded-lg transition-colors flex items-center justify-between text-sm" x-on:click="$wire.dispatch('set-stats-filter', { key: 'utm_campaign', value: '{{ addslashes($campaign) }}' })">
<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
<div class="flex flex-col items-center justify-center py-6 text-center">
<div class="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-gray-50 text-gray-400 dark:bg-gray-800/30 dark:text-gray-500">
<x-filament::icon icon="heroicon-o-document-magnifying-glass" class="h-5 w-5" />
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ __('filament-short-url::default.stats_no_utm_data') }}
</p>
</div>
@endforelse
</div>
@elseif ($activeSubTab === 'terms')
<div class="space-y-3.5 mt-2">
@forelse ($utmTerms as $term => $count)
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1 rounded-lg transition-colors flex items-center justify-between text-sm" x-on:click="$wire.dispatch('set-stats-filter', { key: 'utm_term', value: '{{ addslashes($term) }}' })">
<span class="font-medium text-gray-700 dark:text-gray-300 truncate mr-2">{{ $term }}</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
<div class="flex flex-col items-center justify-center py-6 text-center">
<div class="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-gray-50 text-gray-400 dark:bg-gray-800/30 dark:text-gray-500">
<x-filament::icon icon="heroicon-o-document-magnifying-glass" class="h-5 w-5" />
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ __('filament-short-url::default.stats_no_utm_data') }}
</p>
</div>
@endforelse
</div>
@elseif ($activeSubTab === 'contents')
<div class="space-y-3.5 mt-2">
@forelse ($utmContents as $content => $count)
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1 rounded-lg transition-colors flex items-center justify-between text-sm" x-on:click="$wire.dispatch('set-stats-filter', { key: 'utm_content', value: '{{ addslashes($content) }}' })">
<span class="font-medium text-gray-700 dark:text-gray-300 truncate mr-2">{{ $content }}</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
<div class="flex flex-col items-center justify-center py-6 text-center">
<div class="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-gray-50 text-gray-400 dark:bg-gray-800/30 dark:text-gray-500">
<x-filament::icon icon="heroicon-o-document-magnifying-glass" class="h-5 w-5" />
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ __('filament-short-url::default.stats_no_utm_data') }}
</p>
</div>
@endforelse
</div>
@endif
</div>
</div>
@endif
</div>
</div>
</div>
</x-filament-widgets::widget>

View File

@@ -0,0 +1,38 @@
<x-filament-widgets::widget>
<x-filament::section icon="heroicon-o-arrow-path-rounded-square">
<x-slot name="heading">
A/B Testing Variants
</x-slot>
<div class="space-y-4 mt-2">
@foreach ($visitsByVariant as $variant => $count)
@php
$pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0;
$expectedWeight = null;
if (is_array($rotationVariants)) {
foreach ($rotationVariants as $v) {
if (($v['label'] ?? '') === $variant) {
$expectedWeight = $v['weight'] ?? null;
break;
}
}
}
@endphp
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors" x-on:click="$wire.dispatch('set-stats-filter', { key: 'selected_variant', value: '{{ addslashes($variant) }}' })">
<div class="flex items-center justify-between text-sm">
<span class="font-medium text-gray-700 dark:text-gray-300">{{ $variant }}</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 }}%{{ $expectedWeight !== null ? ' vs expected ' . $expectedWeight . '%' : '' }})
</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>
@endforeach
</div>
</x-filament::section>
</x-filament-widgets::widget>

View File

@@ -1,257 +0,0 @@
@php
$deviceIcons = [
'desktop' => 'heroicon-m-computer-desktop',
'mobile' => 'heroicon-m-device-phone-mobile',
'tablet' => 'heroicon-m-device-tablet',
'robot' => 'heroicon-m-cpu-chip',
];
$browserIcons = [
'Chrome' => 'heroicon-m-globe-alt',
'Firefox' => 'heroicon-m-globe-americas',
'Safari' => 'heroicon-m-compass',
'Edge' => 'heroicon-m-globe-asia-australia',
'Opera' => 'heroicon-m-bolt',
'Internet Explorer' => 'heroicon-m-wrench',
'Samsung Browser' => 'heroicon-m-device-phone-mobile',
];
$osIcons = [
'Windows' => 'heroicon-m-computer-desktop',
'OS X' => 'heroicon-m-computer-desktop',
'macOS' => 'heroicon-m-computer-desktop',
'iOS' => 'heroicon-m-device-phone-mobile',
'Android' => 'heroicon-m-device-phone-mobile',
'Linux' => 'heroicon-m-cpu-chip',
];
@endphp
<x-filament-widgets::widget>
<div class="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" />
</div>
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_devices') }}</h3>
</div>
<div class="space-y-4">
@forelse ($visitsByDevice as $device => $count)
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
<div class="flex items-center gap-3">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gray-50 text-gray-500 dark:bg-gray-800 dark:text-gray-400">
<x-filament::icon :icon="$deviceIcons[$device] ?? 'heroicon-m-question-mark-circle'" class="h-4 w-4" />
</div>
<div class="flex-1 min-w-0">
<div class="flex justify-between text-sm">
<span class="capitalize font-medium text-gray-700 dark:text-gray-300 truncate">{{ $device }}</span>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">{{ $pct }}%</span>
</div>
<div class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div class="h-full rounded-full bg-emerald-500 transition-all duration-500" style="width: {{ $pct }}%"></div>
</div>
</div>
</div>
@empty
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_device_data') }}</p>
@endforelse
</div>
</div>
{{-- Browsers --}}
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-amber-50 text-amber-500 dark:bg-amber-950/50 dark:text-amber-400">
<x-filament::icon icon="heroicon-o-globe-asia-australia" class="h-5 w-5" />
</div>
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_browsers') }}</h3>
</div>
<div class="space-y-3.5">
@forelse ($visitsByBrowser as $browser => $count)
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2 min-w-0">
<x-filament::icon :icon="$browserIcons[$browser] ?? 'heroicon-m-globe-alt'" class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" />
<span class="font-medium text-gray-700 dark:text-gray-300 truncate">{{ $browser }}</span>
</div>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0 ml-2">
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
</span>
</div>
@empty
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_browser_data') }}</p>
@endforelse
</div>
</div>
{{-- Operating Systems --}}
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-blue-50 text-blue-500 dark:bg-blue-950/50 dark:text-blue-400">
<x-filament::icon icon="heroicon-o-computer-desktop" class="h-5 w-5" />
</div>
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_os') }}</h3>
</div>
<div class="space-y-3.5">
@forelse ($visitsByOs as $os => $count)
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2 min-w-0">
<x-filament::icon :icon="$osIcons[$os] ?? 'heroicon-m-computer-desktop'" class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" />
<span class="font-medium text-gray-700 dark:text-gray-300 truncate">{{ $os }}</span>
</div>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0 ml-2">
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
</span>
</div>
@empty
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_os_data') }}</p>
@endforelse
</div>
</div>
</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>
@empty
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_city_data') }}</p>
@endforelse
</div>
</div>
</div>
{{-- UTM Campaigns Section --}}
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
{{-- UTM Sources --}}
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-rose-50 text-rose-500 dark:bg-rose-950/50 dark:text-rose-400">
<x-filament::icon icon="heroicon-o-megaphone" class="h-5 w-5" />
</div>
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_utm_source') }}</h3>
</div>
<div class="space-y-3.5">
@forelse ($utmSources as $source => $count)
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
<div class="flex items-center justify-between text-sm">
<span class="font-medium text-gray-700 dark:text-gray-300 truncate mr-2">{{ $source }}</span>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0">
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
</span>
</div>
@empty
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_utm_data') }}</p>
@endforelse
</div>
</div>
{{-- UTM Mediums --}}
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-teal-50 text-teal-500 dark:bg-teal-950/50 dark:text-teal-400">
<x-filament::icon icon="heroicon-o-tag" class="h-5 w-5" />
</div>
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_utm_medium') }}</h3>
</div>
<div class="space-y-3.5">
@forelse ($utmMediums as $medium => $count)
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
<div class="flex items-center justify-between text-sm">
<span class="font-medium text-gray-700 dark:text-gray-300 truncate mr-2">{{ $medium }}</span>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0">
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
</span>
</div>
@empty
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_utm_data') }}</p>
@endforelse
</div>
</div>
{{-- UTM Campaigns --}}
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-cyan-50 text-cyan-500 dark:bg-cyan-950/50 dark:text-cyan-400">
<x-filament::icon icon="heroicon-o-flag" class="h-5 w-5" />
</div>
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_utm_campaign') }}</h3>
</div>
<div class="space-y-3.5">
@forelse ($utmCampaigns as $campaign => $count)
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
<div class="flex items-center justify-between text-sm">
<span class="font-medium text-gray-700 dark:text-gray-300 truncate mr-2">{{ $campaign }}</span>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0">
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
</span>
</div>
@empty
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_utm_data') }}</p>
@endforelse
</div>
</div>
</div>
</div>
</x-filament-widgets::widget>

View File

@@ -0,0 +1,102 @@
@php
use Filament\Widgets\View\Components\ChartWidgetComponent;
use Illuminate\View\ComponentAttributeBag;
$color = $this->getColor();
$heading = $this->getHeading();
$description = $this->getDescription();
$filters = $this->getFilters();
$isCollapsible = $this->isCollapsible();
$type = $this->getType();
$maxHeight = $this->getMaxHeight();
$hasMaxHeight = filled($maxHeight) && $maxHeight !== '100%';
@endphp
<x-filament-widgets::widget class="fi-wi-chart">
<x-filament::section
:description="$description"
:heading="$heading"
:collapsible="$isCollapsible"
>
<x-slot name="headerEnd">
<div class="flex items-center gap-3">
<!-- Metric Selector -->
<x-filament::input.wrapper class="w-44">
<x-filament::input.select wire:model.live="activeMetric">
<option value="total">{{ __('filament-short-url::default.stats_card_total') }}</option>
<option value="unique">{{ __('filament-short-url::default.stats_card_unique') }}</option>
<option value="qr">{{ __('filament-short-url::default.stats_card_qr_scans') }}</option>
</x-filament::input.select>
</x-filament::input.wrapper>
<!-- Granularity Selector -->
@if ($filters)
<x-filament::input.wrapper class="w-32">
<x-filament::input.select wire:model.live="filter">
@foreach ($filters as $value => $label)
<option value="{{ $value }}">
{{ $label }}
</option>
@endforeach
</x-filament::input.select>
</x-filament::input.wrapper>
@endif
</div>
</x-slot>
<div
@if ($pollingInterval = $this->getPollingInterval())
wire:poll.{{ $pollingInterval }}="updateChartData"
@endif
>
<div
x-load
x-load-src="{{ \Filament\Support\Facades\FilamentAsset::getAlpineComponentSrc('chart', 'filament/widgets') }}"
wire:ignore
data-chart-type="{{ $type }}"
x-data="chart({
cachedData: @js($this->getCachedData()),
options: @js($this->getOptions()),
type: @js($type),
})"
{{
(new ComponentAttributeBag)
->color(ChartWidgetComponent::class, $color)
->class([
'fi-wi-chart-canvas-ctn',
'fi-wi-chart-canvas-ctn-no-aspect-ratio' => $hasMaxHeight,
])
}}
>
<canvas
x-ref="canvas"
@style([
'width: 100%',
'height: 100%; max-height: 100%' => ! $hasMaxHeight,
"max-height: {$maxHeight}" => $hasMaxHeight,
])
></canvas>
<span
x-ref="backgroundColorElement"
class="fi-wi-chart-bg-color"
></span>
<span
x-ref="borderColorElement"
class="fi-wi-chart-border-color"
></span>
<span
x-ref="gridColorElement"
class="fi-wi-chart-grid-color"
></span>
<span
x-ref="textColorElement"
class="fi-wi-chart-text-color"
></span>
</div>
</div>
</x-filament::section>
</x-filament-widgets::widget>

View File

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

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

View 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

View File

@@ -1,8 +1,18 @@
<?php
use Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlApiController;
use Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlLogoController;
use Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlRedirectController;
use Bjanczak\FilamentShortUrl\Http\Middleware\AuthenticateShortUrlApi;
use Illuminate\Support\Facades\Route;
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}',
@@ -13,9 +23,27 @@ Route::match(
->middleware(config('filament-short-url.middleware', ['web', 'throttle:120,1']));
Route::prefix('api/short-url')
->middleware([\Bjanczak\FilamentShortUrl\Http\Middleware\AuthenticateShortUrlApi::class])
->middleware([
AuthenticateShortUrlApi::class,
'throttle:60,1',
])
->group(function () {
Route::get('links', [\Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlApiController::class, 'index']);
Route::post('links', [\Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlApiController::class, 'store']);
Route::delete('links/{id}', [\Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlApiController::class, 'destroy']);
Route::get('links', [ShortUrlApiController::class, 'index']);
Route::post('links', [ShortUrlApiController::class, 'store']);
Route::get('links/{idOrKey}', [ShortUrlApiController::class, 'show']);
Route::get('links/{idOrKey}/stats', [ShortUrlApiController::class, 'stats']);
Route::match(['PUT', 'PATCH'], 'links/{idOrKey}', [ShortUrlApiController::class, 'update']);
Route::delete('links/{idOrKey}', [ShortUrlApiController::class, 'destroy']);
});
Route::post('admin/short-url/upload-logo', [ShortUrlLogoController::class, 'uploadLogo'])
->name('short-url.upload-logo')
->middleware(['web']);
Route::get('short-url/logo/{filename}', [ShortUrlLogoController::class, 'serveLogo'])
->name('short-url.logo');
if (config('filament-short-url.enable_fallback_route', true)) {
Route::fallback(ShortUrlRedirectController::class)
->middleware(config('filament-short-url.middleware', ['web', 'throttle:120,1']));
}

View File

@@ -0,0 +1,22 @@
<?php
namespace Bjanczak\FilamentShortUrl\Assets;
use Filament\Support\Assets\Css;
use Throwable;
class ShortUrlCss extends Css
{
public function getVersion(): string
{
try {
$path = $this->getPublicPath();
if (file_exists($path)) {
return (string) filemtime($path);
}
} catch (Throwable $exception) {
}
return parent::getVersion();
}
}

View File

@@ -7,6 +7,7 @@ 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
{
@@ -39,81 +40,133 @@ class AggregateAndPruneVisitsCommand extends Command
$this->info('Found '.count($dates).' days to aggregate.');
foreach ($dates as $date) {
// Accumulate stats per short_url_id using chunked reads — avoids loading
// potentially millions of rows into PHP memory at once.
$statsByUrl = [];
// Wrap date aggregation in a database transaction for data integrity
DB::transaction(function () use ($date): void {
$nextDate = Carbon::parse($date)->addDay()->toDateString();
$start = $date.' 00:00:00';
$end = $nextDate.' 00:00:00';
ShortUrlVisit::whereBetween('visited_at', [$date.' 00:00:00', $date.' 23:59:59'])
->chunk(1000, function ($chunk) use (&$statsByUrl): void {
foreach ($chunk as $visit) {
$urlId = $visit->short_url_id;
// Driver-aware boolean count: MySQL/SQLite store booleans as TINYINT (= 1),
// PostgreSQL uses a native boolean type (cast to int for aggregation).
$driver = DB::connection()->getDriverName();
$qrExpr = $driver === 'pgsql'
? 'count(case when is_qr_scan::int = 1 then 1 end) as qr_scans'
: 'count(case when is_qr_scan = 1 then 1 end) as qr_scans';
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' => [],
];
}
$totals = DB::table('short_url_visits')
->where('visited_at', '>=', $start)
->where('visited_at', '<', $end)
->select([
'short_url_id',
DB::raw('count(*) as total'),
DB::raw('count(distinct ip_hash) as uniques'),
DB::raw($qrExpr),
])
->groupBy('short_url_id')
->get();
$s = &$statsByUrl[$urlId];
$s['total']++;
if ($totals->isEmpty()) {
return;
}
if ($visit->ip_hash) {
$s['ip_hashes'][$visit->ip_hash] = true;
}
$statsByUrl = [];
foreach ($totals as $row) {
$statsByUrl[$row->short_url_id] = [
'total' => (int) $row->total,
'uniques' => (int) $row->uniques,
'qr_scans' => (int) $row->qr_scans,
'device_stats' => [],
'browser_stats' => [],
'os_stats' => [],
'country_stats' => [],
'city_stats' => [],
'referer_stats' => [],
'utm_source_stats' => [],
'utm_medium_stats' => [],
'utm_campaign_stats' => [],
'language_stats' => [],
'variant_stats' => [],
];
}
$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');
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;
}
// Helper to fetch and populate category stats natively in database GROUP BY
$populateStats = function (string $column, string $statsKey) use ($start, $end, &$statsByUrl): void {
$urlIds = array_keys($statsByUrl);
if (empty($urlIds)) {
return;
}
});
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'],
]);
}
$query = DB::table('short_url_visits')
->where('visited_at', '>=', $start)
->where('visited_at', '<', $end)
->whereIn('short_url_id', $urlIds)
->whereNotNull($column)
->where($column, '<>', '');
if ($statsKey === 'city_stats') {
$query->select(['short_url_id', 'city', 'country_code', DB::raw('count(*) as count')])
->groupBy(['short_url_id', 'city', 'country_code']);
} else {
$query->select(['short_url_id', $column, DB::raw('count(*) as count')])
->groupBy(['short_url_id', $column]);
}
$rows = $query->get();
foreach ($rows as $row) {
$urlId = $row->short_url_id;
if (! isset($statsByUrl[$urlId])) {
continue;
}
if ($statsKey === 'city_stats') {
$cityVal = $row->city;
$countryCode = $row->country_code;
$val = $countryCode ? "{$cityVal} ({$countryCode})" : $cityVal;
} else {
$val = $row->$column;
}
$statsByUrl[$urlId][$statsKey][$val] = (int) $row->count;
}
};
// Populate all categories via 11 quick indexed database aggregations
$populateStats('device_type', 'device_stats');
$populateStats('browser', 'browser_stats');
$populateStats('operating_system', 'os_stats');
$populateStats('country_code', 'country_stats');
$populateStats('city', 'city_stats');
$populateStats('referer_host', 'referer_stats');
$populateStats('utm_source', 'utm_source_stats');
$populateStats('utm_medium', 'utm_medium_stats');
$populateStats('utm_campaign', 'utm_campaign_stats');
$populateStats('browser_language', 'language_stats');
$populateStats('selected_variant', 'variant_stats');
// Write aggregated stats to ShortUrlDailyStats
foreach ($statsByUrl as $urlId => $s) {
ShortUrlDailyStats::updateOrCreate([
'short_url_id' => $urlId,
'date' => $date,
], [
'visits_count' => $s['total'],
'unique_visits_count' => $s['uniques'],
'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'],
'variant_stats' => $s['variant_stats'],
]);
}
});
$this->info("Aggregated stats for {$date}.");
}
@@ -129,6 +182,26 @@ class AggregateAndPruneVisitsCommand extends Command
$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;
}
}

View File

@@ -3,10 +3,10 @@
namespace Bjanczak\FilamentShortUrl\Console\Commands;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Illuminate\Cache\RedisStore;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
class SyncBufferedCountersCommand extends Command
{
@@ -21,15 +21,26 @@ class SyncBufferedCountersCommand extends Command
$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)) {
// Atomically pull the dirty-ID list so incoming increments during this command
// are written to a fresh list rather than being lost. Strategy is driver-aware:
// Redis uses RENAME + SMEMBERS (O(N)) for true atomicity; all other stores use
// Cache::pull() which is atomic on most drivers (file, database, memcached).
$store = Cache::store()->getStore();
$isRedis = $store instanceof RedisStore;
if ($isRedis) {
$tempKey = "{$dirtyKey}:temp:".time();
try {
Redis::rename($dirtyKey, $tempKey);
$dirtyIds = Redis::smembers($tempKey);
Redis::del($tempKey);
$conn = $store->connection();
if ($conn->exists($dirtyKey)) {
$conn->rename($dirtyKey, $tempKey);
$rawIds = $conn->smembers($tempKey);
$conn->del($tempKey);
$dirtyIds = $rawIds ?: [];
} else {
$dirtyIds = [];
}
} catch (\Throwable) {
// If key does not exist or rename fails, fallback
$dirtyIds = [];
}
} else {
@@ -42,26 +53,104 @@ class SyncBufferedCountersCommand extends Command
return 0;
}
$dirtyIds = array_unique(array_filter($dirtyIds));
$dirtyIds = array_unique(array_filter(array_map('intval', $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) {
// Perform a single atomic update query for this URL
ShortUrl::where('id', $id)->update([
'total_visits' => DB::raw("total_visits + {$totalDelta}"),
'unique_visits' => DB::raw("unique_visits + {$uniqueDelta}"),
]);
$processed++;
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))
->with('customDomain')
->get(['id', 'url_key', 'custom_domain_id']);
$appHost = parse_url(config('app.url'), PHP_URL_HOST);
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++;
}
// Bust redirect cache for all host-variant keys so subsequent requests
// see fresh counters (for max_visits enforcement etc.)
foreach ($shortUrls as $url) {
$hostsToForget = array_unique(array_filter([
'default',
$appHost,
$url->customDomain?->domain,
]));
foreach ($hostsToForget as $host) {
Cache::forget("filament-short-url:{$url->url_key}:{$host}");
}
}
});
} catch (\Throwable $e) {
// DB transaction failed — restore pulled cache values so no clicks are lost.
// Increments are used (not set) to safely merge with any new clicks that
// arrived during the failed transaction window.
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']);
}
}
// Restore dirty IDs using the same driver-aware strategy
if ($isRedis) {
$conn = $store->connection();
$conn->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;

View File

@@ -0,0 +1,32 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Filament\Forms\Components;
use Filament\Forms\Components\Field;
class TrafficSplitter extends Field
{
protected string $view = 'filament-short-url::forms.components.traffic-splitter';
protected string $target = 'rotation_variants';
public function target(string $target): static
{
$this->target = $target;
return $this;
}
public function getTargetStatePath(): string
{
$containerPath = $this->getContainer()->getStatePath();
return $containerPath ? $containerPath.'.'.$this->target : $this->target;
}
}

View File

@@ -0,0 +1,182 @@
<?php
namespace Bjanczak\FilamentShortUrl\Filament\Resources;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlCustomDomainResource\Pages\ListShortUrlCustomDomains;
use Bjanczak\FilamentShortUrl\Models\ShortUrlCustomDomain;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Actions\DeleteAction;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\Layout\Split;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\ToggleColumn;
use Filament\Tables\Columns\ViewColumn;
use Filament\Tables\Table;
class ShortUrlCustomDomainResource extends Resource
{
protected static ?string $model = ShortUrlCustomDomain::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-globe-alt';
protected static ?int $navigationSort = 52;
protected static ?string $recordTitleAttribute = 'domain';
public static function getNavigationLabel(): string
{
return __('filament-short-url::default.domains_navigation_label');
}
public static function getModelLabel(): string
{
return __('filament-short-url::default.domain_resource_title');
}
public static function getPluralModelLabel(): string
{
return __('filament-short-url::default.domains_navigation_label');
}
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 static::$navigationSort;
}
}
public static function form(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('domain')
->label(__('filament-short-url::default.domain_label'))
->required()
->maxLength(191)
->placeholder('e.g. links.acme.com')
->rules([
'regex:/^[a-zA-Z0-9][-a-zA-Z0-9]{0,62}(\.[a-zA-Z0-9][-a-zA-Z0-9]{0,62})+$/',
])
->unique(ignoreRecord: true)
->dehydrateStateUsing(fn ($state) => strtolower(trim($state))),
Toggle::make('is_active')
->label(__('filament-short-url::default.domain_status_active'))
->default(true),
])
->columns(1);
}
public static function table(Table $table): Table
{
return $table
->columns([
Split::make([
ViewColumn::make('domain')
->label(__('filament-short-url::default.domain_label'))
->view('filament-short-url::table.domain-column')
->searchable()
->grow(),
ToggleColumn::make('is_active')
->label(__('filament-short-url::default.domain_status_active')),
TextColumn::make('is_verified')
->label(__('filament-short-url::default.domain_verification_status'))
->badge()
->color(fn ($state) => $state ? 'success' : 'danger')
->formatStateUsing(fn ($state) => $state ? __('filament-short-url::default.domain_status_valid') : __('filament-short-url::default.domain_status_invalid')),
]),
])
->modifyQueryUsing(fn ($query) => $query
->withCount('shortUrls')
->withSum('shortUrls as total_clicks', 'total_visits')
)
->filters([])
->actions([
Action::make('dns_settings')
->label('')
->icon('heroicon-o-cog-6-tooth')
->color('gray')
->iconButton()
->badge(fn (ShortUrlCustomDomain $record): ?string => $record->is_verified ? null : '!')
->badgeColor('danger')
->modalHeading(fn (ShortUrlCustomDomain $record) => __('filament-short-url::default.dns_setup_title', ['domain' => $record->domain]))
->modalWidth('2xl')
->modalContent(fn (ShortUrlCustomDomain $record) => view('filament-short-url::table.dns-guide-panel', ['record' => $record]))
->modalSubmitAction(false)
->modalCancelAction(false)
->closeModalByClickingAway(false)
->extraAttributes([
'class' => 'action-trigger-btn group flex items-center justify-center gap-2 whitespace-nowrap rounded-lg border text-sm bg-bg-default text-content-emphasis hover:bg-bg-muted focus-visible:border-border-emphasis sm:inline-flex h-8 px-1.5 outline-none transition-all duration-200 border-transparent sm:group-hover/card:border-neutral-200',
'style' => 'width: 32px !important; height: 32px !important; padding: 0px !important; border-radius: 8px !important;',
]),
ActionGroup::make([
Action::make('verify')
->label(__('filament-short-url::default.action_verify_dns'))
->icon('heroicon-o-arrow-path')
->color('warning')
->action(function (ShortUrlCustomDomain $record) {
$verified = $record->verifyDns();
if ($verified) {
Notification::make()
->title(__('filament-short-url::default.dns_verify_success'))
->success()
->send();
} else {
Notification::make()
->title(__('filament-short-url::default.dns_verify_fail'))
->danger()
->send();
}
}),
ActionGroup::make([
DeleteAction::make()
->label(__('filament-short-url::default.action_delete_domain'))
->icon('heroicon-o-trash')
->color('danger')
->modalHeading(__('filament-short-url::default.action_delete_domain'))
->modalDescription(__('filament-short-url::default.delete_domain_confirmation_desc'))
->modalSubmitActionLabel(__('filament-short-url::default.action_delete_domain'))
->closeModalByClickingAway(false),
])->dropdown(false),
])
->icon('heroicon-m-ellipsis-vertical')
->color('gray')
->iconButton()
->extraAttributes([
'class' => 'action-trigger-btn group flex items-center justify-center gap-2 whitespace-nowrap rounded-lg border text-sm bg-bg-default text-content-emphasis hover:bg-bg-muted focus-visible:border-border-emphasis data-[state=open]:ring-4 data-[state=open]:ring-border-subtle sm:inline-flex h-8 px-1.5 outline-none transition-all duration-200 border-transparent data-[state=open]:border-neutral-500 sm:group-hover/card:data-[state=closed]:border-neutral-200',
'style' => 'width: 32px !important; height: 32px !important; padding: 0px !important; border-radius: 8px !important;',
])
->label(''),
])
->bulkActions([])
->defaultSort('created_at', 'desc')
->emptyState(view('filament-short-url::table.empty-state-domains'));
}
public static function getPages(): array
{
return [
'index' => ListShortUrlCustomDomains::route('/'),
];
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlCustomDomainResource\Pages;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlCustomDomainResource;
use Bjanczak\FilamentShortUrl\Models\ShortUrlCustomDomain;
use Filament\Actions\Action;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ManageRecords;
use Livewire\Attributes\On;
class ListShortUrlCustomDomains extends ManageRecords
{
protected static string $resource = ShortUrlCustomDomainResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make()
->label(__('filament-short-url::default.empty_state_domain_action'))
->modalHeading(__('filament-short-url::default.empty_state_domain_action'))
->icon('heroicon-o-plus')
->size('sm')
->color('primary')
->modalWidth('md')
->modalAutofocus(false)
->closeModalByClickingAway(false)
->after(function ($livewire, $record) {
$livewire->dispatch('mount-dns-setup-modal', recordId: $record->id);
}),
Action::make('dns_setup_modal')
->modalHeading(function (array $arguments) {
$record = ShortUrlCustomDomain::find($arguments['record'] ?? null);
return $record ? __('filament-short-url::default.dns_setup_title', ['domain' => $record->domain]) : '';
})
->modalWidth('2xl')
->modalContent(function (array $arguments) {
$record = ShortUrlCustomDomain::find($arguments['record'] ?? null);
return $record ? view('filament-short-url::table.dns-guide-panel', ['record' => $record]) : null;
})
->modalSubmitAction(false)
->modalCancelAction(false)
->closeModalByClickingAway(false)
->extraAttributes(['class' => 'hidden']),
];
}
#[On('mount-dns-setup-modal')]
public function openDnsSetupModal(int $recordId): void
{
$this->mountAction('dns_setup_modal', ['record' => $recordId]);
}
}

View File

@@ -0,0 +1,162 @@
<?php
namespace Bjanczak\FilamentShortUrl\Filament\Resources;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlPixelResource\Pages\ListShortUrlPixels;
use Bjanczak\FilamentShortUrl\Models\ShortUrlPixel;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
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');
}
public static function getModelLabel(): string
{
return __('filament-short-url::default.pixel_resource_title');
}
public static function getPluralModelLabel(): string
{
return __('filament-short-url::default.pixels_navigation_label');
}
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'))
->required()
->maxLength(150)
->placeholder('e.g. Meta Ads - Yacht Promo'),
Select::make('type')
->label(__('filament-short-url::default.pixel_type'))
->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'))
->required()
->maxLength(100)
->placeholder('e.g. 1234567890 or G-XXXXXXXXXX'),
Toggle::make('is_active')
->label(__('filament-short-url::default.pixel_status_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'))
->searchable()
->sortable()
->weight('bold'),
TextColumn::make('type')
->label(__('filament-short-url::default.pixel_type'))
->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'))
->searchable()
->copyable()
->fontFamily('mono')
->color('gray'),
ToggleColumn::make('is_active')
->label(__('filament-short-url::default.pixel_status_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([])
->defaultSort('created_at', 'desc')
->emptyState(view('filament-short-url::table.empty-state-pixels'));
}
public static function getPages(): array
{
return [
'index' => ListShortUrlPixels::route('/'),
];
}
}

View File

@@ -0,0 +1,26 @@
<?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()
->label(__('filament-short-url::default.empty_state_pixel_action'))
->modalHeading(__('filament-short-url::default.empty_state_pixel_action'))
->icon('heroicon-o-plus')
->size('sm')
->color('primary')
->modalWidth('md')
->modalAutofocus(false),
];
}
}

View File

@@ -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;
@@ -113,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'),
];
}
}

View File

@@ -4,10 +4,13 @@ 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
{
@@ -16,25 +19,372 @@ 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()
->label(__('filament-short-url::default.empty_state_action'))
->modalHeading(__('filament-short-url::default.empty_state_action'))
->icon('heroicon-o-plus')
->size('sm')
->color('primary')
->modalWidth('4xl')
->mutateFormDataUsing(function (array $data): array {
// Auto-generate key if not provided
->modalAutofocus(false)
->mutateFormDataUsing(function (array $data, ShortUrlService $service): array {
if (empty($data['url_key'])) {
$data['url_key'] = app(ShortUrlService::class)->generateKey();
$data['url_key'] = $service->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');
$successSubtitle = __('filament-short-url::default.success_modal_subtitle');
$successHelper = __('filament-short-url::default.success_modal_helper');
$downloadSvgText = __('filament-short-url::default.qr_download_svg');
$downloadPngText = __('filament-short-url::default.qr_download_png');
$closeButtonText = __('filament-short-url::default.close_button');
$copyLinkText = __('filament-short-url::default.action_copy');
$qrCodeText = __('filament-short-url::default.action_qr');
$openLinkText = __('filament-short-url::default.open_link');
$dontShowAgainText = __('filament-short-url::default.dont_show_again');
$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)),
];
}),
];
}

View File

@@ -0,0 +1,179 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs;
use Bjanczak\FilamentShortUrl\Services\SafeBrowsingService;
use Filament\Actions\Action;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
class AdvancedTab
{
/**
* Build the advanced & security settings form tab.
*/
public static function make(): Tab
{
return Tab::make(__('filament-short-url::default.settings_tab_advanced'))
->key('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, SafeBrowsingService $svc): 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 {
$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')),
]),
]);
}
}

View File

@@ -0,0 +1,83 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
class DeepLinkingTab
{
/**
* Build the deep linking settings form tab.
*/
public static function make(): Tab
{
return Tab::make(__('filament-short-url::default.settings_tab_deep_linking'))
->key('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'));
}
};
},
]),
]),
]);
}
}

View File

@@ -0,0 +1,112 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
class DeveloperTab
{
/**
* Build the developer API and webhooks settings form tab.
*/
public static function make(): Tab
{
return Tab::make(__('filament-short-url::default.settings_tab_developer'))
->key('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']);
$set('webhook_signing_secret', null);
}
}),
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')),
TextInput::make('webhook_signing_secret')
->label('Webhook Signing Secret')
->helperText('If configured, outgoing webhook requests will include the HMAC signature in X-ShortUrl-Signature.')
->password()
->revealable()
->placeholder('••••••••••••••••••••')
->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')),
]),
]);
}
}

View File

@@ -0,0 +1,107 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs;
use Filament\Actions\Action;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Schemas\Components\Actions;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
use Illuminate\Support\Facades\Http;
class Ga4Tab
{
/**
* Build the GA4 settings form tab.
*/
public static function make(): Tab
{
return Tab::make(__('filament-short-url::default.settings_tab_ga4'))
->key('ga4')
->icon('heroicon-o-chart-bar')
->schema([
Section::make(__('filament-short-url::default.settings_section_ga4'))
->description(__('filament-short-url::default.settings_ga4_description'))
->schema([
TextInput::make('ga4_api_secret')
->label(__('filament-short-url::default.settings_ga4_api_secret'))
->helperText(__('filament-short-url::default.settings_ga4_api_secret_helper'))
->password()
->revealable()
->placeholder('••••••••••••••••••••'),
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'),
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 {
$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'] ?? [];
$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();
}
}),
]),
]),
]);
}
}

View File

@@ -0,0 +1,160 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
use Illuminate\Support\HtmlString;
class GeneralTab
{
/**
* Build the general settings form tab.
*/
public static function make(): Tab
{
return Tab::make(__('filament-short-url::default.settings_tab_general'))
->key('general')
->icon('heroicon-o-link')
->schema([
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'))
->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'),
])
->required(),
Toggle::make('lock_url_key')
->label(__('filament-short-url::default.settings_lock_url_key'))
->helperText(__('filament-short-url::default.settings_lock_url_key_helper'))
->inline(false),
Toggle::make('disable_default_domain')
->label(__('filament-short-url::default.settings_disable_default_domain'))
->helperText(__('filament-short-url::default.settings_disable_default_domain_helper'))
->inline(false),
TextInput::make('key_length')
->label(__('filament-short-url::default.settings_key_length'))
->helperText(__('filament-short-url::default.settings_key_length_helper'))
->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'))
->helperText(__('filament-short-url::default.settings_queue_connection_helper'))
->options(function (): array {
$connections = array_keys(config('queue.connections', []));
return array_combine($connections, $connections) ?: [
'sync' => 'sync',
'database' => 'database',
'redis' => 'redis',
];
})
->required()
->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(),
]),
]);
}
}

View File

@@ -0,0 +1,154 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs;
use Filament\Actions\Action;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Schemas\Components\Actions;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
use Illuminate\Support\HtmlString;
class GeoIpTab
{
/**
* Build the geo-ip settings form tab.
*/
public static function make(): Tab
{
return Tab::make(__('filament-short-url::default.settings_tab_geoip'))
->key('geoip')
->icon('heroicon-o-globe-alt')
->schema([
Section::make(__('filament-short-url::default.settings_section_geoip'))
->columns(2)
->schema([
Toggle::make('geo_ip_enabled')
->label(__('filament-short-url::default.settings_geoip_enabled'))
->helperText(__('filament-short-url::default.settings_geoip_enabled_helper'))
->columnSpanFull()
->inline(false)
->live(),
Select::make('geo_ip_driver')
->label(__('filament-short-url::default.settings_geoip_driver'))
->helperText(__('filament-short-url::default.settings_geoip_driver_helper'))
->options([
'headers' => __('filament-short-url::default.settings_geoip_driver_headers'),
'maxmind' => __('filament-short-url::default.settings_geoip_driver_maxmind'),
'ip-api' => __('filament-short-url::default.settings_geoip_driver_ipapi'),
])
->required()
->live()
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled')),
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(),
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')),
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')),
TextInput::make('geo_ip_timeout')
->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')
->required()
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled') && $get('geo_ip_driver') === 'ip-api'),
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('/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'),
]),
]);
}
}

View File

@@ -0,0 +1,101 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs;
use Filament\Forms\Components\ColorPicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
class QrDefaultsTab
{
/**
* Build the QR defaults settings form tab.
*/
public static function make(): Tab
{
return Tab::make(__('filament-short-url::default.settings_tab_qr'))
->key('qr-defaults')
->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')),
]),
]);
}
}

View File

@@ -0,0 +1,86 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
class TrackingDefaultsTab
{
/**
* Build the tracking defaults form tab.
*/
public static function make(): Tab
{
return Tab::make(__('filament-short-url::default.settings_tab_tracking_defaults'))
->key('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_anonymize_ips')
->label(__('filament-short-url::default.settings_track_anonymize_ips'))
->helperText(__('filament-short-url::default.settings_track_anonymize_ips_helper'))
->inline(false)
->columnSpanFull()
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
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),
]);
}
}

View File

@@ -3,40 +3,106 @@
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs\AdvancedTab;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs\DeepLinkingTab;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs\DeveloperTab;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs\Ga4Tab;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs\GeneralTab;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs\GeoIpTab;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs\QrDefaultsTab;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs\TrackingDefaultsTab;
use Bjanczak\FilamentShortUrl\FilamentShortUrlPlugin;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Bjanczak\FilamentShortUrl\Services\ShortUrlSettingsManager;
use Filament\Actions\Action;
use Filament\Forms\Components\ColorPicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\Page;
use Filament\Schemas\Components\Actions;
use Filament\Schemas\Components\Section;
use Filament\Pages\Page;
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\Http;
use Illuminate\Support\Facades\Gate;
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');
}
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() + 3;
} catch (\Throwable) {
return 53;
}
}
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
public function mount(ShortUrlSettingsManager $mgr): 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'),
'lock_url_key' => $mgr->get('lock_url_key', false),
'disable_default_domain' => $mgr->get('disable_default_domain', false),
'redirect_status_code' => $mgr->get('redirect_status_code', 302),
'key_length' => $mgr->get('key_length', 6),
'cache_ttl' => $mgr->get('cache_ttl', 3600),
@@ -58,6 +124,7 @@ class ShortUrlSettingsPage extends Page implements HasForms
'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_anonymize_ips' => $mgr->get('tracking_anonymize_ips', false),
'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),
@@ -65,6 +132,7 @@ class ShortUrlSettingsPage extends Page implements HasForms
'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'),
@@ -76,7 +144,23 @@ class ShortUrlSettingsPage extends Page implements HasForms
'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,
// Webhook signing secret
'webhook_signing_secret' => $mgr->get('webhook_signing_secret'),
]);
}
@@ -87,524 +171,47 @@ class ShortUrlSettingsPage extends Page implements HasForms
Tabs::make('ShortUrlSettings')
->persistTabInQueryString()
->tabs([
// ── General ──────────────────────────────────────────
Tab::make(__('filament-short-url::default.settings_tab_general'))
->icon('heroicon-o-link')
->schema([
Section::make(__('filament-short-url::default.settings_section_routing'))
->columns(2)
->schema([
TextInput::make('route_prefix')
->label(__('filament-short-url::default.settings_route_prefix'))
->helperText(__('filament-short-url::default.settings_route_prefix_helper'))
->required()
->alphaDash()
->maxLength(20),
Select::make('redirect_status_code')
->label(__('filament-short-url::default.redirect_code'))
->options([
302 => __('filament-short-url::default.redirect_code_302'),
301 => __('filament-short-url::default.redirect_code_301'),
])
->required(),
TextInput::make('key_length')
->label(__('filament-short-url::default.settings_key_length'))
->helperText(__('filament-short-url::default.settings_key_length_helper'))
->numeric()
->integer()
->minValue(4)
->maxValue(20)
->required(),
TextInput::make('cache_ttl')
->label(__('filament-short-url::default.settings_cache_ttl'))
->helperText(__('filament-short-url::default.settings_cache_ttl_helper'))
->numeric()
->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),
]),
Section::make(__('filament-short-url::default.settings_section_queue'))
->columns(2)
->schema([
Select::make('queue_connection')
->label(__('filament-short-url::default.settings_queue_connection'))
->helperText(__('filament-short-url::default.settings_queue_connection_helper'))
->options(function (): array {
$connections = array_keys(config('queue.connections', []));
return array_combine($connections, $connections) ?: [
'sync' => 'sync',
'database' => 'database',
'redis' => 'redis',
];
})
->required(),
TextInput::make('queue_name')
->label(__('filament-short-url::default.settings_queue_name'))
->helperText(__('filament-short-url::default.settings_queue_name_helper'))
->required(),
]),
Section::make(__('filament-short-url::default.settings_section_buffering'))
->schema([
Toggle::make('counter_buffering_enabled')
->label(__('filament-short-url::default.settings_buffering_enabled'))
->helperText(__('filament-short-url::default.settings_buffering_helper'))
->inline(false),
]),
]),
// ── Geo-IP ───────────────────────────────────────────
Tab::make(__('filament-short-url::default.settings_tab_geoip'))
->icon('heroicon-o-globe-alt')
->schema([
Section::make(__('filament-short-url::default.settings_section_geoip'))
->columns(2)
->schema([
Toggle::make('geo_ip_enabled')
->label(__('filament-short-url::default.settings_geoip_enabled'))
->helperText(__('filament-short-url::default.settings_geoip_enabled_helper'))
->columnSpanFull()
->inline(false)
->live(),
// ── Driver (only when geo-ip is on) ──
Select::make('geo_ip_driver')
->label(__('filament-short-url::default.settings_geoip_driver'))
->helperText(__('filament-short-url::default.settings_geoip_driver_helper'))
->options([
'headers' => __('filament-short-url::default.settings_geoip_driver_headers'),
'maxmind' => __('filament-short-url::default.settings_geoip_driver_maxmind'),
'ip-api' => __('filament-short-url::default.settings_geoip_driver_ipapi'),
])
->required()
->live()
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled')),
// ── Cache TTL (only when geo-ip is on) ──
TextInput::make('geo_ip_cache_ttl')
->label(__('filament-short-url::default.settings_geoip_cache_ttl'))
->helperText(__('filament-short-url::default.settings_geoip_cache_ttl_helper'))
->numeric()
->integer()
->minValue(0)
->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)
->suffix('s')
->required()
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled')),
// ── Timeout (only for ip-api driver) ──
TextInput::make('geo_ip_timeout')
->label(__('filament-short-url::default.settings_geoip_timeout'))
->helperText(__('filament-short-url::default.settings_geoip_timeout_helper'))
->numeric()
->integer()
->minValue(1)
->maxValue(30)
->suffix('s')
->required()
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled') && $get('geo_ip_driver') === 'ip-api'),
// ── MaxMind path (only for maxmind driver) ──
TextInput::make('maxmind_database_path')
->label(__('filament-short-url::default.settings_maxmind_path'))
->helperText(__('filament-short-url::default.settings_maxmind_path_helper'))
->columnSpanFull()
->placeholder('/var/www/html/database/geoip/GeoLite2-Country.mmdb')
->suffixAction(
Action::make('verifyMaxmindPath')
->label(__('filament-short-url::default.settings_maxmind_verify'))
->icon('heroicon-o-server')
->action(function (?string $state): void {
$path = trim($state ?? '');
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'),
]),
]),
// ── Google Analytics 4 ───────────────────────────────
Tab::make(__('filament-short-url::default.settings_tab_ga4'))
->icon('heroicon-o-chart-bar')
->schema([
Section::make(__('filament-short-url::default.settings_section_ga4'))
->description(__('filament-short-url::default.settings_ga4_description'))
->schema([
TextInput::make('ga4_api_secret')
->label(__('filament-short-url::default.settings_ga4_api_secret'))
->helperText(__('filament-short-url::default.settings_ga4_api_secret_helper'))
->password()
->revealable()
->placeholder('••••••••••••••••••••'),
// 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'),
// 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')),
]),
]),
// ── 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_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_global_webhook'))
->schema([
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(),
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(),
]),
Section::make(__('filament-short-url::default.settings_section_api_keys'))
->description(__('filament-short-url::default.settings_api_keys_description'))
->schema([
\Filament\Forms\Components\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([]),
]),
]),
GeneralTab::make(),
GeoIpTab::make(),
Ga4Tab::make(),
AdvancedTab::make(),
TrackingDefaultsTab::make(),
QrDefaultsTab::make(),
DeveloperTab::make(),
DeepLinkingTab::make(),
]),
])
->statePath('data');
}
public function save(): void
public function save(ShortUrlSettingsManager $mgr): void
{
$data = $this->form->getState();
app(ShortUrlSettingsManager::class)->set($data);
$mgr->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()
->send();
if ($newKeys = session()->get('fsu_new_api_keys')) {
foreach ($newKeys as $newKey) {
Notification::make()
->title(__('filament-short-url::default.api_key_generated'))
->body(new HtmlString('<strong>'.$newKey['name'].'</strong>: '.__('filament-short-url::default.api_key_warning')."<br/><code style='background:#e4e4e7;color:#18181b;padding:6px;border-radius:4px;user-select:all;display:block;margin-top:6px;font-family:monospace;font-weight:bold;'>".$newKey['plain'].'</code>'))
->warning()
->persistent()
->send();
}
// Re-mount settings form state to reflect the new masked key names in the UI
$this->mount($mgr);
}
}
protected function getHeaderActions(): array
@@ -615,7 +222,7 @@ class ShortUrlSettingsPage extends Page implements HasForms
->icon('heroicon-o-arrow-left')
->color('gray')
->size('sm')
->url(static::getResource()::getUrl()),
->url(ShortUrlResource::getUrl()),
];
}

View File

@@ -6,10 +6,13 @@ use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Resources\Pages\Page;
use Filament\Schemas\Components\Grid;
use Filament\Tables;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
@@ -40,49 +43,153 @@ class ViewShortUrlLogs extends Page implements HasForms, HasTable
->columns([
Tables\Columns\TextColumn::make('visited_at')
->label(__('filament-short-url::default.stats_col_time'))
->dateTime()
->dateTime('M j, g:i A')
->sortable(),
Tables\Columns\TextColumn::make('shortUrl.url_key')
->label(__('filament-short-url::default.stats_col_link'))
->icon('heroicon-o-link')
->formatStateUsing(function ($state, $record) {
if (! $record->shortUrl) {
return '—';
}
return str_replace(['http://', 'https://'], '', $record->shortUrl->getShortUrl());
})
->sortable()
->toggleable(),
Tables\Columns\TextColumm::make('referer_host')
->label(__('filament-short-url::default.stats_col_referrer'))
->icon('heroicon-o-link')
->formatStateUsing(fn ($state) => empty($state) ? __('filament-short-url::default.stats_referer_direct') : $state)
->color(fn ($state) => empty($state) ? 'gray' : null)
->searchable()
->sortable()
->toggleable(),
Tables\Columns\TextColumn::make('country')
->label(__('filament-short-url::default.stats_col_country'))
->searchable()
->sortable()
->formatStateUsing(function ($state, $record) {
if (! $state) {
return '—';
}
if ($record->country_code) {
try {
$flag = implode('', array_map(fn ($char) => mb_chr(ord($char) + 127397), str_split(strtoupper($record->country_code))));
return "{$flag} {$state}";
} catch (\Throwable) {
return $state;
}
}
return $state;
})
->placeholder('—')
->toggleable(),
Tables\Columns\TextColumn::make('device_type')
->label(__('filament-short-url::default.stats_col_device'))
->icon(fn ($state) => match (strtolower($state)) {
'desktop' => 'heroicon-m-computer-desktop',
'mobile' => 'heroicon-m-device-phone-mobile',
'tablet' => 'heroicon-m-device-tablet',
default => 'heroicon-m-question-mark-circle',
})
->formatStateUsing(fn ($state) => match (strtolower($state)) {
'desktop' => __('filament-short-url::default.stats_device_desktop'),
'mobile' => __('filament-short-url::default.stats_device_mobile'),
'tablet' => __('filament-short-url::default.stats_device_tablet'),
default => ucfirst($state),
})
->searchable()
->sortable()
->toggleable(),
// Hidden/toggleable columns for advanced details
Tables\Columns\TextColumn::make('ip_address')
->label(__('filament-short-url::default.stats_col_ip'))
->searchable()
->sortable()
->placeholder('—')
->toggleable(),
Tables\Columns\TextColumn::make('country')
->label(__('filament-short-url::default.stats_col_country'))
->searchable()
->sortable()
->formatStateUsing(fn ($state, $record) => $record->country_code ? "{$record->country_code} - {$state}" : ($state ?? '—'))
->placeholder('—')
->toggleable(),
Tables\Columns\TextColumn::make('device_type')
->label(__('filament-short-url::default.stats_col_device'))
->searchable()
->sortable()
->formatStateUsing(fn ($state) => ucfirst($state))
->placeholder('—')
->toggleable(),
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('browser')
->label(__('filament-short-url::default.stats_col_browser'))
->searchable()
->sortable()
->formatStateUsing(fn ($state, $record) => $record->browser_version ? "{$state} ({$record->browser_version})" : ($state ?? '—'))
->placeholder('—')
->toggleable(),
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('operating_system')
->label(__('filament-short-url::default.stats_col_os'))
->searchable()
->sortable()
->formatStateUsing(fn ($state, $record) => $record->operating_system_version ? "{$state} ({$record->operating_system_version})" : ($state ?? '—'))
->placeholder('—')
->toggleable(),
Tables\Columns\TextColumn::make('referer_url')
->label(__('filament-short-url::default.track_referer'))
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('browser_language')
->label(__('filament-short-url::default.stats_col_language'))
->searchable()
->sortable()
->limit(30)
->placeholder('—')
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('utm_source')
->label(__('filament-short-url::default.stats_col_utm_source'))
->searchable()
->placeholder('—')
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('utm_medium')
->label(__('filament-short-url::default.stats_col_utm_medium'))
->searchable()
->placeholder('—')
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('utm_campaign')
->label(__('filament-short-url::default.stats_col_utm_campaign'))
->searchable()
->placeholder('—')
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('utm_term')
->label(__('filament-short-url::default.stats_col_utm_term'))
->searchable()
->placeholder('—')
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('utm_content')
->label(__('filament-short-url::default.stats_col_utm_content'))
->searchable()
->placeholder('—')
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('selected_variant')
->label(__('filament-short-url::default.stats_col_variant'))
->searchable()
->placeholder('—')
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\IconColumn::make('is_qr_scan')
->label(__('filament-short-url::default.stats_col_qr_scan'))
->boolean()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\IconColumn::make('is_bot')
->label(__('filament-short-url::default.stats_col_bot'))
->boolean()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\IconColumn::make('is_proxy')
->label(__('filament-short-url::default.stats_col_proxy'))
->boolean()
->toggleable(isToggledHiddenByDefault: true),
])
->defaultSort('visited_at', 'desc')
->paginated([10, 25, 50, 100])
@@ -90,10 +197,10 @@ class ViewShortUrlLogs extends Page implements HasForms, HasTable
Tables\Filters\SelectFilter::make('device_type')
->label(__('filament-short-url::default.stats_col_device'))
->options([
'desktop' => 'Desktop',
'mobile' => 'Mobile',
'tablet' => 'Tablet',
'robot' => 'Robot / Bot',
'desktop' => __('filament-short-url::default.stats_device_desktop'),
'mobile' => __('filament-short-url::default.stats_device_mobile'),
'tablet' => __('filament-short-url::default.stats_device_tablet'),
'robot' => __('filament-short-url::default.stats_device_bot'),
]),
Tables\Filters\SelectFilter::make('country_code')
@@ -122,6 +229,76 @@ class ViewShortUrlLogs extends Page implements HasForms, HasTable
->when($data['visited_until'], fn ($q, $date) => $q->where('visited_at', '<=', $date.' 23:59:59'));
}),
])
->actions([
ActionGroup::make([
Action::make('details')
->label(__('filament-short-url::default.stats_action_view_details'))
->icon('heroicon-o-information-circle')
->modalHeading(__('filament-short-url::default.stats_modal_visit_details'))
->modalWidth('md')
->form([
Grid::make(2)
->schema([
Placeholder::make('ip_address')
->label(__('filament-short-url::default.stats_csv_ip'))
->content(fn ($record) => $record->ip_address ?? '—'),
Placeholder::make('country')
->label(__('filament-short-url::default.stats_detail_location'))
->content(fn ($record) => $record->country
? ($record->country_code ? implode('', array_map(fn ($char) => mb_chr(ord($char) + 127397), str_split(strtoupper($record->country_code)))).' '.$record->country : $record->country)
: '—'),
Placeholder::make('device')
->label(__('filament-short-url::default.stats_col_device'))
->content(fn ($record) => $record->device_type ? match (strtolower($record->device_type)) {
'desktop' => __('filament-short-url::default.stats_device_desktop'),
'mobile' => __('filament-short-url::default.stats_device_mobile'),
'tablet' => __('filament-short-url::default.stats_device_tablet'),
default => ucfirst($record->device_type)
} : '—'),
Placeholder::make('browser')
->label(__('filament-short-url::default.stats_col_browser'))
->content(fn ($record) => $record->browser ? "{$record->browser} ({$record->browser_version})" : '—'),
Placeholder::make('os')
->label(__('filament-short-url::default.stats_col_os'))
->content(fn ($record) => $record->operating_system ? "{$record->operating_system} ({$record->operating_system_version})" : '—'),
Placeholder::make('referer')
->label(__('filament-short-url::default.stats_col_referrer'))
->content(fn ($record) => $record->referer_url ?? '—')
->columnSpanFull(),
Placeholder::make('utm_source')
->label(__('filament-short-url::default.stats_col_utm_source'))
->content(fn ($record) => $record->utm_source ?? '—'),
Placeholder::make('utm_medium')
->label(__('filament-short-url::default.stats_col_utm_medium'))
->content(fn ($record) => $record->utm_medium ?? '—'),
Placeholder::make('utm_campaign')
->label(__('filament-short-url::default.stats_col_utm_campaign'))
->content(fn ($record) => $record->utm_campaign ?? '—'),
Placeholder::make('utm_term')
->label(__('filament-short-url::default.stats_col_utm_term'))
->content(fn ($record) => $record->utm_term ?? '—'),
Placeholder::make('utm_content')
->label(__('filament-short-url::default.stats_col_utm_content'))
->content(fn ($record) => $record->utm_content ?? '—'),
Placeholder::make('selected_variant')
->label(__('filament-short-url::default.stats_col_variant'))
->content(fn ($record) => $record->selected_variant ?? '—'),
Placeholder::make('is_bot')
->label(__('filament-short-url::default.stats_detail_is_bot'))
->content(fn ($record) => $record->is_bot ? __('filament-short-url::default.stats_yes') : __('filament-short-url::default.stats_no')),
Placeholder::make('is_proxy')
->label(__('filament-short-url::default.stats_detail_is_proxy'))
->content(fn ($record) => $record->is_proxy ? __('filament-short-url::default.stats_yes') : __('filament-short-url::default.stats_no')),
Placeholder::make('is_qr_scan')
->label(__('filament-short-url::default.stats_detail_is_qr'))
->content(fn ($record) => $record->is_qr_scan ? __('filament-short-url::default.stats_yes') : __('filament-short-url::default.stats_no')),
Placeholder::make('visited_at')
->label(__('filament-short-url::default.stats_detail_visited_at'))
->content(fn ($record) => $record->visited_at->toDateTimeString()),
]),
]),
]),
])
->headerActions([
Action::make('export_csv')
->label(__('filament-short-url::default.stats_action_export'))
@@ -141,6 +318,15 @@ class ViewShortUrlLogs extends Page implements HasForms, HasTable
__('filament-short-url::default.stats_csv_browser'),
__('filament-short-url::default.stats_csv_os'),
__('filament-short-url::default.stats_csv_referer'),
__('filament-short-url::default.stats_csv_utm_source'),
__('filament-short-url::default.stats_csv_utm_medium'),
__('filament-short-url::default.stats_csv_utm_campaign'),
__('filament-short-url::default.stats_csv_utm_term'),
__('filament-short-url::default.stats_csv_utm_content'),
__('filament-short-url::default.stats_csv_variant'),
__('filament-short-url::default.stats_csv_qr_scan'),
__('filament-short-url::default.stats_csv_bot'),
__('filament-short-url::default.stats_csv_proxy'),
]);
$livewire->getFilteredTableQuery()
@@ -151,10 +337,24 @@ class ViewShortUrlLogs extends Page implements HasForms, HasTable
$visit->visited_at->toDateTimeString(),
$visit->ip_address ?? '—',
$visit->country ? "{$visit->country_code} - {$visit->country}" : '—',
ucfirst($visit->device_type ?? '—'),
$visit->device_type ? match (strtolower($visit->device_type)) {
'desktop' => __('filament-short-url::default.stats_device_desktop'),
'mobile' => __('filament-short-url::default.stats_device_mobile'),
'tablet' => __('filament-short-url::default.stats_device_tablet'),
default => ucfirst($visit->device_type)
} : '—',
$visit->browser ? "{$visit->browser} ({$visit->browser_version})" : '—',
$visit->operating_system ? "{$visit->operating_system} ({$visit->operating_system_version})" : '—',
$visit->referer_url ?? '—',
$visit->utm_source ?? '—',
$visit->utm_medium ?? '—',
$visit->utm_campaign ?? '—',
$visit->utm_term ?? '—',
$visit->utm_content ?? '—',
$visit->selected_variant ?? '—',
$visit->is_qr_scan ? __('filament-short-url::default.stats_yes') : __('filament-short-url::default.stats_no'),
$visit->is_bot ? __('filament-short-url::default.stats_yes') : __('filament-short-url::default.stats_no'),
$visit->is_proxy ? __('filament-short-url::default.stats_yes') : __('filament-short-url::default.stats_no'),
]);
}
});

View File

@@ -9,8 +9,10 @@ use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\Page;
use Filament\Schemas\Schema;
use Livewire\Attributes\On;
class ViewShortUrlStats extends Page implements HasForms
{
@@ -28,6 +30,50 @@ class ViewShortUrlStats extends Page implements HasForms
public ?array $filterData = [];
public array $activeFilters = [];
#[On('set-stats-filter')]
public function setStatsFilter(string $key, $value): void
{
if (count($this->activeFilters) >= 5 && ! isset($this->activeFilters[$key])) {
Notification::make()
->title(__('filament-short-url::default.stats_filter_limit_exceeded'))
->warning()
->send();
return;
}
$this->activeFilters[$key] = $value;
$this->totalVisits = $this->record->getCachedStats(
$this->filterData['date_from'] ?? null,
$this->filterData['date_to'] ?? null,
$this->activeFilters
)['totalVisits'] ?? 0;
}
#[On('clear-stats-filter')]
public function clearStatsFilter(string $key): void
{
unset($this->activeFilters[$key]);
$this->totalVisits = $this->record->getCachedStats(
$this->filterData['date_from'] ?? null,
$this->filterData['date_to'] ?? null,
$this->activeFilters
)['totalVisits'] ?? 0;
}
#[On('clear-all-stats-filters')]
public function clearAllStatsFilters(): void
{
$this->activeFilters = [];
$this->totalVisits = $this->record->getCachedStats(
$this->filterData['date_from'] ?? null,
$this->filterData['date_to'] ?? null,
$this->activeFilters
)['totalVisits'] ?? 0;
}
public function mount(ShortUrl $record): void
{
$this->record = $record;
@@ -46,7 +92,11 @@ class ViewShortUrlStats extends Page implements HasForms
];
}
$this->totalVisits = $record->getCachedStats($this->filterData['date_from'], $this->filterData['date_to'])['totalVisits'] ?? 0;
$this->totalVisits = $record->getCachedStats(
$this->filterData['date_from'] ?? null,
$this->filterData['date_to'] ?? null,
$this->activeFilters
)['totalVisits'] ?? 0;
}
public function form(Schema $schema): Schema
@@ -116,6 +166,25 @@ class ViewShortUrlStats extends Page implements HasForms
->color('gray')
->url(ShortUrlResource::getUrl()),
Action::make('refresh')
->label(__('filament-short-url::default.stats_btn_refresh'))
->icon('heroicon-o-arrow-path')
->color('gray')
->action(function () {
$this->record->clearStatsCache(
$this->filterData['date_from'] ?? null,
$this->filterData['date_to'] ?? null,
$this->activeFilters
);
$this->dispatch('$refresh');
Notification::make()
->title(__('filament-short-url::default.stats_refresh_success'))
->success()
->send();
}),
Action::make('copy_url')
->label(__('filament-short-url::default.stats_btn_copy'))
->icon('heroicon-o-clipboard')

View File

@@ -1,520 +1,39 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas;
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
use Filament\Actions\Action;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\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\Section;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs\AppLinkingTab;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs\LinkTab;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs\MarketingTab;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs\QrDesignTab;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs\TargetingTab;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs\TrackingTab;
use Filament\Schemas\Components\Tabs;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Schema;
class ShortUrlForm
{
/**
* Configure the short URL resource form schema.
*/
public static function configure(Schema $schema): Schema
{
return $schema->components([
Tabs::make()->tabs([
static::linkTab(),
static::targetingTab(),
static::trackingTab(),
static::marketingTab(),
static::qrDesignTab(),
])->columnSpanFull(),
Tabs::make()
->tabs([
LinkTab::make(),
TargetingTab::make(),
AppLinkingTab::make(),
TrackingTab::make(),
MarketingTab::make(),
QrDesignTab::make(),
])->columnSpanFull(),
]);
}
private static function linkTab(): Tab
{
return Tab::make(__('filament-short-url::default.tab_link'))
->icon('heroicon-o-link')
->schema([
Section::make()->schema([
TextInput::make('destination_url')
->label(__('filament-short-url::default.destination_url'))
->helperText(__('filament-short-url::default.destination_url_helper'))
->required()
->url()
->maxLength(2048)
->live(onBlur: true)
->afterStateHydrated(function (TextInput $component, $state, Set $set) {
if (! $state) {
return;
}
$parts = parse_url($state);
if (isset($parts['query'])) {
parse_str($parts['query'], $query);
$set('utm_source', $query['utm_source'] ?? null);
$set('utm_medium', $query['utm_medium'] ?? null);
$set('utm_campaign', $query['utm_campaign'] ?? null);
$set('utm_term', $query['utm_term'] ?? null);
$set('utm_content', $query['utm_content'] ?? null);
}
})
->afterStateUpdated(function ($state, Set $set) {
if (! $state) {
return;
}
$parts = parse_url($state);
if (isset($parts['query'])) {
parse_str($parts['query'], $query);
$set('utm_source', $query['utm_source'] ?? null);
$set('utm_medium', $query['utm_medium'] ?? null);
$set('utm_campaign', $query['utm_campaign'] ?? null);
$set('utm_term', $query['utm_term'] ?? null);
$set('utm_content', $query['utm_content'] ?? null);
} else {
$set('utm_source', null);
$set('utm_medium', null);
$set('utm_campaign', null);
$set('utm_term', null);
$set('utm_content', null);
}
})
->columnSpanFull(),
TextInput::make('url_key')
->label(__('filament-short-url::default.url_key'))
->helperText(__('filament-short-url::default.url_key_helper'))
->alphaDash()
->maxLength(32)
->unique('short_urls', 'url_key', ignoreRecord: true)
->suffixAction(
Action::make('regenerate')
->icon('heroicon-o-arrow-path')
->tooltip('Generate new key')
->action(function (Set $set): void {
$set('url_key', app(ShortUrlService::class)->generateKey());
})
)
->placeholder('auto-generated'),
Select::make('redirect_status_code')
->label(__('filament-short-url::default.redirect_code'))
->options([
302 => __('filament-short-url::default.redirect_code_302'),
301 => __('filament-short-url::default.redirect_code_301'),
])
->default(fn () => config('filament-short-url.redirect_status_code', 302))
->required(),
])->columns(2),
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('forward_query_params')
->label(__('filament-short-url::default.forward_query_params'))
->helperText(__('filament-short-url::default.forward_query_params_helper'))
->default(false)
->inline(false),
])->columns(2),
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)
->columnSpanFull(),
]),
]);
}
public static function syncUtmToDestination(Get $get, Set $set): void
{
$url = $get('destination_url');
if (! $url) {
return;
}
$parts = parse_url($url);
$query = [];
if (isset($parts['query'])) {
parse_str($parts['query'], $query);
}
// List of UTM parameters we build
$utms = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'];
foreach ($utms as $utm) {
$val = $get($utm);
if ($val !== null && $val !== '') {
$query[$utm] = $val;
} else {
unset($query[$utm]);
}
}
$scheme = isset($parts['scheme']) ? $parts['scheme'].'://' : '';
$host = $parts['host'] ?? '';
$port = isset($parts['port']) ? ':'.$parts['port'] : '';
$path = $parts['path'] ?? '';
$queryString = ! empty($query) ? '?'.http_build_query($query) : '';
$fragment = isset($parts['fragment']) ? '#'.$parts['fragment'] : '';
$set('destination_url', $scheme.$host.$port.$path.$queryString.$fragment);
}
private static function trackingTab(): Tab
{
return Tab::make(__('filament-short-url::default.tab_tracking'))
->icon('heroicon-o-chart-bar')
->schema([
Section::make(__('filament-short-url::default.form_section_tracking'))
->schema([
Toggle::make('track_visits')
->label(__('filament-short-url::default.track_visits'))
->default(fn () => config('filament-short-url.tracking.enabled', true))
->live()
->inline(false)
->columnSpanFull(),
])->columns(1),
Section::make(__('filament-short-url::default.form_section_tracked_fields'))
->schema([
Toggle::make('track_ip_address')
->label(__('filament-short-url::default.track_ip'))
->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(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(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(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(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(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(fn () => config('filament-short-url.tracking.fields.referer_url', true))
->inline(false)
->disabled(fn (Get $get): bool => ! $get('track_visits')),
])
->columns(4)
->hidden(fn (Get $get): bool => ! $get('track_visits')),
Section::make(__('filament-short-url::default.utm_builder'))
->description(__('filament-short-url::default.utm_builder_helper'))
->collapsible()
->collapsed()
->schema([
TextInput::make('utm_source')
->label(__('filament-short-url::default.utm_source'))
->placeholder(__('filament-short-url::default.utm_source_placeholder'))
->dehydrated(false)
->live(onBlur: true)
->afterStateUpdated(fn (Get $get, Set $set) => static::syncUtmToDestination($get, $set)),
TextInput::make('utm_medium')
->label(__('filament-short-url::default.utm_medium'))
->placeholder(__('filament-short-url::default.utm_medium_placeholder'))
->dehydrated(false)
->live(onBlur: true)
->afterStateUpdated(fn (Get $get, Set $set) => static::syncUtmToDestination($get, $set)),
TextInput::make('utm_campaign')
->label(__('filament-short-url::default.utm_campaign'))
->placeholder(__('filament-short-url::default.utm_campaign_placeholder'))
->dehydrated(false)
->live(onBlur: true)
->afterStateUpdated(fn (Get $get, Set $set) => static::syncUtmToDestination($get, $set)),
TextInput::make('utm_term')
->label(__('filament-short-url::default.utm_term'))
->placeholder(__('filament-short-url::default.utm_term_placeholder'))
->dehydrated(false)
->live(onBlur: true)
->afterStateUpdated(fn (Get $get, Set $set) => static::syncUtmToDestination($get, $set)),
TextInput::make('utm_content')
->label(__('filament-short-url::default.utm_content'))
->placeholder(__('filament-short-url::default.utm_content_placeholder'))
->dehydrated(false)
->live(onBlur: true)
->columnSpanFull()
->afterStateUpdated(fn (Get $get, Set $set) => static::syncUtmToDestination($get, $set)),
])
->columns(2),
Section::make(__('filament-short-url::default.form_section_analytics'))
->schema([
TextInput::make('ga_tracking_id')
->label(__('filament-short-url::default.ga_tracking_id'))
->helperText(__('filament-short-url::default.ga_tracking_id_helper'))
->placeholder('G-XXXXXXXXXX')
->regex('/^G-[A-Z0-9]+$/')
->nullable(),
]),
]);
}
private static function qrDesignTab(): Tab
{
return Tab::make(__('filament-short-url::default.tab_qr_design'))
->icon('heroicon-o-qr-code')
->schema([
TextInput::make('qr_options')
->extraAttributes([
'id' => 'qr-options-json-input',
'style' => 'position:absolute;width:1px;height:1px;opacity:0;pointer-events:none',
'aria-hidden' => 'true',
])
->dehydrateStateUsing(fn (?string $state): array => json_decode($state ?? '{}', true) ?: [])
->afterStateHydrated(function (TextInput $component, mixed $state): void {
$component->state(is_array($state) ? json_encode($state) : ($state ?? '{}'));
})
->columnSpanFull()
->label(''),
ViewField::make('qr_designer')
->view('filament-short-url::qr-designer')
->columnSpanFull()
->dehydrated(false),
]);
}
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'),
'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.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([
TextInput::make('pixel_meta_id')
->label(__('filament-short-url::default.pixel_meta'))
->placeholder('e.g., 1234567890')
->maxLength(100)
->nullable(),
TextInput::make('pixel_google_id')
->label(__('filament-short-url::default.pixel_google'))
->placeholder('e.g., G-XXXXXXXXXX or AW-XXXXXXXXXX')
->maxLength(100)
->nullable(),
TextInput::make('pixel_linkedin_id')
->label(__('filament-short-url::default.pixel_linkedin'))
->placeholder('e.g., 1234567')
->maxLength(100)
->nullable(),
])->columns(3),
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(),
]),
]);
}
}

View File

@@ -0,0 +1,114 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Support;
class WeightBalancer
{
/**
* Balance the weights of variants dynamically to sum up to exactly 100%.
*/
public static function balanceWeights(mixed $component): void
{
$items = $component->getState() ?? [];
$m = count($items);
if ($m === 0) {
return;
}
$sum = 0;
foreach ($items as $key => $item) {
$items[$key]['weight'] = (int) ($item['weight'] ?? 0);
$sum += $items[$key]['weight'];
}
$diff = 100 - $sum;
if ($diff !== 0) {
$diffSteps = (int) ($diff / 5);
$changeSteps = (int) ($diffSteps / $m);
$remainderSteps = $diffSteps % $m;
foreach ($items as $key => $item) {
$items[$key]['weight'] = max(0, min(100, $items[$key]['weight'] + $changeSteps * 5));
}
$step = $remainderSteps > 0 ? 5 : -5;
$count = abs($remainderSteps);
$keys = array_keys($items);
for ($i = 0; $i < $count; $i++) {
$k = $keys[$i % $m];
$items[$k]['weight'] = max(0, min(100, $items[$k]['weight'] + $step));
}
// Ensure sum is exactly 100 by forcing the difference on the first eligible item
$finalSum = array_sum(array_column($items, 'weight'));
$finalDiff = 100 - $finalSum;
if ($finalDiff !== 0) {
foreach ($items as $key => $item) {
$newWeight = $items[$key]['weight'] + $finalDiff;
if ($newWeight >= 0 && $newWeight <= 100) {
$items[$key]['weight'] = $newWeight;
break;
}
}
}
$component->state($items);
$component->getLivewire()->getErrorBag()->forget($component->getStatePath());
}
}
/**
* Balance the weights of variants equally (e.g. on add/delete).
*/
public static function balanceWeightsEqually(mixed $component): void
{
$items = $component->getState() ?? [];
$m = count($items);
if ($m === 0) {
return;
}
$base = (int) (100 / $m);
$remainder = 100 % $m;
foreach ($items as $key => $item) {
$items[$key]['weight'] = $base;
}
$keys = array_keys($items);
for ($i = 0; $i < $remainder; $i++) {
$k = $keys[$i];
$items[$k]['weight'] += 1;
}
$component->state($items);
$component->getLivewire()->getErrorBag()->forget($component->getStatePath());
}
/**
* Update validation errors on the repeater level when a slider is dragged.
*/
public static function updateRepeaterValidationError(mixed $component): void
{
$statePath = $component->getStatePath();
$repeaterPath = (string) str($statePath)->beforeLast('.')->beforeLast('.');
$livewire = $component->getLivewire();
$get = $component->makeGetUtility();
$items = $get($repeaterPath, isAbsolute: true) ?? [];
$sum = array_sum(array_column($items, 'weight'));
if ($sum !== 100) {
$livewire->addError($repeaterPath, __('filament-short-url::default.weights_sum_error', ['sum' => $sum]));
} else {
$livewire->getErrorBag()->forget($repeaterPath);
}
}
}

View File

@@ -0,0 +1,46 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Components\ViewField;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
class AppLinkingTab
{
/**
* Build the app linking details form tab.
*/
public static function make(): Tab
{
return Tab::make(__('filament-short-url::default.tab_app_linking'))
->icon('heroicon-o-device-phone-mobile')
->schema([
Section::make(__('filament-short-url::default.form_section_app_linking'))
->schema([
Toggle::make('auto_open_app_mobile')
->label(__('filament-short-url::default.auto_open_app_mobile'))
->helperText(__('filament-short-url::default.auto_open_app_mobile_helper'))
->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')),
]),
]);
}
}

View File

@@ -0,0 +1,388 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs;
use Bjanczak\FilamentShortUrl\Filament\Forms\Components\TrafficSplitter;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Support\WeightBalancer;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Bjanczak\FilamentShortUrl\Models\ShortUrlCustomDomain;
use Bjanczak\FilamentShortUrl\Rules\SafeUrl;
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
use Filament\Actions\Action;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Repeater\TableColumn;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Components\ToggleButtons;
use Filament\Schemas\Components\FusedGroup;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Illuminate\Support\Str;
class LinkTab
{
/**
* Build the link details form tab.
*/
public static function make(): Tab
{
return Tab::make(__('filament-short-url::default.tab_link'))
->icon('heroicon-o-link')
->schema([
Section::make()->schema([
ToggleButtons::make('destination_type')
->label(__('filament-short-url::default.destination_type'))
->options([
'single' => __('filament-short-url::default.destination_type_single'),
'split' => __('filament-short-url::default.destination_type_split'),
])
->colors([
'single' => 'primary',
'split' => 'warning',
])
->icons([
'single' => 'heroicon-o-link',
'split' => 'heroicon-o-arrow-path-rounded-square',
])
->default('single')
->live()
->inline()
->columnSpanFull()
->afterStateUpdated(function ($state, Set $set, Get $get) {
if ($state === 'split' && empty($get('rotation_variants'))) {
$set('rotation_variants', [
(string) Str::uuid() => ['label' => 'Variant A', 'url' => '', 'weight' => 50],
(string) Str::uuid() => ['label' => 'Variant B', 'url' => '', 'weight' => 50],
]);
}
}),
TextInput::make('destination_url')
->label(__('filament-short-url::default.destination_url'))
->helperText(__('filament-short-url::default.destination_url_helper'))
->required(fn (Get $get): bool => $get('destination_type') === 'single' || ! $get('destination_type'))
->visible(fn (Get $get): bool => $get('destination_type') === 'single' || ! $get('destination_type'))
->url()
->maxLength(2048)
->rules([
app(SafeUrl::class),
])
->live(onBlur: true)
->afterStateHydrated(function (TextInput $component, $state, Set $set) {
if (! $state) {
return;
}
$parts = parse_url($state);
if (isset($parts['query'])) {
parse_str($parts['query'], $query);
$set('utm_source', $query['utm_source'] ?? null);
$set('utm_medium', $query['utm_medium'] ?? null);
$set('utm_campaign', $query['utm_campaign'] ?? null);
$set('utm_term', $query['utm_term'] ?? null);
$set('utm_content', $query['utm_content'] ?? null);
}
})
->afterStateUpdated(function ($state, Set $set) {
if (! $state) {
return;
}
$parts = parse_url($state);
if (isset($parts['query'])) {
parse_str($parts['query'], $query);
$set('utm_source', $query['utm_source'] ?? null);
$set('utm_medium', $query['utm_medium'] ?? null);
$set('utm_campaign', $query['utm_campaign'] ?? null);
$set('utm_term', $query['utm_term'] ?? null);
$set('utm_content', $query['utm_content'] ?? null);
} else {
$set('utm_source', null);
$set('utm_medium', null);
$set('utm_campaign', null);
$set('utm_term', null);
$set('utm_content', null);
}
})
->columnSpanFull(),
Repeater::make('rotation_variants')
->hiddenLabel()
->extraAttributes(['class' => 'ab-test-repeater'])
->table([
TableColumn::make(__('filament-short-url::default.variant_label'))
->width('30%'),
TableColumn::make(__('filament-short-url::default.variant_url'))
->width('70%'),
])
->schema([
TextInput::make('label')
->hiddenLabel()
->placeholder('e.g. Variant A')
->required()
->maxLength(100),
TextInput::make('url')
->hiddenLabel()
->url()
->required()
->maxLength(2048)
->rules([
app(SafeUrl::class),
]),
Hidden::make('weight'),
])
->defaultItems(2)
->minItems(2)
->maxItems(5)
->reorderable(false)
->live()
->rules([
function () {
return function (string $attribute, $value, \Closure $fail) {
if (! is_array($value)) {
return;
}
$sum = array_sum(array_column($value, 'weight'));
if ($sum !== 100) {
$fail(__('filament-short-url::default.weights_sum_error', ['sum' => $sum]));
}
};
},
])
->deleteAction(
fn ($action) => $action
->visible(fn (Get $get): bool => count($get('rotation_variants') ?? []) > 2)
->after(fn ($component) => WeightBalancer::balanceWeightsEqually($component))
)
->addAction(
fn ($action) => $action->after(fn ($component) => WeightBalancer::balanceWeightsEqually($component))
)
->addActionLabel(__('filament-short-url::default.add_url'))
->visible(fn (Get $get): bool => $get('destination_type') === 'split')
->columnSpanFull(),
TrafficSplitter::make('traffic_split')
->label(__('filament-short-url::default.traffic_split'))
->target('rotation_variants')
->visible(fn (Get $get): bool => $get('destination_type') === 'split')
->columnSpanFull(),
FusedGroup::make([
Select::make('custom_domain_id')
->hiddenLabel()
->options(function () {
$domains = ShortUrlCustomDomain::where('is_active', true)
->where('is_verified', true)
->pluck('domain', 'id');
if (! config('filament-short-url.disable_default_domain', false)) {
$defaultDomain = request()->getHost() ?: parse_url(config('app.url'), PHP_URL_HOST);
$domains = collect(['default' => $defaultDomain])->union($domains);
}
return $domains;
})
->default(function () {
if (! config('filament-short-url.disable_default_domain', false)) {
return 'default';
}
$firstDomain = ShortUrlCustomDomain::where('is_active', true)
->where('is_verified', true)
->first();
return $firstDomain ? $firstDomain->id : null;
})
->afterStateHydrated(function (Select $component, $state) {
if ($state === null && ! config('filament-short-url.disable_default_domain', false)) {
$component->state('default');
}
})
->dehydrateStateUsing(fn ($state) => $state === 'default' ? null : $state)
->disabled(function (?ShortUrl $record) {
if ($record && $record->exists && config('filament-short-url.lock_url_key', false)) {
return true;
}
$domainsCount = ShortUrlCustomDomain::where('is_active', true)
->where('is_verified', true)
->count();
$defaultEnabled = ! config('filament-short-url.disable_default_domain', false);
$totalOptionsCount = $domainsCount + ($defaultEnabled ? 1 : 0);
return $totalOptionsCount <= 1;
})
->dehydrated()
->required(fn () => (bool) config('filament-short-url.disable_default_domain', false))
->selectablePlaceholder(false)
->nullable()
->native(false),
TextInput::make('url_key')
->hiddenLabel()
->helperText(__('filament-short-url::default.url_key_helper'))
->alphaDash()
->maxLength(32)
->unique('short_urls', 'url_key', ignoreRecord: true)
->disabled(fn (?ShortUrl $record) => $record && $record->exists && config('filament-short-url.lock_url_key', false))
->placeholder('auto-generated')
->suffixAction(
Action::make('regenerate')
->icon('heroicon-o-arrow-path')
->tooltip('Generate new key')
->action(function (Set $set, ShortUrlService $service): void {
$set('url_key', $service->generateKey());
})
->visible(fn (?ShortUrl $record) => ! ($record && $record->exists && config('filament-short-url.lock_url_key', false)))
),
])
->extraAttributes(['class' => 'custom-fused'])
->label(__('filament-short-url::default.short_link_label'))
->columns(2)
->columnSpanFull(),
Select::make('redirect_status_code')
->label(__('filament-short-url::default.redirect_code'))
->options([
302 => __('filament-short-url::default.redirect_code_302'),
301 => __('filament-short-url::default.redirect_code_301'),
])
->default(fn () => config('filament-short-url.redirect_status_code', 302))
->required(),
])->columns(2),
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('forward_query_params')
->label(__('filament-short-url::default.forward_query_params'))
->helperText(__('filament-short-url::default.forward_query_params_helper'))
->default(false)
->inline(false),
])->columns(2),
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)
->columnSpanFull(),
]),
]);
}
/**
* Synchronize campaign parameters into the query parameters of the destination URL.
*/
public static function syncUtmToDestination(Get $get, Set $set): void
{
$url = $get('destination_url');
if (! $url) {
return;
}
$parts = parse_url($url);
$query = [];
if (isset($parts['query'])) {
parse_str($parts['query'], $query);
}
$utms = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'];
foreach ($utms as $utm) {
$val = $get($utm);
if ($val !== null && $val !== '') {
$query[$utm] = $val;
} else {
unset($query[$utm]);
}
}
$scheme = isset($parts['scheme']) ? $parts['scheme'].'://' : '';
$host = $parts['host'] ?? '';
$port = isset($parts['port']) ? ':'.$parts['port'] : '';
$path = $parts['path'] ?? '';
$queryString = ! empty($query) ? '?'.http_build_query($query) : '';
$fragment = isset($parts['fragment']) ? '#'.$parts['fragment'] : '';
$set('destination_url', $scheme.$host.$port.$path.$queryString.$fragment);
}
}

View File

@@ -0,0 +1,75 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\ViewField;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs\Tab;
use Illuminate\Support\HtmlString;
class MarketingTab
{
/**
* Build the marketing details form tab.
*/
public static function make(): 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'))
->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([
Placeholder::make('webhook_info')
->hiddenLabel()
->content(new HtmlString(
'<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="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm.75-11.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm0 2.5a.75.75 0 0 1 .75.75v4a.75.75 0 0 1-1.5 0v-4a.75.75 0 0 1 .75-.75Z"></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 data-as="p">'.
__('filament-short-url::default.webhook_helper_alert').
'</span>'.
'</div>'.
'</div>'
))
->columnSpanFull(),
TextInput::make('webhook_url')
->label(__('filament-short-url::default.webhook_url'))
->placeholder('https://api.yourcrm.com/webhooks/clicks')
->url()
->maxLength(2048)
->nullable()
->columnSpanFull(),
ViewField::make('webhook_payload_example')
->view('filament-short-url::webhook-payload-example')
->columnSpanFull(),
]),
]);
}
}

View File

@@ -0,0 +1,59 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\ViewField;
use Filament\Schemas\Components\Group;
use Filament\Schemas\Components\Tabs\Tab;
class QrDesignTab
{
/**
* Build the QR code design form tab.
*/
public static function make(): Tab
{
return Tab::make(__('filament-short-url::default.tab_qr_design'))
->icon('heroicon-o-qr-code')
->schema([
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')
->columnSpanFull()
->dehydrated(false),
]);
}
}

View File

@@ -0,0 +1,548 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs;
use Bjanczak\FilamentShortUrl\Filament\Forms\Components\TrafficSplitter;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Support\WeightBalancer;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Bjanczak\FilamentShortUrl\Rules\SafeUrl;
use Filament\Actions\Action;
use Filament\Forms\Components\Builder;
use Filament\Forms\Components\Builder\Block;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Repeater\TableColumn;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Schemas\Components\Actions;
use Filament\Schemas\Components\Group;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Illuminate\Support\HtmlString;
use Illuminate\Support\Str;
class TargetingTab
{
/**
* Build the targeting and security form tab.
*/
public static function make(): 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([
Hidden::make('password'),
Hidden::make('password_active_flag')
->dehydrated(false)
->afterStateHydrated(function (Hidden $component, $state, ?ShortUrl $record) {
$component->state($record && ! empty($record->password));
}),
Hidden::make('is_entering_password')
->dehydrated(false)
->default(false),
// State 1: Password is active (password_active_flag is true)
Group::make([
Placeholder::make('password_status')
->label(__('filament-short-url::default.password'))
->content(new HtmlString(
'<div class="flex items-center gap-2 text-emerald-600 dark:text-emerald-400 font-semibold">'.
'<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" /></svg>'.
'<span>'.__('filament-short-url::default.password_status_active').'</span>'.
'</div>'
))
->columnSpan(1),
Actions::make([
Action::make('change_password')
->label(__('filament-short-url::default.change_password'))
->icon('heroicon-o-pencil')
->color('primary')
->action(function (Set $set) {
$set('password_active_flag', false);
$set('is_entering_password', true);
$set('new_password_input', null);
$set('new_password_confirmation_input', null);
}),
Action::make('remove_password')
->label(__('filament-short-url::default.remove_password'))
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->action(function (Set $set, ?ShortUrl $record) {
$set('password', null);
$set('new_password_input', null);
$set('new_password_confirmation_input', null);
$set('password_active_flag', false);
$set('is_entering_password', false);
if ($record) {
$record->password = null;
}
}),
])
->columnSpan(1),
])
->columns(2)
->columnSpanFull()
->visible(fn (Get $get): bool => (bool) $get('password_active_flag')),
// State 2: No Password, Setup Button Group (password_active_flag is false, is_entering_password is false)
Group::make([
Actions::make([
Action::make('setup_password')
->label(__('filament-short-url::default.set_password'))
->icon('heroicon-o-key')
->color('primary')
->action(function (Set $set) {
$set('is_entering_password', true);
}),
])
->columnSpanFull(),
])
->columnSpanFull()
->visible(fn (Get $get): bool => ! $get('password_active_flag') && ! $get('is_entering_password')),
// State 3: Entering Password Group (password_active_flag is false, is_entering_password is true)
Group::make([
Group::make([
TextInput::make('new_password_input')
->label(__('filament-short-url::default.new_password'))
->password()
->revealable()
->live()
->maxLength(255)
->dehydrated(false)
->required(fn (Get $get): bool => ! $get('password_active_flag') && $get('is_entering_password'))
->columnSpan(1),
TextInput::make('new_password_confirmation_input')
->label(__('filament-short-url::default.confirm_password'))
->password()
->revealable()
->same('new_password_input')
->maxLength(255)
->dehydrated(false)
->required(fn (Get $get): bool => ! empty($get('new_password_input')))
->columnSpan(1),
])
->columns(2)
->columnSpanFull(),
Actions::make([
Action::make('confirm_password')
->label(__('filament-short-url::default.confirm'))
->icon('heroicon-o-check')
->color('success')
->action(function (Get $get, Set $set) {
$password = $get('new_password_input');
$confirm = $get('new_password_confirmation_input');
if (empty($password)) {
Notification::make()
->title(__('filament-short-url::default.password_required_error'))
->danger()
->send();
return;
}
if ($password !== $confirm) {
Notification::make()
->title(__('filament-short-url::default.password_mismatch_error'))
->danger()
->send();
return;
}
$set('password', $password);
$set('password_active_flag', true);
$set('is_entering_password', false);
}),
Action::make('cancel_password')
->label(__('filament-short-url::default.cancel'))
->icon('heroicon-o-x-mark')
->color('gray')
->action(function (Get $get, Set $set, ?ShortUrl $record) {
if ($record && ! empty($record->password)) {
$set('password_active_flag', true);
} else {
$set('password_active_flag', false);
}
$set('is_entering_password', false);
$set('new_password_input', null);
$set('new_password_confirmation_input', null);
}),
])
->columnSpanFull(),
])
->columnSpanFull()
->visible(fn (Get $get): bool => ! $get('password_active_flag') && $get('is_entering_password')),
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_rules'))
->compact()
->schema([
Repeater::make('targeting_rules')
->label(__('filament-short-url::default.targeting_rules'))
->hiddenLabel()
->defaultItems(0)
->maxItems(10)
->reorderable(false)
->collapsible()
->collapsed()
->columns(12)
->afterStateHydrated(function (Repeater $component, $state) {
if (! is_array($state)) {
$component->state([]);
return;
}
// If it is legacy format (has 'type' key)
if (isset($state['type'])) {
$type = $state['type'];
$newRules = [];
if ($type === 'device') {
$devices = $state['device'] ?? [];
$mobileUrl = $devices['mobile'] ?? $devices['ios'] ?? null;
if ($mobileUrl) {
$newRules[] = [
'match' => 'or',
'url' => $mobileUrl,
'filters' => [
[
'type' => 'device',
'data' => ['devices' => ['mobile']],
],
],
];
}
$tabletUrl = $devices['tablet'] ?? $devices['android'] ?? null;
if ($tabletUrl) {
$newRules[] = [
'match' => 'or',
'url' => $tabletUrl,
'filters' => [
[
'type' => 'device',
'data' => ['devices' => ['tablet']],
],
],
];
}
$desktopUrl = $devices['desktop'] ?? null;
if ($desktopUrl) {
$newRules[] = [
'match' => 'or',
'url' => $desktopUrl,
'filters' => [
[
'type' => 'device',
'data' => ['devices' => ['desktop']],
],
],
];
}
} elseif ($type === 'geo') {
foreach ($state['geo'] ?? [] as $geoRule) {
if (! empty($geoRule['url']) && ! empty($geoRule['country_code'])) {
$newRules[] = [
'match' => 'or',
'url' => $geoRule['url'],
'filters' => [
[
'type' => 'country',
'data' => ['countries' => [strtoupper($geoRule['country_code'])]],
],
],
];
}
}
} elseif ($type === 'language') {
foreach ($state['language'] ?? [] as $langRule) {
if (! empty($langRule['url']) && ! empty($langRule['language_code'])) {
$newRules[] = [
'match' => 'or',
'url' => $langRule['url'],
'filters' => [
[
'type' => 'language',
'data' => ['languages' => [strtolower($langRule['language_code'])]],
],
],
];
}
}
}
$component->state($newRules);
return;
}
if (! array_is_list($state)) {
$component->state([]);
return;
}
$component->state($state);
})
->schema([
Select::make('match')
->label(__('filament-short-url::default.match'))
->options([
'or' => __('filament-short-url::default.match_or'),
'and' => __('filament-short-url::default.match_and'),
])
->default('or')
->required()
->columnSpan(3),
Select::make('destination_type')
->label(__('filament-short-url::default.destination_type'))
->options([
'single' => __('filament-short-url::default.destination_type_single'),
'split' => __('filament-short-url::default.destination_type_split'),
])
->default('single')
->live()
->required()
->columnSpan(3)
->afterStateUpdated(function ($state, Set $set, Get $get) {
if ($state === 'split' && empty($get('variants'))) {
$set('variants', [
(string) Str::uuid() => ['label' => 'Variant A', 'url' => '', 'weight' => 50],
(string) Str::uuid() => ['label' => 'Variant B', 'url' => '', 'weight' => 50],
]);
}
}),
TextInput::make('url')
->label(__('filament-short-url::default.direct_to_url'))
->url()
->required(fn (Get $get): bool => $get('destination_type') === 'single' || ! $get('destination_type'))
->visible(fn (Get $get): bool => $get('destination_type') === 'single' || ! $get('destination_type'))
->maxLength(2048)
->rules([
app(SafeUrl::class),
])
->columnSpan(6),
Repeater::make('variants')
->hiddenLabel()
->extraAttributes(['class' => 'ab-test-repeater'])
->table([
TableColumn::make(__('filament-short-url::default.variant_label'))
->width('30%'),
TableColumn::make(__('filament-short-url::default.variant_url'))
->width('70%'),
])
->schema([
TextInput::make('label')
->hiddenLabel()
->placeholder('e.g. Variant A')
->required()
->maxLength(100),
TextInput::make('url')
->hiddenLabel()
->url()
->required()
->maxLength(2048)
->rules([
app(SafeUrl::class),
]),
Hidden::make('weight'),
])
->defaultItems(2)
->minItems(2)
->maxItems(5)
->reorderable(false)
->live()
->rules([
function () {
return function (string $attribute, $value, \Closure $fail) {
if (! is_array($value)) {
return;
}
$sum = array_sum(array_column($value, 'weight'));
if ($sum !== 100) {
$fail(__('filament-short-url::default.weights_sum_error', ['sum' => $sum]));
}
};
},
])
->deleteAction(
fn ($action) => $action
->visible(fn (Get $get): bool => count($get('variants') ?? []) > 2)
->after(fn ($component) => WeightBalancer::balanceWeightsEqually($component))
)
->addAction(
fn ($action) => $action->after(fn ($component) => WeightBalancer::balanceWeightsEqually($component))
)
->visible(fn (Get $get): bool => $get('destination_type') === 'split')
->columnSpanFull(),
TrafficSplitter::make('traffic_split')
->label(__('filament-short-url::default.traffic_split'))
->target('variants')
->visible(fn (Get $get): bool => $get('destination_type') === 'split')
->columnSpanFull(),
Builder::make('filters')
->label(__('filament-short-url::default.add_filter'))
->hiddenLabel()
->addActionLabel(__('filament-short-url::default.add_filter'))
->reorderable(false)
->collapsible()
->blockNumbers(false)
->addBetweenAction(fn ($action) => $action->hidden())
->minItems(1)
->blocks([
Block::make('device')
->label(fn (?array $state) => $state === null
? __('filament-short-url::default.filter_device')
: new HtmlString(__('filament-short-url::default.select_devices').' <sup class="text-danger-600 dark:text-danger-400 fi-fo-field-label-required-mark" style="color: rgb(220, 38, 38) !important;">*</sup>')
)
->icon('heroicon-o-device-phone-mobile')
->schema([
CheckboxList::make('devices')
->label(__('filament-short-url::default.select_devices'))
->hiddenLabel()
->options([
'desktop' => __('filament-short-url::default.device_desktop_label'),
'mobile' => __('filament-short-url::default.device_mobile_label'),
'tablet' => __('filament-short-url::default.device_tablet_label'),
])
->required()
->columns(3)
->columnSpanFull(),
])
->maxItems(1),
Block::make('platform')
->label(fn (?array $state) => $state === null
? __('filament-short-url::default.filter_platform')
: new HtmlString(__('filament-short-url::default.select_platforms').' <sup class="text-danger-600 dark:text-danger-400 fi-fo-field-label-required-mark" style="color: rgb(220, 38, 38) !important;">*</sup>')
)
->icon('heroicon-o-computer-desktop')
->schema([
CheckboxList::make('platforms')
->label(__('filament-short-url::default.select_platforms'))
->hiddenLabel()
->options([
'android' => 'Android',
'fire_os' => 'Fire OS',
'ios' => 'iOS / iPadOS',
'linux' => 'Linux',
'mac' => 'macOS',
'windows' => 'Windows',
])
->required()
->columns(3)
->columnSpanFull(),
])
->maxItems(1),
Block::make('country')
->label(fn (?array $state) => $state === null
? __('filament-short-url::default.filter_country')
: new HtmlString(__('filament-short-url::default.select_countries').' <sup class="text-danger-600 dark:text-danger-400 fi-fo-field-label-required-mark" style="color: rgb(220, 38, 38) !important;">*</sup>')
)
->icon('heroicon-o-globe-alt')
->schema([
Select::make('countries')
->label(__('filament-short-url::default.select_countries'))
->hiddenLabel()
->multiple()
->searchable()
->allowHtml()
->options(function (): array {
$countries = __('filament-short-url::countries');
if (is_array($countries)) {
asort($countries, SORT_LOCALE_STRING);
$htmlOptions = [];
foreach ($countries as $code => $name) {
$lowerCode = strtolower($code);
$htmlOptions[$code] = "<span class=\"flex items-center gap-2\"><img src=\"https://flagcdn.com/h20/{$lowerCode}.webp\" class=\"w-5 h-auto rounded-sm inline-block mr-2\" alt=\"{$name}\" style=\"vertical-align: middle;\" /><span>{$name}</span></span>";
}
return $htmlOptions;
}
return [];
})
->optionsLimit(300)
->required()
->columnSpanFull(),
])
->maxItems(1),
Block::make('language')
->label(fn (?array $state) => $state === null
? __('filament-short-url::default.filter_language')
: new HtmlString(__('filament-short-url::default.select_languages').' <sup class="text-danger-600 dark:text-danger-400 fi-fo-field-label-required-mark" style="color: rgb(220, 38, 38) !important;">*</sup>')
)
->icon('heroicon-o-language')
->schema([
Select::make('languages')
->label(__('filament-short-url::default.select_languages'))
->hiddenLabel()
->multiple()
->searchable()
->options(function (): array {
$languages = __('filament-short-url::languages');
if (is_array($languages)) {
asort($languages, SORT_LOCALE_STRING);
return $languages;
}
return [];
})
->required()
->columnSpanFull(),
])
->maxItems(1),
])
->rules([
function () {
return function (string $attribute, $value, \Closure $fail) {
if (! is_array($value)) {
return;
}
$types = collect($value)->pluck('type');
if ($types->duplicates()->isNotEmpty()) {
$fail('Each filter type (Device, Platform, Country, Language) can only be added once.');
}
};
},
])
->columnSpanFull(),
])
->columnSpanFull()
->default([]),
]),
]);
}
}

View File

@@ -0,0 +1,145 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
class TrackingTab
{
/**
* Build the tracking details form tab.
*/
public static function make(): Tab
{
return Tab::make(__('filament-short-url::default.tab_tracking'))
->icon('heroicon-o-chart-bar')
->schema([
Section::make(__('filament-short-url::default.form_section_tracking'))
->schema([
Toggle::make('track_visits')
->label(__('filament-short-url::default.track_visits'))
->default(fn () => config('filament-short-url.tracking.enabled', true))
->live()
->inline(false)
->columnSpanFull(),
])->columns(1),
Section::make(__('filament-short-url::default.form_section_tracked_fields'))
->schema([
Toggle::make('track_ip_address')
->label(__('filament-short-url::default.track_ip'))
->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(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(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(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(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(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(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')),
])
->columns(4)
->hidden(fn (Get $get): bool => ! $get('track_visits')),
Section::make(__('filament-short-url::default.utm_builder'))
->description(__('filament-short-url::default.utm_builder_helper'))
->collapsible()
->collapsed()
->schema([
TextInput::make('utm_source')
->label(__('filament-short-url::default.utm_source'))
->placeholder(__('filament-short-url::default.utm_source_placeholder'))
->dehydrated(false)
->live(onBlur: true)
->afterStateUpdated(fn (Get $get, Set $set) => LinkTab::syncUtmToDestination($get, $set)),
TextInput::make('utm_medium')
->label(__('filament-short-url::default.utm_medium'))
->placeholder(__('filament-short-url::default.utm_medium_placeholder'))
->dehydrated(false)
->live(onBlur: true)
->afterStateUpdated(fn (Get $get, Set $set) => LinkTab::syncUtmToDestination($get, $set)),
TextInput::make('utm_campaign')
->label(__('filament-short-url::default.utm_campaign'))
->placeholder(__('filament-short-url::default.utm_campaign_placeholder'))
->dehydrated(false)
->live(onBlur: true)
->afterStateUpdated(fn (Get $get, Set $set) => LinkTab::syncUtmToDestination($get, $set)),
TextInput::make('utm_term')
->label(__('filament-short-url::default.utm_term'))
->placeholder(__('filament-short-url::default.utm_term_placeholder'))
->dehydrated(false)
->live(onBlur: true)
->afterStateUpdated(fn (Get $get, Set $set) => LinkTab::syncUtmToDestination($get, $set)),
TextInput::make('utm_content')
->label(__('filament-short-url::default.utm_content'))
->placeholder(__('filament-short-url::default.utm_content_placeholder'))
->dehydrated(false)
->live(onBlur: true)
->columnSpanFull()
->afterStateUpdated(fn (Get $get, Set $set) => LinkTab::syncUtmToDestination($get, $set)),
])
->columns(2),
Section::make(__('filament-short-url::default.form_section_analytics'))
->schema([
TextInput::make('ga_tracking_id')
->label(__('filament-short-url::default.ga_tracking_id'))
->helperText(__('filament-short-url::default.ga_tracking_id_helper'))
->placeholder('G-XXXXXXXXXX')
->regex('/^G-[A-Z0-9]+$/')
->nullable(),
]),
]);
}
}

View File

@@ -5,6 +5,7 @@ namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Tables;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteAction;
@@ -28,142 +29,18 @@ class ShortUrlsTable
// 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));
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
);
})
->view('filament-short-url::table.url-key-column')
->searchable(query: fn ($query, string $search) => $query->where('url_key', 'like', "%{$search}%")),
// 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>
'))
->view('filament-short-url::table.destination-column')
->searchable(),
// 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.' clicks
</span>
<!-- 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.' unique
</span>
<!-- 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->format('M d, Y').'
</span>
<!-- 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>
Expires: '.$record->expires_at->format('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>
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>
'.$record->redirect_status_code.' redirect
</span>
</div>
')),
->view('filament-short-url::table.metadata-badges'),
]),
])
->filters([
@@ -184,135 +61,173 @@ HTML
'1' => 'Single-use',
]),
])
->recordClasses(fn (): string => 'short-url-card')
->recordClasses(fn (): string => 'short-url-card group/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>
')),
ActionGroup::make([
EditAction::make()
->icon('heroicon-o-pencil-square')
->label(fn () => new HtmlString('<div class="flex items-center justify-between w-full min-w-[140px] text-left"><span>'.__('filament-short-url::default.action_edit').'</span><span class="text-[10px] bg-neutral-100 dark:bg-neutral-800 text-neutral-400 dark:text-neutral-500 rounded px-1.5 py-0.5 ml-auto font-mono">E</span></div>'))
->modalAutofocus(false)
->closeModalByClickingAway(false)
->modalSubmitAction(false)
->extraModalFooterActions(static fn (EditAction $action): array => [
Action::make('save_changes')
->label(__('filament-actions::edit.single.modal.actions.save.label') ?: 'Save changes')
->color('primary')
->modal(static function () use ($action): bool {
/** @var ShortUrl $record */
$record = $action->getRecord();
$data = $action->getRawData();
$newKey = $data['url_key'] ?? null;
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')).'\'
});
return $newKey && $newKey !== $record->url_key;
})
->requiresConfirmation(static function () use ($action): bool {
/** @var ShortUrl $record */
$record = $action->getRecord();
$data = $action->getRawData();
$newKey = $data['url_key'] ?? null;
return $newKey && $newKey !== $record->url_key;
})
->modalHeading(__('filament-short-url::default.url_key_change_confirmation_heading') ?: 'Replace link?')
->modalDescription(__('filament-short-url::default.url_key_change_confirmation') ?: 'You have modified the short key of this link. Saving these changes will make the original short URL stop working.')
->action(static function () use ($action) {
/** @var ShortUrl $record */
$record = $action->getRecord();
$livewire = $action->getLivewire();
$state = $livewire->getMountedTableActionForm()->getState();
$action->process(function () use ($record, $state) {
$record->update($state);
});
$action->success();
$action->sendSuccessNotification();
$livewire->mountedActions = [];
}),
]),
Action::make('qrCode')
->label(fn () => new HtmlString('<div class="flex items-center justify-between w-full min-w-[140px] text-left"><span>'.__('filament-short-url::default.action_qr').'</span><span class="text-[10px] bg-neutral-100 dark:bg-neutral-800 text-neutral-400 dark:text-neutral-500 rounded px-1.5 py-0.5 ml-auto font-mono">Q</span></div>'))
->icon('heroicon-o-qr-code')
->modalWidth('md')
->modalSubmitAction(false)
->modalCancelAction(false)
->form(fn (ShortUrl $record): array => [
Forms\Components\Placeholder::make('qr_modal_content')
->hiddenLabel()
->content(view('filament-short-url::table.qr-code-modal', ['record' => $record])),
]),
Action::make('share')
->label(fn () => new HtmlString('<div class="flex items-center justify-between w-full min-w-[140px] text-left"><span>'.__('filament-short-url::default.action_share').'</span><span class="text-[10px] bg-neutral-100 dark:bg-neutral-800 text-neutral-400 dark:text-neutral-500 rounded px-1.5 py-0.5 ml-auto font-mono">I</span></div>'))
->icon('heroicon-o-document-duplicate')
->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) => view('filament-short-url::table.share-platforms', ['record' => $record])),
Forms\Components\Placeholder::make('copy_field')
->label('')
->content(fn (ShortUrl $record) => view('filament-short-url::table.share-copy-field', ['record' => $record])),
]),
Action::make('stats')
->label(fn () => new HtmlString('<div class="flex items-center justify-between w-full min-w-[140px] text-left"><span>'.__('filament-short-url::default.action_stats').'</span><span class="text-[10px] bg-neutral-100 dark:bg-neutral-800 text-neutral-400 dark:text-neutral-500 rounded px-1.5 py-0.5 ml-auto font-mono">S</span></div>'))
->icon('heroicon-o-chart-bar')
->url(fn (ShortUrl $record): string => ShortUrlResource::getUrl('stats', ['record' => $record])),
ActionGroup::make([
DeleteAction::make()
->label(fn () => new HtmlString('<div class="flex items-center justify-between w-full min-w-[140px] text-left text-red-600 dark:text-red-400 font-semibold"><span>'.__('filament-short-url::default.action_delete').'</span><span class="text-[10px] bg-red-50 dark:bg-red-950/30 text-red-600 dark:text-red-400 rounded px-1.5 py-0.5 ml-auto font-mono font-normal">X</span></div>'))
->icon('heroicon-o-trash')
->color('danger')
->modalHeading(__('filament-short-url::default.action_delete'))
->modalDescription(__('filament-short-url::default.delete_confirmation_desc'))
->modalSubmitActionLabel(__('filament-short-url::default.action_delete'))
->form(fn (ShortUrl $record): array => [
Forms\Components\Placeholder::make('link_preview')
->hiddenLabel()
->content(function () use ($record) {
$shortUrl = $record->getShortUrl();
$shortUrlDisplay = str($shortUrl)->after('://')->toString();
$destHost = null;
if ($record->destination_type === 'split') {
$variants = $record->rotation_variants ?? [];
if (! empty($variants)) {
$firstVariant = reset($variants);
$destHost = parse_url($firstVariant['url'] ?? '', PHP_URL_HOST);
}
}
"
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>
')),
]),
if (! $destHost) {
$destHost = parse_url($record->destination_url ?? '', PHP_URL_HOST);
}
$destHostEncoded = e($destHost);
Action::make('stats')
->label(__('filament-short-url::default.action_stats'))
->icon('heroicon-o-chart-bar')
$destUrl = $record->destination_url;
if ($record->destination_type === 'split') {
$destUrl = __('filament-short-url::default.destination_type_split');
}
$destUrlEncoded = e($destUrl);
return new HtmlString("
<div class=\"flex items-center gap-3 p-4 rounded-xl border border-neutral-200 dark:border-neutral-700 bg-neutral-50/50 dark:bg-neutral-800/50 shadow-sm\">
<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/{$destHostEncoded}.ico\"
class=\"w-full h-full object-contain\"
onerror=\"this.src='https://heroicons.com/24/outline/link.svg'\" />
</div>
<div class=\"min-w-0 flex-1\">
<div class=\"text-sm font-semibold text-neutral-900 dark:text-neutral-100 truncate\">{$shortUrlDisplay}</div>
<div class=\"flex items-center gap-1.5 text-gray-500 dark:text-gray-400 text-xs mt-0.5\">
<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>
<span class=\"truncate max-w-[50ch] text-[#273144] dark:text-gray-300 text-[13px] leading-[16px] font-medium\">{$destUrlEncoded}</span>
</div>
</div>
</div>
");
}),
Forms\Components\TextInput::make('verification')
->label(fn () => new HtmlString(__('filament-short-url::default.delete_verification_label', [
'short_url' => str($record->getShortUrl())->after('://')->toString(),
])))
->required()
->rules([
function () use ($record) {
return function (string $attribute, $value, \Closure $fail) use ($record) {
$expected = str($record->getShortUrl())->after('://')->toString();
if ($value !== $expected) {
$fail(__('filament-short-url::default.delete_verification_error'));
}
};
},
]),
]),
])->dropdown(false),
])
->icon('heroicon-m-ellipsis-vertical')
->color('gray')
->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')
->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')),
->extraAttributes([
'class' => 'action-trigger-btn group flex items-center justify-center gap-2 whitespace-nowrap rounded-lg border text-sm bg-bg-default text-content-emphasis hover:bg-bg-muted focus-visible:border-border-emphasis data-[state=open]:ring-4 data-[state=open]:ring-border-subtle sm:inline-flex h-8 px-1.5 outline-none transition-all duration-200 border-transparent data-[state=open]:border-neutral-500 sm:group-hover/card:data-[state=closed]:border-neutral-200',
'style' => 'width: 32px !important; height: 32px !important; padding: 0px !important; border-radius: 8px !important;',
])
->label(''),
])
->contentGrid([
'md' => 1,
@@ -335,6 +250,9 @@ HTML
DeleteBulkAction::make(),
]),
])
->defaultSort('created_at', 'desc');
->defaultSort('created_at', 'desc')
->modifyQueryUsing(fn ($query) => $query->with(['user', 'customDomain']))
->recordUrl(fn (ShortUrl $record): string => ShortUrlResource::getUrl('stats', ['record' => $record]))
->emptyState(view('filament-short-url::table.empty-state'));
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\Concerns;
trait HasStatsFilters
{
/**
* @var array<string, mixed>
*/
public array $filters = [];
}

View File

@@ -0,0 +1,75 @@
<?php
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\Concerns\HasStatsFilters;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Filament\Widgets\Widget;
class ShortUrlDeviceBreakdownWidget extends Widget
{
use HasStatsFilters;
public ?ShortUrl $record = null;
public ?string $dateFrom = null;
public ?string $dateTo = null;
public string $activeTab = 'devices';
public array $loadedTabs = ['devices' => true];
protected string $view = 'filament-short-url::widgets.device-breakdown';
public function setActiveTab(string $tab): void
{
$this->activeTab = $tab;
$this->loadedTabs[$tab] = true;
}
protected function getViewData(): array
{
if (! $this->record) {
return [
'activeTab' => $this->activeTab,
'visitsByDevice' => [],
'visitsByBrowser' => [],
'visitsByOs' => [],
'visitsByBrowserVersion' => [],
'visitsByOsVersion' => [],
'totalVisits' => 0,
'browserIcons' => [],
'osIcons' => [],
];
}
$stats = $this->record->getCachedStats($this->dateFrom, $this->dateTo, $this->filters);
return [
'activeTab' => $this->activeTab,
'visitsByDevice' => isset($this->loadedTabs['devices']) ? ($stats['visitsByDevice'] ?? []) : [],
'visitsByBrowser' => isset($this->loadedTabs['browsers']) ? ($stats['visitsByBrowser'] ?? []) : [],
'visitsByOs' => isset($this->loadedTabs['os']) ? ($stats['visitsByOs'] ?? []) : [],
'visitsByBrowserVersion' => isset($this->loadedTabs['browsers']) ? ($stats['visitsByBrowserVersion'] ?? []) : [],
'visitsByOsVersion' => isset($this->loadedTabs['os']) ? ($stats['visitsByOsVersion'] ?? []) : [],
'totalVisits' => $stats['totalVisits'] ?? 0,
'browserIcons' => [
'Chrome' => 'heroicon-m-globe-alt',
'Firefox' => 'heroicon-m-globe-alt',
'Safari' => 'heroicon-m-globe-alt',
'Edge' => 'heroicon-m-globe-alt',
'Opera' => 'heroicon-m-globe-alt',
'Internet Explorer' => 'heroicon-m-globe-alt',
],
'osIcons' => [
'Windows' => 'heroicon-m-cpu-chip',
'OS X' => 'heroicon-m-cpu-chip',
'Android' => 'heroicon-m-cpu-chip',
'iOS' => 'heroicon-m-cpu-chip',
'Ubuntu' => 'heroicon-m-cpu-chip',
'Linux' => 'heroicon-m-cpu-chip',
],
];
}
}

Some files were not shown because too many files have changed in this diff Show More