10 Commits

138 changed files with 28859 additions and 3942 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.

901
README.md

File diff suppressed because it is too large Load Diff

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

@@ -42,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
@@ -70,6 +88,7 @@ return [
*/
'tracking' => [
'enabled' => true,
'anonymize_ips' => env('SHORT_URL_ANONYMIZE_IPS', false),
'fields' => [
'ip_address' => true,
'browser' => true,
@@ -204,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
@@ -217,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

@@ -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_url_visits', function (Blueprint $table): void {
$table->boolean('is_bot')->default(false)->index();
$table->boolean('is_proxy')->default(false)->index();
});
}
public function down(): void
{
Schema::table('short_url_visits', function (Blueprint $table): void {
$table->dropColumn(['is_bot', 'is_proxy']);
});
}
};

View File

@@ -1,43 +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->integer('qr_scans')->default(0);
});
Schema::table('short_url_visits', function (Blueprint $table): void {
$table->boolean('is_qr_scan')->default(false)->index();
$table->string('browser_language', 10)->nullable()->index();
});
Schema::table('short_url_daily_stats', function (Blueprint $table): void {
$table->integer('qr_visits_count')->default(0);
$table->json('language_stats')->nullable();
});
}
public function down(): void
{
Schema::table('short_urls', function (Blueprint $table): void {
$table->dropColumn('qr_scans');
});
Schema::table('short_url_visits', function (Blueprint $table): void {
$table->dropIndex(['is_qr_scan']);
$table->dropColumn('is_qr_scan');
$table->dropIndex(['browser_language']);
$table->dropColumn('browser_language');
});
Schema::table('short_url_daily_stats', function (Blueprint $table): void {
$table->dropColumn(['qr_visits_count', 'language_stats']);
});
}
};

View File

@@ -1,22 +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->boolean('track_browser_language')->default(true);
});
}
public function down(): void
{
Schema::table('short_urls', function (Blueprint $table): void {
$table->dropColumn('track_browser_language');
});
}
};

View File

@@ -12,7 +12,7 @@ return new class extends Migration
public function up(): void
{
Schema::table('short_urls', function (Blueprint $table) {
$table->string('qr_logo')->nullable();
$table->unsignedBigInteger('user_id')->nullable()->after('id')->index();
});
}
@@ -22,7 +22,7 @@ return new class extends Migration
public function down(): void
{
Schema::table('short_urls', function (Blueprint $table) {
$table->dropColumn('qr_logo');
$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

@@ -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('custom_domain_id')->nullable()->after('user_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('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',
@@ -134,6 +153,8 @@ 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:',
@@ -174,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',
@@ -200,6 +223,10 @@ return [
'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". 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.',
@@ -221,7 +248,7 @@ return [
'settings_geoip_timeout_helper' => 'Maximum seconds to wait for an external Geo-IP API response before giving up.',
'settings_maxmind_path' => 'MaxMind Database Path',
'settings_maxmind_path_helper' => 'Absolute path to your GeoLite2-Country.mmdb or GeoIP2-Country.mmdb file. Required when driver is set to MaxMind.',
'settings_maxmind_info_callout' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M8 0C3.58125 0 0 3.58125 0 8C0 12.4187 3.58125 16 8 16C12.4187 16 16 12.4187 16 8C16 3.58125 12.4187 0 8 0ZM8 14.5C4.41563 14.5 1.5 11.5841 1.5 8C1.5 4.41594 4.41563 1.5 8 1.5C11.5844 1.5 14.5 4.41594 14.5 8C14.5 11.5841 11.5844 14.5 8 14.5ZM9.25 10.5H8.75V7.75C8.75 7.3375 8.41563 7 8 7H7C6.5875 7 6.25 7.3375 6.25 7.75C6.25 8.1625 6.5875 8.5 7 8.5H7.25V10.5H6.75C6.3375 10.5 6 10.8375 6 11.25C6 11.6625 6.3375 12 6.75 12H9.25C9.66406 12 10 11.6641 10 11.25C10 10.8359 9.66563 10.5 9.25 10.5ZM8 6C8.55219 6 9 5.55219 9 5C9 4.44781 8.55219 4 8 4C7.44781 4 7 4.44687 7 5C7 5.55313 7.44687 6 8 6Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>How to configure MaxMind (Offline Geo-IP):</strong><br>1. Register for a free account at <a href="https://www.maxmind.com" target="_blank" class="underline text-primary-600 dark:text-primary-400">maxmind.com</a>.<br>2. Download the free <strong>GeoLite2 Country</strong> or <strong>GeoLite2 City</strong> database in binary format (file extension <code class="px-1 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">.mmdb</code>).<br>3. Upload the file to your server, e.g. under the path <code class="px-1 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">storage/geoip/GeoLite2-Country.mmdb</code>.<br>4. Paste the absolute path below and click the <strong>Verify file</strong> button on the right side of the field.</span></div></div>',
'settings_maxmind_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',
@@ -240,7 +267,7 @@ 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="M8 0C3.58125 0 0 3.58125 0 8C0 12.4187 3.58125 16 8 16C12.4187 16 16 12.4187 16 8C16 3.58125 12.4187 0 8 0ZM8 14.5C4.41563 14.5 1.5 11.5841 1.5 8C1.5 4.41594 4.41563 1.5 8 1.5C11.5844 1.5 14.5 4.41594 14.5 8C14.5 11.5841 11.5844 14.5 8 14.5ZM9.25 10.5H8.75V7.75C8.75 7.3375 8.41563 7 8 7H7C6.5875 7 6.25 7.3375 6.25 7.75C6.25 8.1625 6.5875 8.5 7 8.5H7.25V10.5H6.75C6.3375 10.5 6 10.8375 6 11.25C6 11.6625 6.3375 12 6.75 12H9.25C9.66406 12 10 11.6641 10 11.25C10 10.8359 9.66563 10.5 9.25 10.5ZM8 6C8.55219 6 9 5.55219 9 5C9 4.44781 8.55219 4 8 4C7.44781 4 7 4.44687 7 5C7 5.55313 7.44687 6 8 6Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full [&_kbd]:bg-background-light dark:[&_kbd]:bg-background-dark [_code]:!text-current [_kbd]:!text-current [_a]:!text-current [_a]:border-current [_strong]:!text-current text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>Required Cron Task (Scheduler):</strong> You have enabled click counter buffering. You must add the following task to your server\'s crontab:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1</code><br>Command run automatically in the background by the scheduler:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">php artisan short-url:sync-counters</code></span></div></div>',
'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',
@@ -259,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',
@@ -282,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
@@ -292,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',
@@ -312,6 +360,31 @@ return [
'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',
@@ -337,7 +410,7 @@ 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="M8 0C3.58125 0 0 3.58125 0 8C0 12.4187 3.58125 16 8 16C12.4187 16 16 12.4187 16 8C16 3.58125 12.4187 0 8 0ZM8 14.5C4.41563 14.5 1.5 11.5841 1.5 8C1.5 4.41594 4.41563 1.5 8 1.5C11.5844 1.5 14.5 4.41594 14.5 8C14.5 11.5841 11.5844 14.5 8 14.5ZM9.25 10.5H8.75V7.75C8.75 7.3375 8.41563 7 8 7H7C6.5875 7 6.25 7.3375 6.25 7.75C6.25 8.1625 6.5875 8.5 7 8.5H7.25V10.5H6.75C6.3375 10.5 6 10.8375 6 11.25C6 11.6625 6.3375 12 6.75 12H9.25C9.66406 12 10 11.6641 10 11.25C10 10.8359 9.66563 10.5 9.25 10.5ZM8 6C8.55219 6 9 5.55219 9 5C9 4.44781 8.55219 4 8 4C7.44781 4 7 4.44687 7 5C7 5.55313 7.44687 6 8 6Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full [&_kbd]:bg-background-light dark:[&_kbd]:bg-background-dark [_code]:!text-current [_kbd]:!text-current [_a]:!text-current [_a]:border-current [_strong]:!text-current text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>Queue Worker Required:</strong> The selected connection runs asynchronously. You must run a background worker to process these jobs:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">php artisan queue:work --queue=:queue</code></span></div></div>',
'settings_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' => '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).',
@@ -354,6 +427,9 @@ return [
'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',
@@ -392,6 +468,8 @@ 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',
@@ -415,6 +493,8 @@ 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',
@@ -451,6 +531,7 @@ return [
'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',
@@ -460,4 +541,138 @@ return [
'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

@@ -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',
@@ -134,6 +149,8 @@ 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:',
@@ -147,7 +164,7 @@ return [
'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",
'dont_show_again' => 'Nie pokazuj opcji udostępniania po utworzeniu linku',
// Stats Page
'stats_title' => 'Statystyki',
@@ -174,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',
@@ -201,6 +220,10 @@ return [
'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". 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.',
@@ -222,7 +245,7 @@ return [
'settings_geoip_timeout_helper' => 'Maksymalna liczba sekund oczekiwania na odpowiedź zewnętrznego API Geo-IP.',
'settings_maxmind_path' => 'Ścieżka bazy MaxMind',
'settings_maxmind_path_helper' => 'Bezwzględna ścieżka do pliku GeoLite2-Country.mmdb lub GeoIP2-Country.mmdb. Wymagana gdy sterownik to MaxMind.',
'settings_maxmind_info_callout' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M8 0C3.58125 0 0 3.58125 0 8C0 12.4187 3.58125 16 8 16C12.4187 16 16 12.4187 16 8C16 3.58125 12.4187 0 8 0ZM8 14.5C4.41563 14.5 1.5 11.5841 1.5 8C1.5 4.41594 4.41563 1.5 8 1.5C11.5844 1.5 14.5 4.41594 14.5 8C14.5 11.5841 11.5844 14.5 8 14.5ZM9.25 10.5H8.75V7.75C8.75 7.3375 8.41563 7 8 7H7C6.5875 7 6.25 7.3375 6.25 7.75C6.25 8.1625 6.5875 8.5 7 8.5H7.25V10.5H6.75C6.3375 10.5 6 10.8375 6 11.25C6 11.6625 6.3375 12 6.75 12H9.25C9.66406 12 10 11.6641 10 11.25C10 10.8359 9.66563 10.5 9.25 10.5ZM8 6C8.55219 6 9 5.55219 9 5C9 4.44781 8.55219 4 8 4C7.44781 4 7 4.44687 7 5C7 5.55313 7.44687 6 8 6Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>Jak skonfigurować MaxMind (Offline Geo-IP):</strong><br>1. Zarejestruj darmowe konto na <a href="https://www.maxmind.com" target="_blank" class="underline text-primary-600 dark:text-primary-400">maxmind.com</a>.<br>2. Pobierz bezpłatną bazę danych <strong>GeoLite2 Country</strong> lub <strong>GeoLite2 City</strong> w formacie binarnym (plik z rozszerzeniem <code class="px-1 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">.mmdb</code>).<br>3. Prześlij plik na swój serwer, np. do katalogu <code class="px-1 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">storage/geoip/GeoLite2-Country.mmdb</code>.<br>4. Wpisz pełną ścieżkę bezwzględną poniżej i kliknij przycisk <strong>Zweryfikuj plik</strong> z prawej strony pola.</span></div></div>',
'settings_maxmind_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',
@@ -241,7 +264,7 @@ 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="M8 0C3.58125 0 0 3.58125 0 8C0 12.4187 3.58125 16 8 16C12.4187 16 16 12.4187 16 8C16 3.58125 12.4187 0 8 0ZM8 14.5C4.41563 14.5 1.5 11.5841 1.5 8C1.5 4.41594 4.41563 1.5 8 1.5C11.5844 1.5 14.5 4.41594 14.5 8C14.5 11.5841 11.5844 14.5 8 14.5ZM9.25 10.5H8.75V7.75C8.75 7.3375 8.41563 7 8 7H7C6.5875 7 6.25 7.3375 6.25 7.75C6.25 8.1625 6.5875 8.5 7 8.5H7.25V10.5H6.75C6.3375 10.5 6 10.8375 6 11.25C6 11.6625 6.3375 12 6.75 12H9.25C9.66406 12 10 11.6641 10 11.25C10 10.8359 9.66563 10.5 9.25 10.5ZM8 6C8.55219 6 9 5.55219 9 5C9 4.44781 8.55219 4 8 4C7.44781 4 7 4.44687 7 5C7 5.55313 7.44687 6 8 6Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full [&_kbd]:bg-background-light dark:[&_kbd]:bg-background-dark [_code]:!text-current [_kbd]:!text-current [_a]:!text-current [_a]:border-current [_strong]:!text-current text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>Wymagane zadanie Cron (Scheduler):</strong> Włączyłeś buforowanie kliknięć. Musisz dodać do harmonogramu zadań (cron) komendę synchronizującą liczniki z bazą danych:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">* * * * * cd /sciezka-do-twojego-projektu && php artisan schedule:run >> /dev/null 2>&1</code><br>Komenda uruchamiana automatycznie w tle przez harmonogram:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">php artisan short-url:sync-counters</code></span></div></div>',
'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',
@@ -260,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',
@@ -283,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
@@ -293,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',
@@ -313,6 +357,31 @@ return [
'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',
@@ -338,7 +407,7 @@ 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_queue_worker_info' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M8 0C3.58125 0 0 3.58125 0 8C0 12.4187 3.58125 16 8 16C12.4187 16 16 12.4187 16 8C16 3.58125 12.4187 0 8 0ZM8 14.5C4.41563 14.5 1.5 11.5841 1.5 8C1.5 4.41594 4.41563 1.5 8 1.5C11.5844 1.5 14.5 4.41594 14.5 8C14.5 11.5841 11.5844 14.5 8 14.5ZM9.25 10.5H8.75V7.75C8.75 7.3375 8.41563 7 8 7H7C6.5875 7 6.25 7.3375 6.25 7.75C6.25 8.1625 6.5875 8.5 7 8.5H7.25V10.5H6.75C6.3375 10.5 6 10.8375 6 11.25C6 11.6625 6.3375 12 6.75 12H9.25C9.66406 12 10 11.6641 10 11.25C10 10.8359 9.66563 10.5 9.25 10.5ZM8 6C8.55219 6 9 5.55219 9 5C9 4.44781 8.55219 4 8 4C7.44781 4 7 4.44687 7 5C7 5.55313 7.44687 6 8 6Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full [&_kbd]:bg-background-light dark:[&_kbd]:bg-background-dark [_code]:!text-current [_kbd]:!text-current [_a]:!text-current [_a]:border-current [_strong]:!text-current text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>Wymagany proces w tle (Queue Worker):</strong> Wybrane połączenie działa asynchronicznie. Musisz uruchomić proces w tle (workera), aby przetwarzać te zadania:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">php artisan queue:work --queue=:queue</code></span></div></div>',
'settings_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).',
@@ -355,6 +424,9 @@ return [
'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',
@@ -393,6 +465,8 @@ 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',
@@ -416,6 +490,8 @@ 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',
@@ -452,6 +528,7 @@ return [
'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ęć',
@@ -461,4 +538,139 @@ return [
'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,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">
@@ -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

@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ __('filament-short-url::default.pixel_loading_title') ?? 'Connecting...' }}</title>
<title>{{ __('filament-short-url::default.pixel_loading_title') }}</title>
<!-- Premium Google Fonts: Bricolage Grotesque -->
<link rel="preconnect" href="https://fonts.googleapis.com">
@@ -33,8 +33,16 @@
}
</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?
@@ -44,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=[]}
@@ -76,7 +92,36 @@
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
<!-- 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">
@@ -103,10 +148,10 @@
</div>
<p class="text-xl font-medium text-neutral-900 dark:text-white mt-2">
{{ __('filament-short-url::default.pixel_loading_title') ?? 'Connecting...' }}
{{ __('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') ?? 'Preparing your connection and forwarding you now.' }}
{{ __('filament-short-url::default.pixel_loading_description') }}
</p>
</div>

View File

@@ -9,7 +9,6 @@
if (isset($component) && method_exists($component, 'getStatePath')) {
$statePath = $component->getStatePath();
\Illuminate\Support\Facades\Log::info('QR DESIGNER STATE PATH: ' . $statePath);
if ($statePath) {
$parts = explode('.', $statePath);
if (end($parts) === 'qr_designer') {

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,34 +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))
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsChart::class, [
'record' => $record,
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
], key('stats-chart-' . $dateFrom . '-' . $dateTo))
'filters' => $activeFilters,
], key('stats-chart-' . $dateFrom . '-' . $dateTo . '-' . $filtersHash))
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsRightBreakdown::class, [
'record' => $record,
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
], key('stats-right-breakdown-' . $dateFrom . '-' . $dateTo))
<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,
], key('stats-world-map-' . $dateFrom . '-' . $dateTo))
'filters' => $activeFilters,
], key('stats-world-map-' . $dateFrom . '-' . $dateTo . '-' . $filtersHash))
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsBottomBreakdown::class, [
<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,
], key('stats-bottom-breakdown-' . $dateFrom . '-' . $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">
@@ -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-globe-europe-africa',
'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,63 +0,0 @@
<x-filament-widgets::widget>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
{{-- Countries --}}
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
<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;
$translatedCountry = \Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsRightBreakdown::getCountryTranslation($country);
@endphp
<div>
<div class="flex items-center justify-between text-sm">
<span class="font-medium text-gray-700 dark:text-gray-300">{{ $translatedCountry }}</span>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span></span>
</div>
<div class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<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>
{{-- Languages --}}
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-indigo-50 text-indigo-500 dark:bg-indigo-950/50 dark:text-indigo-400">
<x-filament::icon icon="heroicon-o-language" class="h-5 w-5" />
</div>
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_languages') }}</h3>
</div>
<div class="space-y-3">
@forelse ($visitsByLanguage as $langCode => $count)
@php
$pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0;
$langName = \Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsRightBreakdown::getLanguageTranslation($langCode);
@endphp
<div>
<div class="flex items-center justify-between text-sm">
<span class="font-medium text-gray-700 dark:text-gray-300">{{ $langName }} ({{ strtoupper($langCode) }})</span>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span></span>
</div>
<div class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div class="h-full rounded-full bg-indigo-500 transition-all duration-500" style="width: {{ $pct }}%"></div>
</div>
</div>
@empty
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_language_data') }}</p>
@endforelse
</div>
</div>
</div>
</x-filament-widgets::widget>

View File

@@ -1,12 +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\Http\Request;
use Illuminate\Support\Facades\Log;
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}',
@@ -17,22 +23,27 @@ Route::match(
->middleware(config('filament-short-url.middleware', ['web', 'throttle:120,1']));
Route::prefix('api/short-url')
->middleware([AuthenticateShortUrlApi::class])
->middleware([
AuthenticateShortUrlApi::class,
'throttle:60,1',
])
->group(function () {
Route::get('links', [ShortUrlApiController::class, 'index']);
Route::post('links', [ShortUrlApiController::class, 'store']);
Route::delete('links/{id}', [ShortUrlApiController::class, 'destroy']);
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', [ShortUrlApiController::class, 'uploadLogo'])
Route::post('admin/short-url/upload-logo', [ShortUrlLogoController::class, 'uploadLogo'])
->name('short-url.upload-logo')
->middleware(['web']);
Route::post('admin/short-url/log-debug', function (Request $request) {
Log::info('QR DESIGNER JS DEBUG: '.json_encode($request->all()));
return response()->json(['status' => 'ok']);
})->middleware(['web']);
Route::get('short-url/logo/{filename}', [ShortUrlApiController::class, 'serveLogo'])
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

@@ -42,97 +42,116 @@ class AggregateAndPruneVisitsCommand extends Command
foreach ($dates as $date) {
// Wrap date aggregation in a database transaction for data integrity
DB::transaction(function () use ($date): void {
// Accumulate stats per short_url_id using chunked reads — avoids loading
// potentially millions of rows into PHP memory at once.
$statsByUrl = [];
$nextDate = Carbon::parse($date)->addDay()->toDateString();
$start = $date.' 00:00:00';
$end = $nextDate.' 00:00:00';
DB::table('short_url_visits')
->where('visited_at', '>=', $date.' 00:00:00')
->where('visited_at', '<', $nextDate.' 00:00:00')
// 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';
$totals = DB::table('short_url_visits')
->where('visited_at', '>=', $start)
->where('visited_at', '<', $end)
->select([
'short_url_id',
'is_qr_scan',
'ip_hash',
'device_type',
'browser',
'operating_system',
'country',
'country_code',
'utm_source',
'utm_medium',
'utm_campaign',
'browser_language',
'city',
'referer_host',
DB::raw('count(*) as total'),
DB::raw('count(distinct ip_hash) as uniques'),
DB::raw($qrExpr),
])
->orderBy('id')
->chunk(1000, function ($chunk) use (&$statsByUrl): void {
foreach ($chunk as $visit) {
$urlId = $visit->short_url_id;
->groupBy('short_url_id')
->get();
if (! isset($statsByUrl[$urlId])) {
$statsByUrl[$urlId] = [
'total' => 0,
'ip_hashes' => [],
'device_stats' => [],
'browser_stats' => [],
'os_stats' => [],
'country_stats' => [],
'city_stats' => [],
'referer_stats' => [],
'utm_source_stats' => [],
'utm_medium_stats' => [],
'utm_campaign_stats' => [],
'qr_scans' => 0,
'language_stats' => [],
];
}
if ($totals->isEmpty()) {
return;
}
$s = &$statsByUrl[$urlId];
$s['total']++;
$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' => [],
];
}
if ($visit->is_qr_scan) {
$s['qr_scans']++;
}
// 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;
}
if ($visit->ip_hash) {
$s['ip_hashes'][$visit->ip_hash] = true;
}
$query = DB::table('short_url_visits')
->where('visited_at', '>=', $start)
->where('visited_at', '<', $end)
->whereIn('short_url_id', $urlIds)
->whereNotNull($column)
->where($column, '<>', '');
$inc = function (?string $value, string $key) use (&$s): void {
if ($value) {
$s[$key][$value] = ($s[$key][$value] ?? 0) + 1;
}
};
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]);
}
$inc($visit->device_type, 'device_stats');
$inc($visit->browser, 'browser_stats');
$inc($visit->operating_system, 'os_stats');
$inc($visit->country, 'country_stats');
$inc($visit->utm_source, 'utm_source_stats');
$inc($visit->utm_medium, 'utm_medium_stats');
$inc($visit->utm_campaign, 'utm_campaign_stats');
$inc($visit->browser_language, 'language_stats');
$rows = $query->get();
if ($visit->city) {
$cityKey = "{$visit->city} ({$visit->country_code})";
$s['city_stats'][$cityKey] = ($s['city_stats'][$cityKey] ?? 0) + 1;
}
if ($visit->referer_host) {
$s['referer_stats'][$visit->referer_host] = ($s['referer_stats'][$visit->referer_host] ?? 0) + 1;
}
foreach ($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' => count($s['ip_hashes']),
'unique_visits_count' => $s['uniques'],
'device_stats' => $s['device_stats'],
'browser_stats' => $s['browser_stats'],
'os_stats' => $s['os_stats'],
@@ -144,6 +163,7 @@ class AggregateAndPruneVisitsCommand extends Command
'utm_campaign_stats' => $s['utm_campaign_stats'],
'qr_visits_count' => $s['qr_scans'],
'language_stats' => $s['language_stats'],
'variant_stats' => $s['variant_stats'],
]);
}
});

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,19 +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 {
if (Redis::exists($dirtyKey)) {
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 {
@@ -46,7 +53,7 @@ class SyncBufferedCountersCommand extends Command
return 0;
}
$dirtyIds = array_unique(array_filter($dirtyIds));
$dirtyIds = array_unique(array_filter(array_map('intval', $dirtyIds)));
$processed = 0;
$updatesToMake = [];
@@ -76,7 +83,11 @@ class SyncBufferedCountersCommand extends Command
try {
DB::transaction(function () use ($updatesToMake, &$processed) {
$shortUrls = ShortUrl::whereIn('id', array_keys($updatesToMake))->get(['id', 'url_key']);
$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([
@@ -87,12 +98,24 @@ class SyncBufferedCountersCommand extends Command
$processed++;
}
// Bust redirect cache for all host-variant keys so subsequent requests
// see fresh counters (for max_visits enforcement etc.)
foreach ($shortUrls as $url) {
Cache::forget("filament-short-url:{$url->url_key}");
$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) {
// Restore pulled values in cache so no clicks are lost
// 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}";
@@ -109,9 +132,10 @@ class SyncBufferedCountersCommand extends Command
}
}
// Put the IDs back into the dirty list
if (Cache::getDefaultDriver() === 'redis' && class_exists(Redis::class)) {
Redis::sadd($dirtyKey, ...array_keys($updatesToMake));
// 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) {

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

@@ -19,22 +19,17 @@ 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'))
->visible(fn () => ShortUrlSettingsPage::canAccess()),
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 {
->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;
@@ -76,16 +71,16 @@ class ListShortUrls extends ManageRecords
$encoded = urlencode($shortUrl);
$eid = 'fsu_'.substr(md5($shortUrl), 0, 8);
$successTitle = __('filament-short-url::default.success_modal_title') ?? 'Your link & QR code are ready!';
$successSubtitle = __('filament-short-url::default.success_modal_subtitle') ?? 'Time to get some clicks 🎉';
$successHelper = __('filament-short-url::default.success_modal_helper') ?? 'Copy and share manually or choose a platform.';
$downloadSvgText = __('filament-short-url::default.qr_download_svg') ?? 'Download SVG';
$downloadPngText = __('filament-short-url::default.qr_download_png') ?? 'Download PNG';
$closeButtonText = __('filament-short-url::default.close_button') ?? 'Close';
$copyLinkText = __('filament-short-url::default.action_copy') ?? 'Copy link';
$qrCodeText = __('filament-short-url::default.action_qr') ?? 'QR Code';
$openLinkText = __('filament-short-url::default.open_link') ?? 'Open link';
$dontShowAgainText = __('filament-short-url::default.dont_show_again') ?? "Don't show sharing options after creating a link";
$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', []);

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,39 +3,60 @@
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\SafeBrowsingService;
use Bjanczak\FilamentShortUrl\Services\ShortUrlSettingsManager;
use Filament\Actions\Action;
use Filament\Forms\Components\ColorPicker;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\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\Gate;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\HtmlString;
class ShortUrlSettingsPage extends Page implements HasForms
{
use InteractsWithForms;
protected static string $resource = ShortUrlResource::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-cog-6-tooth';
protected static ?string $slug = 'short-url-settings';
protected string $view = 'filament-short-url::settings';
public static function getNavigationLabel(): string
{
return __('filament-short-url::default.settings_nav_label');
}
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 {
@@ -55,17 +76,33 @@ class ShortUrlSettingsPage extends Page implements HasForms
}
// Default fallback: Check if the user is authorized to view the resource in general
return static::getResource()::canViewAny();
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),
@@ -87,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),
@@ -117,6 +155,12 @@ class ShortUrlSettingsPage extends Page implements HasForms
'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'),
]);
}
@@ -127,717 +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('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(),
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(),
]),
]),
// ── 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')),
Placeholder::make('geoip_headers_warning')
->content(function () {
$html = __('filament-short-url::default.settings_geoip_headers_warning');
return new HtmlString($html);
})
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled') &&
$get('geo_ip_driver') === 'headers' &&
! (bool) $get('trust_cdn_headers')
)
->columnSpanFull(),
// ── Cache TTL (only when geo-ip is on) ──
TextInput::make('geo_ip_cache_ttl')
->label(__('filament-short-url::default.settings_geoip_cache_ttl'))
->helperText(__('filament-short-url::default.settings_geoip_cache_ttl_helper'))
->numeric()
->integer()
->minValue(0)
->maxValue(31536000)
->suffix('s')
->required()
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled')),
// ── Stats Cache TTL (only when geo-ip is on) ──
TextInput::make('geo_ip_stats_cache_ttl')
->label(__('filament-short-url::default.settings_geoip_stats_cache_ttl'))
->helperText(__('filament-short-url::default.settings_geoip_stats_cache_ttl_helper'))
->numeric()
->integer()
->minValue(0)
->maxValue(86400)
->suffix('s')
->required()
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled')),
// ── 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) ──
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'),
]),
]),
// ── 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')),
]),
Section::make(__('filament-short-url::default.settings_section_security_v2'))
->description(__('filament-short-url::default.settings_section_security_v2_desc'))
->columns(2)
->schema([
Toggle::make('vpn_detection_enabled')
->label(__('filament-short-url::default.settings_vpn_detection_enabled'))
->helperText(__('filament-short-url::default.settings_vpn_detection_enabled_helper'))
->default(false)
->inline(false)
->live()
->columnSpanFull(),
Select::make('vpn_detection_driver')
->label(__('filament-short-url::default.settings_vpn_driver'))
->helperText(__('filament-short-url::default.settings_vpn_driver_helper'))
->options([
'ip-api' => __('filament-short-url::default.settings_vpn_driver_ipapi'),
'vpnapi' => __('filament-short-url::default.settings_vpn_driver_vpnapi'),
])
->default('ip-api')
->live()
->required()
->visible(fn (Get $get): bool => (bool) $get('vpn_detection_enabled')),
TextInput::make('vpnapi_key')
->label(__('filament-short-url::default.settings_vpnapi_key'))
->helperText(__('filament-short-url::default.settings_vpnapi_key_helper'))
->password()
->revealable()
->placeholder('••••••••••••••••••••')
->visible(fn (Get $get): bool => (bool) $get('vpn_detection_enabled') && $get('vpn_detection_driver') === 'vpnapi'),
Select::make('vpn_block_action')
->label(__('filament-short-url::default.settings_vpn_block_action'))
->helperText(__('filament-short-url::default.settings_vpn_block_action_helper'))
->options([
'flag_only' => __('filament-short-url::default.settings_vpn_block_flag_only'),
'block_with_403' => __('filament-short-url::default.settings_vpn_block_block_403'),
])
->default('flag_only')
->required()
->visible(fn (Get $get): bool => (bool) $get('vpn_detection_enabled')),
Toggle::make('safe_browsing_enabled')
->label(__('filament-short-url::default.settings_safe_browsing_enabled'))
->helperText(__('filament-short-url::default.settings_safe_browsing_enabled_helper'))
->default(false)
->inline(false)
->live()
->columnSpanFull(),
TextInput::make('google_safe_browsing_api_key')
->label(__('filament-short-url::default.settings_safe_browsing_api_key'))
->helperText(__('filament-short-url::default.settings_safe_browsing_api_key_helper'))
->password()
->revealable()
->placeholder('AIza••••••••••••••••••')
->columnSpanFull()
->suffixAction(
Action::make('testSafeBrowsing')
->label(__('filament-short-url::default.settings_safe_browsing_test'))
->icon('heroicon-o-signal')
->color('gray')
->action(function (Get $get): void {
$key = trim($get('google_safe_browsing_api_key') ?? '');
if (empty($key)) {
Notification::make()
->title(__('filament-short-url::default.settings_safe_browsing_test_empty'))
->warning()->send();
return;
}
try {
$svc = app(SafeBrowsingService::class);
$safe = $svc->isSafeWithKey('https://google.com', $key);
Notification::make()
->title($safe
? __('filament-short-url::default.settings_safe_browsing_test_ok')
: __('filament-short-url::default.settings_safe_browsing_test_fail'))
->color($safe ? 'success' : 'danger')
->send();
} catch (\Throwable $e) {
Notification::make()
->title(__('filament-short-url::default.settings_safe_browsing_test_error'))
->body($e->getMessage())
->danger()->send();
}
})
)
->visible(fn (Get $get): bool => (bool) $get('safe_browsing_enabled')),
]),
]),
// ── Tracking Defaults ─────────────────────────────────
Tab::make(__('filament-short-url::default.settings_tab_tracking_defaults'))
->icon('heroicon-o-chart-bar')
->schema([
Section::make(__('filament-short-url::default.settings_section_tracking_defaults'))
->description(__('filament-short-url::default.settings_section_tracking_defaults_helper'))
->schema([
Toggle::make('tracking_enabled')
->label(__('filament-short-url::default.settings_track_visits_default'))
->live()
->inline(false)
->columnSpanFull(),
Toggle::make('tracking_fields_ip_address')
->label(__('filament-short-url::default.settings_track_ip_default'))
->inline(false)
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
Toggle::make('tracking_fields_browser')
->label(__('filament-short-url::default.settings_track_browser_default'))
->inline(false)
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
Toggle::make('tracking_fields_browser_version')
->label(__('filament-short-url::default.settings_track_browser_version_default'))
->inline(false)
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
Toggle::make('tracking_fields_operating_system')
->label(__('filament-short-url::default.settings_track_os_default'))
->inline(false)
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
Toggle::make('tracking_fields_operating_system_version')
->label(__('filament-short-url::default.settings_track_os_version_default'))
->inline(false)
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
Toggle::make('tracking_fields_browser_language')
->label(__('filament-short-url::default.settings_track_browser_language_default'))
->inline(false)
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
Toggle::make('tracking_fields_referer_url')
->label(__('filament-short-url::default.settings_track_referer_default'))
->inline(false)
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
Toggle::make('tracking_fields_device_type')
->label(__('filament-short-url::default.settings_track_device_type_default'))
->inline(false)
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
])
->columns(4),
]),
// ── QR Defaults ──────────────────────────────────────
Tab::make(__('filament-short-url::default.settings_tab_qr'))
->icon('heroicon-o-qr-code')
->schema([
Section::make(__('filament-short-url::default.settings_section_qr_defaults'))
->description(__('filament-short-url::default.settings_section_qr_defaults_helper'))
->columns(2)
->schema([
TextInput::make('qr_size')
->label(__('filament-short-url::default.settings_qr_size'))
->numeric()
->integer()
->minValue(100)
->maxValue(2000)
->required(),
Select::make('qr_margin')
->label(__('filament-short-url::default.settings_qr_margin'))
->options(array_combine(range(0, 10), range(0, 10)))
->required(),
Select::make('qr_dot_style')
->label(__('filament-short-url::default.settings_qr_dot_style'))
->options([
'square' => __('filament-short-url::default.qr_option_square'),
'dots' => __('filament-short-url::default.qr_option_dots'),
'rounded' => __('filament-short-url::default.qr_option_rounded'),
'classy' => __('filament-short-url::default.qr_option_classy'),
'classy-rounded' => __('filament-short-url::default.qr_option_classy_rounded'),
'extra-rounded' => __('filament-short-url::default.qr_option_extra_rounded'),
])
->required(),
ColorPicker::make('qr_foreground_color')
->label(__('filament-short-url::default.settings_qr_foreground_color'))
->regex('/^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})\b$/')
->placeholder('#000000')
->required(),
ColorPicker::make('qr_background_color')
->label(__('filament-short-url::default.settings_qr_background_color'))
->regex('/^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})\b$/')
->placeholder('#ffffff')
->required(),
Toggle::make('qr_gradient_enabled')
->label(__('filament-short-url::default.settings_qr_gradient_enabled'))
->live()
->inline(false),
ColorPicker::make('qr_gradient_from')
->label(__('filament-short-url::default.settings_qr_gradient_from'))
->regex('/^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})\b$/')
->placeholder('#4f46e5')
->required(fn (Get $get): bool => (bool) $get('qr_gradient_enabled'))
->visible(fn (Get $get): bool => (bool) $get('qr_gradient_enabled')),
ColorPicker::make('qr_gradient_to')
->label(__('filament-short-url::default.settings_qr_gradient_to'))
->regex('/^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})\b$/')
->placeholder('#06b6d4')
->required(fn (Get $get): bool => (bool) $get('qr_gradient_enabled'))
->visible(fn (Get $get): bool => (bool) $get('qr_gradient_enabled')),
Select::make('qr_gradient_type')
->label(__('filament-short-url::default.settings_qr_gradient_type'))
->options([
'linear' => __('filament-short-url::default.qr_gradient_linear'),
'radial' => __('filament-short-url::default.qr_gradient_radial'),
])
->required(fn (Get $get): bool => (bool) $get('qr_gradient_enabled'))
->visible(fn (Get $get): bool => (bool) $get('qr_gradient_enabled')),
]),
]),
// ── Developer API & Webhooks ────────────────────────
Tab::make(__('filament-short-url::default.settings_tab_developer'))
->icon('heroicon-o-cpu-chip')
->schema([
Section::make(__('filament-short-url::default.settings_section_rest_api'))
->schema([
Toggle::make('api_enabled')
->label(__('filament-short-url::default.settings_api_enabled'))
->helperText(__('filament-short-url::default.settings_api_enabled_helper'))
->default(false)
->inline(false)
->columnSpanFull()
->live(),
]),
Section::make(__('filament-short-url::default.settings_section_api_keys'))
->description(__('filament-short-url::default.settings_api_keys_description'))
->visible(fn (Get $get): bool => (bool) $get('api_enabled'))
->schema([
Repeater::make('api_keys')
->label(__('filament-short-url::default.settings_api_keys'))
->schema([
TextInput::make('name')
->label(__('filament-short-url::default.api_key_name'))
->required(),
TextInput::make('key')
->label(__('filament-short-url::default.api_key'))
->disabled()
->dehydrated()
->default(fn () => 'sh_key_'.bin2hex(random_bytes(16))),
Toggle::make('is_active')
->label(__('filament-short-url::default.active'))
->default(true),
])
->columns(3)
->default([]),
]),
Section::make(__('filament-short-url::default.settings_section_global_webhook'))
->schema([
Toggle::make('global_webhook_enabled')
->label(__('filament-short-url::default.settings_global_webhook_enabled'))
->helperText(__('filament-short-url::default.settings_global_webhook_enabled_helper'))
->live()
->inline(false)
->afterStateUpdated(function (bool $state, $set) {
if (! $state) {
$set('global_webhook_url', null);
$set('webhook_events', ['visited']);
}
}),
TextInput::make('global_webhook_url')
->label(__('filament-short-url::default.settings_global_webhook_url'))
->helperText(__('filament-short-url::default.settings_global_webhook_url_helper'))
->url()
->nullable()
->columnSpanFull()
->visible(fn (Get $get): bool => (bool) $get('global_webhook_enabled')),
Select::make('webhook_events')
->label(__('filament-short-url::default.settings_webhook_events'))
->helperText(__('filament-short-url::default.settings_webhook_events_helper'))
->multiple()
->options([
'visited' => __('filament-short-url::default.webhook_event_visited'),
'created' => __('filament-short-url::default.webhook_event_created'),
'expired' => __('filament-short-url::default.webhook_event_expired'),
'limit_reached' => __('filament-short-url::default.webhook_event_limit_reached'),
])
->default(['visited'])
->columnSpanFull()
->visible(fn (Get $get): bool => (bool) $get('global_webhook_enabled')),
]),
]),
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
@@ -848,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,580 +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\SafeBrowsingService;
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
use Filament\Actions\Action;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Components\ViewField;
use Filament\Schemas\Components\Group;
use Filament\Schemas\Components\Section;
use 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)
->rules([
fn (): \Closure => function (string $attribute, $value, \Closure $fail) {
$safeBrowsing = app(SafeBrowsingService::class);
if (! $safeBrowsing->isSafe($value)) {
$fail(__('filament-short-url::default.safe_browsing_error') ?? 'This URL has been flagged by Google Safe Browsing as unsafe.');
}
},
])
->live(onBlur: true)
->afterStateHydrated(function (TextInput $component, $state, Set $set) {
if (! $state) {
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')),
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) => 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([
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),
]);
}
private static function targetingTab(): Tab
{
return Tab::make(__('filament-short-url::default.tab_targeting'))
->icon('heroicon-o-shield-check')
->schema([
Section::make(__('filament-short-url::default.security_section_title'))
->schema([
TextInput::make('password')
->label(__('filament-short-url::default.password'))
->helperText(__('filament-short-url::default.password_helper'))
->password()
->revealable()
->nullable()
->maxLength(255),
Toggle::make('show_warning_page')
->label(__('filament-short-url::default.show_warning_page'))
->helperText(__('filament-short-url::default.show_warning_page_helper'))
->default(false)
->inline(false),
])->columns(2),
Section::make(__('filament-short-url::default.targeting_type'))
->schema([
Select::make('targeting_rules.type')
->label(__('filament-short-url::default.targeting_type'))
->options([
'none' => __('filament-short-url::default.targeting_type_none'),
'device' => __('filament-short-url::default.targeting_type_device'),
'geo' => __('filament-short-url::default.targeting_type_country'),
'language' => __('filament-short-url::default.targeting_type_language'),
'rotation' => __('filament-short-url::default.targeting_type_rotation'),
])
->default('none')
->live()
->required(),
Section::make(__('filament-short-url::default.device_targeting_rules'))
->schema([
TextInput::make('targeting_rules.device.mobile')
->label(__('filament-short-url::default.device_mobile'))
->url()
->nullable()
->maxLength(2048),
TextInput::make('targeting_rules.device.tablet')
->label(__('filament-short-url::default.device_tablet'))
->url()
->nullable()
->maxLength(2048),
TextInput::make('targeting_rules.device.desktop')
->label(__('filament-short-url::default.device_desktop'))
->url()
->nullable()
->maxLength(2048),
])
->columns(1)
->visible(fn (Get $get): bool => $get('targeting_rules.type') === 'device'),
Repeater::make('targeting_rules.geo')
->label(__('filament-short-url::default.country_targeting_rules'))
->schema([
Select::make('country_code')
->label(__('filament-short-url::default.country_code'))
->options(function (): array {
$countries = __('filament-short-url::countries');
if (is_array($countries)) {
asort($countries, SORT_LOCALE_STRING);
return $countries;
}
return [];
})
->searchable()
->optionsLimit(300)
->disableOptionsWhenSelectedInSiblingRepeaterItems()
->required(),
TextInput::make('url')
->label(__('filament-short-url::default.destination_url'))
->url()
->required()
->maxLength(2048),
])
->columns(2)
->visible(fn (Get $get): bool => $get('targeting_rules.type') === 'geo'),
Repeater::make('targeting_rules.language')
->label(__('filament-short-url::default.language_targeting_rules'))
->schema([
Select::make('language_code')
->label(__('filament-short-url::default.language_code'))
->options(function (): array {
$languages = __('filament-short-url::languages');
if (is_array($languages)) {
asort($languages, SORT_LOCALE_STRING);
return $languages;
}
return [];
})
->searchable()
->disableOptionsWhenSelectedInSiblingRepeaterItems()
->required(),
TextInput::make('url')
->label(__('filament-short-url::default.destination_url'))
->url()
->required()
->maxLength(2048),
])
->columns(2)
->visible(fn (Get $get): bool => $get('targeting_rules.type') === 'language'),
Repeater::make('targeting_rules.rotation')
->label(__('filament-short-url::default.rotation_targeting_rules'))
->schema([
TextInput::make('url')
->label(__('filament-short-url::default.rotation_url'))
->url()
->required()
->maxLength(2048),
TextInput::make('weight')
->label(__('filament-short-url::default.rotation_weight'))
->helperText(__('filament-short-url::default.rotation_weight_helper'))
->numeric()
->integer()
->minValue(1)
->maxValue(1000)
->required()
->default(50),
])
->columns(2)
->visible(fn (Get $get): bool => $get('targeting_rules.type') === 'rotation'),
]),
]);
}
private static function marketingTab(): Tab
{
return Tab::make(__('filament-short-url::default.tab_marketing'))
->icon('heroicon-o-megaphone')
->schema([
Section::make(__('filament-short-url::default.marketing_pixels_title'))
->description(__('filament-short-url::default.marketing_pixels_desc'))
->schema([
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,151 +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.' '.__('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>
'.$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>
'.$record->qr_scans.' '.__('filament-short-url::default.badge_qr_scans').'
</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->translatedFormat('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>
'.__('filament-short-url::default.badge_expires', ['date' => $record->expires_at->translatedFormat('M d, Y')]).'
' : '
<svg class="w-3.5 h-3.5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
'.__('filament-short-url::default.badge_no_expiry').'
').'
</span>
<!-- Redirect Type Badge -->
<span class="inline-flex items-center gap-1 bg-[#f4f4f5] dark:bg-gray-800 px-2 py-1 rounded text-[11px] font-medium text-gray-600 dark:text-gray-300">
<svg class="w-3.5 h-3.5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
</svg>
'.__('filament-short-url::default.badge_redirect', ['code' => $record->redirect_status_code]).'
</span>
</div>
')),
->view('filament-short-url::table.metadata-badges'),
]),
])
->filters([
@@ -193,413 +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')).'\'
});
}
"
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>
')),
]),
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;
Action::make('qrCode')
->label(__('filament-short-url::default.action_qr') ?? 'QR Code')
->icon('heroicon-o-qr-code')
->color('gray')
->iconButton()
->tooltip(__('filament-short-url::default.action_qr') ?? 'QR Code')
->modalWidth('md')
->modalSubmitAction(false)
->modalCancelAction(false)
->form(function (ShortUrl $record): array {
$shortUrl = $record->getShortUrl();
$qrTargetUrl = $shortUrl.'?source=qr';
$destHost = parse_url($record->destination_url, PHP_URL_HOST) ?? '';
$urlKey = $record->url_key;
$eid = 'fsu_'.substr(md5($shortUrl), 0, 8);
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();
$qrHelperText = __('filament-short-url::default.qr_modal_helper') ?? 'Scan, copy, or download your custom QR code.';
$downloadSvgText = __('filament-short-url::default.qr_download_svg') ?? 'Download SVG';
$downloadPngText = __('filament-short-url::default.qr_download_png') ?? 'Download PNG';
$closeButtonText = __('filament-short-url::default.close_button') ?? 'Close';
$copyLinkText = __('filament-short-url::default.action_copy') ?? 'Copy link';
$openLinkText = __('filament-short-url::default.open_link') ?? 'Open link';
$action->process(function () use ($record, $state) {
$record->update($state);
});
$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');
$action->success();
$action->sendSuccessNotification();
$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,
];
$livewire->mountedActions = [];
}),
]),
$mainColor = $isGrad ? ($qrDefaults['gradient_from'] ?? '#4f46e5') : $fgColor;
$eyeConfigEnabled = $qrDefaults['eye_config_enabled'] ?? false;
$eyeSquareStyle = $qrDefaults['eye_square_style'] ?? ($dotStyle === 'dots' ? 'dot' : 'square');
$eyeDotStyle = $qrDefaults['eye_dot_style'] ?? ($dotStyle === 'dots' ? 'dot' : 'square');
$eyeColor = $qrDefaults['eye_color'] ?? $mainColor;
$cornersSquareOptions = $eyeConfigEnabled ? [
'type' => $eyeSquareStyle,
'color' => $eyeColor,
] : [
'type' => $dotStyle === 'dots' ? 'dot' : 'square',
'color' => $mainColor,
];
$cornersDotOptions = $eyeConfigEnabled ? [
'type' => $eyeDotStyle,
'color' => $eyeColor,
] : [
'type' => $dotStyle === 'dots' ? 'dot' : 'square',
'color' => $mainColor,
];
$logo = $qrDefaults['logo'] ?? null;
$logoSize = $qrDefaults['logo_size'] ?? 0.3;
$logoMargin = $qrDefaults['logo_margin'] ?? 9;
$logoHideBackground = $qrDefaults['logo_hide_background'] ?? true;
$logoShape = $qrDefaults['logo_shape'] ?? 'square';
$qrOptionsJson = json_encode([
'type' => 'svg',
'width' => 200,
'height' => 200,
'margin' => $qrDefaults['margin'] ?? 1,
'dotsOptions' => $dotsOptions,
'backgroundOptions' => ['color' => $bgColor],
'cornersSquareOptions' => $cornersSquareOptions,
'cornersDotOptions' => $cornersDotOptions,
'image' => $logo ?: null,
'imageOptions' => [
'crossOrigin' => 'anonymous',
'hideBackgroundDots' => $logoHideBackground,
'imageSize' => $logoSize,
'margin' => $logoMargin,
'logoShape' => $logoShape,
],
'qrOptions' => ['errorCorrectionLevel' => $logo ? 'H' : 'M'],
]);
$escapedQrOptions = e($qrOptionsJson);
$html = <<<HTML
<div data-qr-options="{$escapedQrOptions}" x-data="{
shortUrl: '{$shortUrl}',
qrTargetUrl: '{$qrTargetUrl}',
urlKey: '{$urlKey}',
eid: '{$eid}',
init() {
this.\$nextTick(() => {
this.loadScript().then(() => {
const canvas = document.getElementById(this.eid + '_qr_canvas');
if (canvas && !canvas.innerHTML) {
const opts = JSON.parse(this.\$el.getAttribute('data-qr-options'));
opts.data = this.qrTargetUrl;
const fixSvg = () => {
const svg = canvas.querySelector('svg');
if (svg) {
const w = svg.getAttribute('width') || opts.width || 200;
const h = svg.getAttribute('height') || opts.height || 200;
svg.setAttribute('viewBox', '0 0 ' + w + ' ' + h);
svg.style.width = '100%';
svg.style.height = '100%';
}
};
if (opts.image && opts.imageOptions) {
const img = new Image();
img.onload = () => {
const cv = document.createElement('canvas');
cv.width = 1200;
cv.height = 1200;
const cx = cv.getContext('2d');
cx.imageSmoothingEnabled = true;
cx.imageSmoothingQuality = 'high';
const isCircle = opts.imageOptions.logoShape === 'circle';
const targetDim = 1200 - (parseFloat(opts.imageOptions.margin || 0) * 20);
cx.save();
if (isCircle) {
cx.beginPath();
cx.arc(600, 600, targetDim / 2, 0, 2 * Math.PI);
cx.clip();
const scale = Math.max(targetDim / img.width, targetDim / img.height);
const w = img.width * scale;
const h = img.height * scale;
const x = (1200 - w) / 2;
const y = (1200 - h) / 2;
cx.drawImage(img, x, y, w, h);
} else {
cx.beginPath();
const offset = (1200 - targetDim) / 2;
const radius = 144 * (targetDim / 1200);
if (typeof cx.roundRect === 'function') {
cx.roundRect(offset, offset, targetDim, targetDim, radius);
} else {
cx.moveTo(offset + radius, offset);
cx.lineTo(offset + targetDim - radius, offset);
cx.quadraticCurveTo(offset + targetDim, offset, offset + targetDim, offset + radius);
cx.lineTo(offset + targetDim, offset + targetDim - radius);
cx.quadraticCurveTo(offset + targetDim, offset + targetDim, offset + targetDim - radius, offset + targetDim);
cx.lineTo(offset + radius, offset + targetDim);
cx.quadraticCurveTo(offset, offset + targetDim, offset, offset + targetDim - radius);
cx.lineTo(offset, offset + radius);
cx.quadraticCurveTo(offset, offset, offset + radius, offset);
cx.closePath();
}
cx.clip();
const scale = Math.max(targetDim / img.width, targetDim / img.height);
const w = img.width * scale;
const h = img.height * scale;
const x = (1200 - w) / 2;
const y = (1200 - h) / 2;
cx.drawImage(img, x, y, w, h);
}
cx.restore();
opts.image = cv.toDataURL('image/png');
opts.imageOptions.margin = 0;
window['qr_' + this.eid] = new window.QRCodeStyling(opts);
window['qr_' + this.eid].append(canvas);
fixSvg();
};
img.src = opts.image;
} else {
window['qr_' + this.eid] = new window.QRCodeStyling(opts);
window['qr_' + this.eid].append(canvas);
fixSvg();
}
}
});
});
},
loadScript() {
return new Promise((resolve) => {
if (window.QRCodeStyling) return resolve();
const s = document.createElement('script');
s.src = 'https://unpkg.com/qr-code-styling@1.6.0-rc.1/lib/qr-code-styling.js';
s.onload = () => resolve();
s.onerror = () => {
const s2 = document.createElement('script');
s2.src = 'https://cdn.jsdelivr.net/npm/qr-code-styling@1.6.0-rc.1/lib/qr-code-styling.js';
s2.onload = () => resolve();
document.head.appendChild(s2);
};
document.head.appendChild(s);
});
}
}">
<!-- Close Button (x) -->
<button type="button" x-on:click="event.preventDefault(); event.stopPropagation(); const f=\$el.closest('.fi-modal-window'); const m=f?(f.getAttribute('x-on:keydown.window.escape')||'').match(/'(fi-[^']*)'/):null; if(m){\$dispatch('close-modal',{id:m[1]})}else{\$wire.unmountAction()}"
style="position:absolute;top:16px;right:16px;z-index:50;width:28px;height:28px;border-radius:50%;border:none;background:#f3f4f6;cursor:pointer;display:flex;align-items:center;justify-content:center;color:#6b7280;transition:color .15s,background-color .15s"
onmouseover="this.style.color='#374151';this.style.backgroundColor='#e5e7eb'"
onmouseout="this.style.color='#6b7280';this.style.backgroundColor='#f3f4f6'"
title="{$closeButtonText}">
<svg style="width:16px;height:16px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<div style="text-align:center;padding:4px 0 8px">
<p style="font-size:13px;color:#9ca3af;margin:0">{$qrHelperText}</p>
</div>
<!-- URL pill -->
<div style="display:flex;align-items:center;gap:10px;background:#EFF6FF;border-radius:999px;padding:10px 14px;margin:18px 0 0">
<div style="width:30px;height:30px;border-radius:50%;background:#fff;display:flex;align-items:center;justify-content:center;flex-shrink:0;border:1px solid #e5e7eb">
<img src="https://icons.duckduckgo.com/ip2/{$destHost}.ico" style="width:16px;height:16px;object-fit:contain" onerror="this.style.display='none'">
</div>
<span style="flex:1;font-size:14px;font-weight:600;color:#1d4ed8;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{$shortUrl}</span>
<div style="display:flex;align-items:center;gap:4px;flex-shrink:0">
<button id="{$eid}_copy" type="button"
onclick="
event.preventDefault();
event.stopPropagation();
const u='{$shortUrl}';
if(navigator.clipboard&&window.isSecureContext){navigator.clipboard.writeText(u);}
else{const t=document.createElement('textarea');t.value=u;t.style.cssText='position:fixed;left:-9999px';document.body.appendChild(t);t.select();document.execCommand('copy');t.remove();}
const b=document.getElementById('{$eid}_copy');
const prev=b.innerHTML;
b.innerHTML='<svg style=\'width:16px;height:16px;color:#16a34a\' fill=\'none\' viewBox=\'0 0 24 24\' stroke=\'currentColor\' stroke-width=\'2\'><path stroke-linecap=\'round\' stroke-linejoin=\'round\' d=\'M5 13l4 4L19 7\'/></svg>';
setTimeout(()=>b.innerHTML=prev,1800);
"
style="width:30px;height:30px;border-radius:7px;border:1px solid #e5e7eb;background:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .15s"
onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'" title="{$copyLinkText}">
<svg style="width:16px;height:16px;color:#6b7280" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path stroke-linecap="round" stroke-linejoin="round" d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</button>
<a href="{$shortUrl}" target="_blank" rel="noopener noreferrer"
style="width:30px;height:30px;border-radius:7px;border:1px solid #e5e7eb;background:#fff;display:flex;align-items:center;justify-content:center;text-decoration:none;transition:background .15s"
onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'" title="{$openLinkText}">
<svg style="width:15px;height:15px;color:#6b7280" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
</a>
</div>
</div>
<!-- QR Code Container (Visible immediately) -->
<div id="{$eid}_qr_container" style="display:block;margin:16px 0 0;text-align:center;background:#f9fafb;border:1px solid #e5e7eb;border-radius:12px;padding:16px">
<div style="display:flex;justify-content:center;margin-bottom:12px">
<div id="{$eid}_qr_canvas" style="background:#fff;padding:8px;border-radius:8px;box-shadow:0 1px 3px rgba(0,0,0,0.05)"></div>
</div>
<div style="display:flex;justify-content:center;gap:8px">
<button type="button" onclick="event.preventDefault();event.stopPropagation();window['qr_{$eid}']?.download({ name: '{$urlKey}-qr', extension: 'svg' })" style="font-size:12px;font-weight:600;color:#374151;border:1.5px solid #e5e7eb;background:#fff;padding:6px 12px;border-radius:8px;cursor:pointer;transition:background .15s" onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'">{$downloadSvgText}</button>
<button type="button" onclick="event.preventDefault();event.stopPropagation();window['qr_{$eid}']?.download({ name: '{$urlKey}-qr', extension: 'png' })" style="font-size:12px;font-weight:600;color:#374151;border:1.5px solid #e5e7eb;background:#fff;padding:6px 12px;border-radius:8px;cursor:pointer;transition:background .15s" onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'">{$downloadPngText}</button>
</div>
</div>
</div>
HTML;
return [
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(new HtmlString($html)),
];
}),
->content(view('filament-short-url::table.qr-code-modal', ['record' => $record])),
]),
Action::make('stats')
->label(__('filament-short-url::default.action_stats'))
->icon('heroicon-o-chart-bar')
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);
}
}
if (! $destHost) {
$destHost = parse_url($record->destination_url ?? '', PHP_URL_HOST);
}
$destHostEncoded = e($destHost);
$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,
@@ -622,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',
],
];
}
}

View File

@@ -0,0 +1,53 @@
<?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 ShortUrlGeoBreakdownWidget extends Widget
{
use HasStatsFilters;
public ?ShortUrl $record = null;
public ?string $dateFrom = null;
public ?string $dateTo = null;
public string $activeTab = 'countries';
public array $loadedTabs = ['countries' => true];
protected string $view = 'filament-short-url::widgets.geo-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,
'visitsByCountry' => [],
'visitsByCity' => [],
'visitsByLanguage' => [],
'totalVisits' => 0,
];
}
$stats = $this->record->getCachedStats($this->dateFrom, $this->dateTo, $this->filters);
return [
'activeTab' => $this->activeTab,
'visitsByCountry' => isset($this->loadedTabs['countries']) ? ($stats['visitsByCountry'] ?? []) : [],
'visitsByCity' => isset($this->loadedTabs['cities']) ? ($stats['visitsByCity'] ?? []) : [],
'visitsByLanguage' => isset($this->loadedTabs['languages']) ? ($stats['visitsByLanguage'] ?? []) : [],
'totalVisits' => $stats['totalVisits'] ?? 0,
];
}
}

View File

@@ -45,12 +45,14 @@ class ShortUrlGlobalOverview extends BaseWidget
Stat::make(__('filament-short-url::default.stats_card_total'), number_format($clickData['totalVisits']))
->description(abs($clicksTrend).'% '.($clicksTrend >= 0 ? 'up' : 'down').' vs last 7 days')
->descriptionIcon($clicksTrend >= 0 ? 'heroicon-m-arrow-trending-up' : 'heroicon-m-arrow-trending-down')
->chart($clickData['clicksChart'] ?? [])
->icon('heroicon-o-cursor-arrow-rays')
->color($clicksTrend >= 0 ? 'success' : 'danger'),
Stat::make(__('filament-short-url::default.stats_card_unique'), number_format($clickData['uniqueVisits']))
->description(abs($uniquesTrend).'% '.($uniquesTrend >= 0 ? 'up' : 'down').' vs last 7 days')
->descriptionIcon($uniquesTrend >= 0 ? 'heroicon-m-arrow-trending-up' : 'heroicon-m-arrow-trending-down')
->chart($clickData['uniquesChart'] ?? [])
->icon('heroicon-o-user-group')
->color($uniquesTrend >= 0 ? 'success' : 'danger'),
];
@@ -112,6 +114,34 @@ class ShortUrlGlobalOverview extends BaseWidget
->distinct('ip_hash')
->count('ip_hash');
// Fetch last 7 days chart data via database-level aggregation to avoid loading raw models into memory
$driver = DB::connection()->getDriverName();
$dateExpression = match ($driver) {
'sqlite' => "strftime('%Y-%m-%d', visited_at)",
'pgsql' => "to_char(visited_at, 'YYYY-MM-DD')",
default => "DATE_FORMAT(visited_at, '%Y-%m-%d')",
};
$dailyClicks = ShortUrlVisit::select(DB::raw("{$dateExpression} as date"), DB::raw('COUNT(*) as count'))
->where('visited_at', '>=', now()->subDays(6)->startOfDay())
->groupBy('date')
->pluck('count', 'date')
->toArray();
$dailyUniques = ShortUrlVisit::select(DB::raw("{$dateExpression} as date"), DB::raw('COUNT(DISTINCT ip_hash) as count'))
->where('visited_at', '>=', now()->subDays(6)->startOfDay())
->groupBy('date')
->pluck('count', 'date')
->toArray();
$clicksChart = [];
$uniquesChart = [];
for ($i = 6; $i >= 0; $i--) {
$targetDate = now()->subDays($i)->toDateString();
$clicksChart[] = (int) ($dailyClicks[$targetDate] ?? 0);
$uniquesChart[] = (int) ($dailyUniques[$targetDate] ?? 0);
}
return [
'totalVisits' => (int) ($agg->total_visits ?? 0),
'uniqueVisits' => (int) ($agg->unique_visits ?? 0),
@@ -119,6 +149,8 @@ class ShortUrlGlobalOverview extends BaseWidget
'prev7Clicks' => $prev7Clicks,
'last7Uniques' => $last7Uniques,
'prev7Uniques' => $prev7Uniques,
'clicksChart' => $clicksChart,
'uniquesChart' => $uniquesChart,
];
});
}

View File

@@ -0,0 +1,105 @@
<?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 ShortUrlSecurityBreakdownWidget extends Widget
{
use HasStatsFilters;
public ?ShortUrl $record = null;
public ?string $dateFrom = null;
public ?string $dateTo = null;
public string $activeTab = 'bots';
public array $loadedTabs = ['bots' => true];
protected string $view = 'filament-short-url::widgets.security-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,
'totalClicks' => 0,
'humanClicks' => 0,
'botClicks' => 0,
'humanPercentage' => 0,
'botPercentage' => 0,
'proxyClicks' => 0,
'proxyPercentage' => 0,
];
}
$totalClicks = 0;
$humanClicks = 0;
$botClicks = 0;
$humanPercentage = 0;
$botPercentage = 0;
$proxyClicks = 0;
$proxyPercentage = 0;
// Perform total count query only if we load bots or vpn
if (! empty($this->loadedTabs)) {
$totalQuery = $this->record->visits()
->whereBetween('visited_at', [
($this->dateFrom ? $this->dateFrom.' 00:00:00' : '1970-01-01 00:00:00'),
($this->dateTo ? $this->dateTo.' 23:59:59' : now()->toDateTimeString()),
]);
$this->record->applyStatsFilters($totalQuery, $this->filters);
$totalClicks = $totalQuery->count();
}
// Calculate Bot ratio
if (isset($this->loadedTabs['bots'])) {
$botQuery = $this->record->visits()
->where('is_bot', true)
->whereBetween('visited_at', [
($this->dateFrom ? $this->dateFrom.' 00:00:00' : '1970-01-01 00:00:00'),
($this->dateTo ? $this->dateTo.' 23:59:59' : now()->toDateTimeString()),
]);
$this->record->applyStatsFilters($botQuery, $this->filters);
$botClicks = $botQuery->count();
$humanClicks = max(0, $totalClicks - $botClicks);
$humanPercentage = $totalClicks > 0 ? round(($humanClicks / $totalClicks) * 100, 1) : 0;
$botPercentage = $totalClicks > 0 ? round(($botClicks / $totalClicks) * 100, 1) : 0;
}
// Calculate VPN / Proxy ratio
if (isset($this->loadedTabs['vpn'])) {
$proxyQuery = $this->record->visits()
->where('is_proxy', true)
->whereBetween('visited_at', [
($this->dateFrom ? $this->dateFrom.' 00:00:00' : '1970-01-01 00:00:00'),
($this->dateTo ? $this->dateTo.' 23:59:59' : now()->toDateTimeString()),
]);
$this->record->applyStatsFilters($proxyQuery, $this->filters);
$proxyClicks = $proxyQuery->count();
$proxyPercentage = $totalClicks > 0 ? round(($proxyClicks / $totalClicks) * 100, 1) : 0;
}
return [
'activeTab' => $this->activeTab,
'totalClicks' => $totalClicks,
'humanClicks' => $humanClicks,
'botClicks' => $botClicks,
'humanPercentage' => $humanPercentage,
'botPercentage' => $botPercentage,
'proxyClicks' => $proxyClicks,
'proxyPercentage' => $proxyPercentage,
];
}
}

View File

@@ -0,0 +1,73 @@
<?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 ShortUrlSourcesBreakdownWidget extends Widget
{
use HasStatsFilters;
public ?ShortUrl $record = null;
public ?string $dateFrom = null;
public ?string $dateTo = null;
public string $activeTab = 'referrers';
public string $activeSubTab = 'sources';
public array $loadedTabs = ['referrers' => true];
protected string $view = 'filament-short-url::widgets.sources-breakdown';
public function setActiveTab(string $tab): void
{
$this->activeTab = $tab;
if ($tab === 'referrers') {
$this->loadedTabs['referrers'] = true;
} elseif ($tab === 'utm') {
$this->loadedTabs['utm_'.$this->activeSubTab] = true;
}
}
public function setActiveSubTab(string $subTab): void
{
$this->activeSubTab = $subTab;
$this->loadedTabs['utm_'.$subTab] = true;
}
protected function getViewData(): array
{
if (! $this->record) {
return [
'activeTab' => $this->activeTab,
'activeSubTab' => $this->activeSubTab,
'visitsByReferer' => [],
'utmSources' => [],
'utmMediums' => [],
'utmCampaigns' => [],
'utmTerms' => [],
'utmContents' => [],
'totalVisits' => 0,
];
}
$stats = $this->record->getCachedStats($this->dateFrom, $this->dateTo, $this->filters);
return [
'activeTab' => $this->activeTab,
'activeSubTab' => $this->activeSubTab,
'visitsByReferer' => isset($this->loadedTabs['referrers']) ? ($stats['visitsByReferer'] ?? []) : [],
'utmSources' => isset($this->loadedTabs['utm_sources']) ? ($stats['utmSources'] ?? []) : [],
'utmMediums' => isset($this->loadedTabs['utm_mediums']) ? ($stats['utmMediums'] ?? []) : [],
'utmCampaigns' => isset($this->loadedTabs['utm_campaigns']) ? ($stats['utmCampaigns'] ?? []) : [],
'utmTerms' => isset($this->loadedTabs['utm_terms']) ? ($stats['utmTerms'] ?? []) : [],
'utmContents' => isset($this->loadedTabs['utm_contents']) ? ($stats['utmContents'] ?? []) : [],
'totalVisits' => $stats['totalVisits'] ?? 0,
];
}
}

View File

@@ -2,12 +2,16 @@
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\Concerns\HasStatsFilters;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
use Illuminate\Support\Carbon;
class ShortUrlStatsOverview extends BaseWidget
{
use HasStatsFilters;
public ?ShortUrl $record = null;
public ?string $dateFrom = null;
@@ -20,7 +24,25 @@ class ShortUrlStatsOverview extends BaseWidget
return [];
}
$stats = $this->record->getCachedStats($this->dateFrom, $this->dateTo);
$stats = $this->record->getCachedStats($this->dateFrom, $this->dateTo, $this->filters);
$totalVisits = (int) ($stats['totalVisits'] ?? 0);
// Calculate previous period for PoP comparison
$prevStats = null;
if ($this->dateFrom && $this->dateTo) {
try {
$from = Carbon::parse($this->dateFrom);
$to = Carbon::parse($this->dateTo);
$diffInDays = $from->diffInDays($to) + 1;
$prevFrom = $from->copy()->subDays($diffInDays)->toDateString();
$prevTo = $from->copy()->subDay()->toDateString();
$prevStats = $this->record->getCachedStats($prevFrom, $prevTo, $this->filters);
} catch (\Throwable $e) {
// Fail gracefully if date parsing fails
}
}
$topSource = 'Direct';
if (! empty($stats['utmSources'])) {
@@ -32,18 +54,34 @@ class ShortUrlStatsOverview extends BaseWidget
$topCountry = array_key_first($stats['visitsByCountry']);
}
$trend = ! empty($stats['visitsByDay']) ? array_values($stats['visitsByDay']) : [];
return [
Stat::make(__('filament-short-url::default.stats_card_total'), number_format($stats['totalVisits'] ?? 0))
->icon('heroicon-o-cursor-arrow-rays')
->color('primary'),
$this->buildStatWithTrend(
__('filament-short-url::default.stats_card_total'),
(int) ($stats['totalVisits'] ?? 0),
$prevStats ? (int) ($prevStats['totalVisits'] ?? 0) : null,
'heroicon-o-cursor-arrow-rays',
'primary',
$trend
),
Stat::make(__('filament-short-url::default.stats_card_unique'), number_format($stats['uniqueVisits'] ?? 0))
->icon('heroicon-o-user-group')
->color('info'),
$this->buildStatWithTrend(
__('filament-short-url::default.stats_card_unique'),
(int) ($stats['uniqueVisits'] ?? 0),
$prevStats ? (int) ($prevStats['uniqueVisits'] ?? 0) : null,
'heroicon-o-user-group',
'info',
$trend
),
Stat::make(__('filament-short-url::default.stats_card_today_clicks'), number_format($stats['visitsToday'] ?? 0))
->icon('heroicon-o-clock')
->color('success'),
$this->buildStatWithTrend(
__('filament-short-url::default.stats_card_today_clicks'),
(int) ($stats['visitsToday'] ?? 0),
$prevStats ? (int) ($prevStats['visitsToday'] ?? 0) : null,
'heroicon-o-clock',
'success'
),
Stat::make(__('filament-short-url::default.stats_card_top_source'), $topSource)
->icon('heroicon-o-megaphone')
@@ -53,9 +91,92 @@ class ShortUrlStatsOverview extends BaseWidget
->icon('heroicon-o-globe-alt')
->color('success'),
Stat::make(__('filament-short-url::default.stats_card_qr_scans'), number_format($stats['qrScans'] ?? 0))
->icon('heroicon-o-qr-code')
->color('info'),
$this->buildStatWithTrend(
__('filament-short-url::default.stats_card_qr_scans'),
(int) ($stats['qrScans'] ?? 0),
$prevStats ? (int) ($prevStats['qrScans'] ?? 0) : null,
'heroicon-o-qr-code',
'info'
),
Stat::make(__('filament-short-url::default.stats_card_qr_rate'), number_format($totalVisits > 0 ? (($stats['qrScans'] ?? 0) / $totalVisits) * 100 : 0, 1).'%')
->icon('heroicon-o-arrow-trending-up')
->color('indigo')
->description((function () use ($stats, $totalVisits, $prevStats) {
$currentRate = $totalVisits > 0 ? (($stats['qrScans'] ?? 0) / $totalVisits) * 100 : 0;
$prevRate = null;
if ($prevStats) {
$prevTotal = (int) ($prevStats['totalVisits'] ?? 0);
$prevQr = (int) ($prevStats['qrScans'] ?? 0);
$prevRate = $prevTotal > 0 ? ($prevQr / $prevTotal) * 100 : 0;
}
if ($prevRate !== null) {
$delta = $currentRate - $prevRate;
return ($delta >= 0 ? '+' : '').number_format($delta, 1).'% vs '.__('filament-short-url::default.stats_prev_period');
}
return '— vs '.__('filament-short-url::default.stats_prev_period');
})())
->descriptionIcon((function () use ($stats, $totalVisits, $prevStats) {
$currentRate = $totalVisits > 0 ? (($stats['qrScans'] ?? 0) / $totalVisits) * 100 : 0;
$prevRate = null;
if ($prevStats) {
$prevTotal = (int) ($prevStats['totalVisits'] ?? 0);
$prevQr = (int) ($prevStats['qrScans'] ?? 0);
$prevRate = $prevTotal > 0 ? ($prevQr / $prevTotal) * 100 : 0;
}
if ($prevRate !== null) {
return ($currentRate - $prevRate) >= 0 ? 'heroicon-m-arrow-trending-up' : 'heroicon-m-arrow-trending-down';
}
return null;
})())
->descriptionColor((function () use ($stats, $totalVisits, $prevStats) {
$currentRate = $totalVisits > 0 ? (($stats['qrScans'] ?? 0) / $totalVisits) * 100 : 0;
$prevRate = null;
if ($prevStats) {
$prevTotal = (int) ($prevStats['totalVisits'] ?? 0);
$prevQr = (int) ($prevStats['qrScans'] ?? 0);
$prevRate = $prevTotal > 0 ? ($prevQr / $prevTotal) * 100 : 0;
}
if ($prevRate !== null) {
return ($currentRate - $prevRate) >= 0 ? 'success' : 'danger';
}
return 'gray';
})()),
];
}
/**
* Build a stat widget with a calculated percentage trend delta and sparkline chart.
*/
protected function buildStatWithTrend(string $label, int $current, ?int $previous, string $icon, string $color, array $trend = []): Stat
{
$stat = Stat::make($label, number_format($current))
->icon($icon)
->color($color);
if (! empty($trend)) {
$stat->chart($trend);
}
if ($previous !== null && $previous > 0) {
$delta = (($current - $previous) / $previous) * 100;
$deltaStr = ($delta >= 0 ? '+' : '').number_format($delta, 1).'%';
$stat->description($deltaStr.' vs '.__('filament-short-url::default.stats_prev_period'))
->descriptionIcon($delta >= 0 ? 'heroicon-m-arrow-trending-up' : 'heroicon-m-arrow-trending-down')
->descriptionColor($delta >= 0 ? 'success' : 'danger');
} elseif ($previous === 0 && $current > 0) {
$stat->description('+100.0% vs '.__('filament-short-url::default.stats_prev_period'))
->descriptionIcon('heroicon-m-arrow-trending-up')
->descriptionColor('success');
} else {
$stat->description('— vs '.__('filament-short-url::default.stats_prev_period'))
->descriptionColor('gray');
}
return $stat;
}
}

View File

@@ -0,0 +1,53 @@
<?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;
use Illuminate\Contracts\View\View;
class ShortUrlVariantsWidget extends Widget
{
use HasStatsFilters;
public ?ShortUrl $record = null;
public ?string $dateFrom = null;
public ?string $dateTo = null;
protected string $view = 'filament-short-url::widgets.variants';
protected function getViewData(): array
{
if (! $this->record) {
return [
'visitsByVariant' => [],
'totalVisits' => 0,
];
}
$stats = $this->record->getCachedStats($this->dateFrom, $this->dateTo, $this->filters);
return [
'visitsByVariant' => $stats['visitsByVariant'] ?? [],
'totalVisits' => $stats['totalVisits'] ?? 0,
'rotationVariants' => $this->record->rotation_variants ?? [],
];
}
public function render(): View
{
if (! $this->record) {
return view('filament-short-url::widgets.empty');
}
$stats = $this->record->getCachedStats($this->dateFrom, $this->dateTo, $this->filters);
if (empty($stats['visitsByVariant'])) {
return view('filament-short-url::widgets.empty');
}
return parent::render();
}
}

View File

@@ -1,58 +0,0 @@
<?php
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Filament\Widgets\Widget;
class ShortUrlVisitsBottomBreakdown extends Widget
{
public ?ShortUrl $record = null;
public ?string $dateFrom = null;
public ?string $dateTo = null;
protected string $view = 'filament-short-url::widgets.visits-bottom-breakdown';
protected int|string|array $columnSpan = 'full';
public function mount(?ShortUrl $record = null): void
{
$this->record = $record;
}
/**
* @return array<string, mixed>
*/
protected function getViewData(): array
{
if (! $this->record) {
return [
'visitsByDevice' => [],
'visitsByBrowser' => [],
'visitsByOs' => [],
'visitsByReferer' => [],
'visitsByCity' => [],
'utmSources' => [],
'utmMediums' => [],
'utmCampaigns' => [],
'totalVisits' => 0,
];
}
$stats = $this->record->getCachedStats($this->dateFrom, $this->dateTo);
return [
'visitsByDevice' => $stats['visitsByDevice'] ?? [],
'visitsByBrowser' => $stats['visitsByBrowser'] ?? [],
'visitsByOs' => $stats['visitsByOs'] ?? [],
'visitsByReferer' => $stats['visitsByReferer'] ?? [],
'visitsByCity' => $stats['visitsByCity'] ?? [],
'utmSources' => $stats['utmSources'] ?? [],
'utmMediums' => $stats['utmMediums'] ?? [],
'utmCampaigns' => $stats['utmCampaigns'] ?? [],
'totalVisits' => $stats['totalVisits'] ?? 0,
];
}
}

View File

@@ -2,12 +2,16 @@
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\Concerns\HasStatsFilters;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Filament\Widgets\ChartWidget;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class ShortUrlVisitsChart extends ChartWidget
{
use HasStatsFilters;
public ?ShortUrl $record = null;
public ?string $dateFrom = null;
@@ -18,11 +22,227 @@ class ShortUrlVisitsChart extends ChartWidget
protected int|string|array $columnSpan = 'full';
public ?string $filter = null;
public string $activeMetric = 'total';
protected string $view = 'filament-short-url::widgets.visits-chart';
public function mount(): void
{
$this->setDefaultFilter();
}
protected function setDefaultFilter(): void
{
$start = $this->dateFrom ? Carbon::parse($this->dateFrom) : now()->subDays(29);
$end = $this->dateTo ? Carbon::parse($this->dateTo) : now();
$daysDiff = (int) $start->diffInDays($end);
if ($daysDiff <= 1) {
$this->filter = 'hourly';
} elseif ($daysDiff <= 30) {
$this->filter = 'daily';
} elseif ($daysDiff <= 90) {
$this->filter = 'weekly';
} else {
$this->filter = 'monthly';
}
}
public function getHeading(): string
{
return __('filament-short-url::default.stats_chart_title');
}
protected function getFilters(): ?array
{
return [
'hourly' => __('filament-short-url::default.stats_granularity_hourly'),
'daily' => __('filament-short-url::default.stats_granularity_daily'),
'weekly' => __('filament-short-url::default.stats_granularity_weekly'),
'monthly' => __('filament-short-url::default.stats_granularity_monthly'),
];
}
public function getTimelineData(Carbon $start, Carbon $end, string $metric, string $granularity, array $filters): array
{
$dateFromClean = $start->toDateString();
$dateToClean = $end->toDateString();
$today = Carbon::today()->toDateString();
$driver = DB::connection()->getDriverName();
$buckets = [];
$labels = [];
if ($granularity === 'hourly') {
$hoursDiff = (int) $start->diffInHours($end);
if ($hoursDiff > 168) {
$granularity = 'daily';
} else {
$current = $start->copy()->startOfHour();
$endHour = $end->copy()->endOfHour();
while ($current->lte($endHour)) {
$bucketKey = $current->format('Y-m-d H:00');
$buckets[$bucketKey] = 0;
$labels[$bucketKey] = $current->format('d.m H:i');
$current->addHour();
}
}
}
if ($granularity === 'weekly') {
$current = $start->copy()->startOfWeek();
while ($current->lte($end)) {
$bucketKey = $current->format('o-W');
$buckets[$bucketKey] = 0;
$labels[$bucketKey] = 'W'.$current->format('W').' ('.$current->format('d.m').')';
$current->addWeek();
}
} elseif ($granularity === 'monthly') {
$current = $start->copy()->startOfMonth();
while ($current->lte($end)) {
$bucketKey = $current->format('Y-m');
$buckets[$bucketKey] = 0;
$labels[$bucketKey] = $current->format('m.Y');
$current->addMonth();
}
} elseif ($granularity === 'daily') {
$daysDiff = (int) $start->diffInDays($end);
for ($i = $daysDiff; $i >= 0; $i--) {
$d = $end->copy()->subDays($i)->format('Y-m-d');
$buckets[$d] = 0;
$labels[$d] = Carbon::parse($d)->format('d.m');
}
}
if (empty($filters) && $granularity !== 'hourly') {
$dailyStatsQuery = DB::table('short_url_daily_stats')
->where('short_url_id', $this->record->id)
->where('date', '>=', $dateFromClean)
->where('date', '<=', $dateToClean)
->orderBy('date', 'asc');
$col = match ($metric) {
'unique' => 'unique_visits_count',
'qr' => 'qr_visits_count',
default => 'visits_count',
};
$dailyRows = $dailyStatsQuery->pluck($col, 'date')->toArray();
foreach ($dailyRows as $date => $count) {
$carbonDate = Carbon::parse($date);
if ($granularity === 'weekly') {
$bucketKey = $carbonDate->format('o-W');
} elseif ($granularity === 'monthly') {
$bucketKey = $carbonDate->format('Y-m');
} else {
$bucketKey = $date;
}
if (array_key_exists($bucketKey, $buckets)) {
$buckets[$bucketKey] += (int) $count;
}
}
if ($dateToClean >= $today) {
$todayRawQuery = DB::table('short_url_visits')
->where('short_url_id', $this->record->id)
->where('visited_at', '>=', $today.' 00:00:00')
->where('is_bot', false)
->where('is_proxy', false);
$aggregateExpression = match ($metric) {
'unique' => 'COUNT(DISTINCT ip_hash)',
'qr' => 'SUM(CASE WHEN is_qr_scan = 1 THEN 1 ELSE 0 END)',
default => 'COUNT(*)',
};
$todayCount = (int) $todayRawQuery->selectRaw("{$aggregateExpression} as cnt")->value('cnt');
$carbonToday = Carbon::parse($today);
if ($granularity === 'weekly') {
$todayBucket = $carbonToday->format('o-W');
} elseif ($granularity === 'monthly') {
$todayBucket = $carbonToday->format('Y-m');
} else {
$todayBucket = $today;
}
if (array_key_exists($todayBucket, $buckets)) {
$buckets[$todayBucket] += $todayCount;
}
}
} else {
$query = DB::table('short_url_visits')
->where('short_url_id', $this->record->id)
->where('visited_at', '>=', $dateFromClean.' 00:00:00')
->where('visited_at', '<=', $dateToClean.' 23:59:59')
->where('is_bot', false)
->where('is_proxy', false);
$this->record->applyStatsFilters($query, $filters);
$dateExpression = '';
if ($granularity === 'hourly') {
$dateExpression = match ($driver) {
'sqlite' => "strftime('%Y-%m-%d %H:00', visited_at)",
'pgsql' => "to_char(visited_at, 'YYYY-MM-DD HH24:00')",
default => "DATE_FORMAT(visited_at, '%Y-%m-%d %H:00')",
};
} elseif ($granularity === 'weekly') {
$dateExpression = match ($driver) {
'sqlite' => "strftime('%Y-%W', visited_at)",
'pgsql' => "to_char(visited_at, 'IYYY-IW')",
default => 'YEARWEEK(visited_at, 3)',
};
} elseif ($granularity === 'monthly') {
$dateExpression = match ($driver) {
'sqlite' => "strftime('%Y-%m', visited_at)",
'pgsql' => "to_char(visited_at, 'YYYY-MM')",
default => "DATE_FORMAT(visited_at, '%Y-%m')",
};
} else {
$dateExpression = match ($driver) {
'sqlite' => "strftime('%Y-%m-%d', visited_at)",
'pgsql' => "to_char(visited_at, 'YYYY-MM-DD')",
default => "DATE_FORMAT(visited_at, '%Y-%m-%d')",
};
}
$aggregateExpression = match ($metric) {
'unique' => 'COUNT(DISTINCT ip_hash)',
'qr' => 'SUM(CASE WHEN is_qr_scan = 1 THEN 1 ELSE 0 END)',
default => 'COUNT(*)',
};
$rows = $query->select(DB::raw("{$dateExpression} as time_bucket"), DB::raw("{$aggregateExpression} as count"))
->groupBy('time_bucket')
->pluck('count', 'time_bucket')
->toArray();
foreach ($rows as $bucket => $count) {
if ($granularity === 'weekly' && $driver !== 'sqlite' && $driver !== 'pgsql') {
if (strlen($bucket) === 6) {
$year = substr($bucket, 0, 4);
$week = substr($bucket, 4, 2);
$bucket = "{$year}-{$week}";
}
}
if (array_key_exists($bucket, $buckets)) {
$buckets[$bucket] = (int) $count;
}
}
}
return [
'data' => array_values($buckets),
'labels' => array_values($labels),
];
}
protected function getData(): array
{
if (! $this->record) {
@@ -32,14 +252,56 @@ class ShortUrlVisitsChart extends ChartWidget
];
}
$stats = $this->record->getCachedStats($this->dateFrom, $this->dateTo);
$visitsByDay = $stats['visitsByDay'] ?? [];
$start = $this->dateFrom ? Carbon::parse($this->dateFrom)->startOfDay() : now()->subDays(29)->startOfDay();
$end = $this->dateTo ? Carbon::parse($this->dateTo)->endOfDay() : now()->endOfDay();
$granularity = $this->filter ?: 'daily';
$filtersHash = md5(json_encode($this->filters));
$cacheTtl = (int) config('filament-short-url.geo_ip.stats_cache_ttl', 300);
$cacheKey = "short_url_chart_data_{$this->record->id}_".$start->toDateString().'_'.$end->toDateString().'_'.$granularity.'_'.$filtersHash.'_'.$this->activeMetric;
$this->record->registerCacheKey($cacheKey);
$chartData = cache()->remember($cacheKey, $cacheTtl, function () use ($start, $end, $granularity) {
return $this->getTimelineData($start, $end, $this->activeMetric, $granularity, $this->filters);
});
$currentValues = $chartData['data'] ?? [];
$labels = $chartData['labels'] ?? [];
// Dual-line Chart data generation: Query stats for previous period
$diffInDays = $start->diffInDays($end) + 1;
$prevStart = $start->copy()->subDays($diffInDays);
$prevEnd = $start->copy()->subDay();
$prevCacheKey = "short_url_chart_data_{$this->record->id}_".$prevStart->toDateString().'_'.$prevEnd->toDateString().'_'.$granularity.'_'.$filtersHash.'_'.$this->activeMetric;
$this->record->registerCacheKey($prevCacheKey);
$prevChartData = cache()->remember($prevCacheKey, $cacheTtl, function () use ($prevStart, $prevEnd, $granularity) {
return $this->getTimelineData($prevStart, $prevEnd, $this->activeMetric, $granularity, $this->filters);
});
$prevValues = $prevChartData['data'] ?? [];
// Align/pad previous data structure to match current labels length exactly
$countCurrent = count($currentValues);
$countPrev = count($prevValues);
if ($countPrev < $countCurrent) {
$prevValues = array_pad($prevValues, $countCurrent, 0);
} elseif ($countPrev > $countCurrent) {
$prevValues = array_slice($prevValues, 0, $countCurrent);
}
$metricLabel = match ($this->activeMetric) {
'unique' => __('filament-short-url::default.stats_card_unique'),
'qr' => __('filament-short-url::default.stats_card_qr_scans'),
default => __('filament-short-url::default.qr_chart_visits_label'),
};
return [
'datasets' => [
[
'label' => __('filament-short-url::default.qr_chart_visits_label'),
'data' => array_values($visitsByDay),
'label' => $metricLabel,
'data' => $currentValues,
'borderColor' => 'rgb(99, 102, 241)',
'backgroundColor' => 'rgba(99, 102, 241, 0.08)',
'borderWidth' => 2,
@@ -48,19 +310,19 @@ class ShortUrlVisitsChart extends ChartWidget
'tension' => 0.4,
'fill' => true,
],
[
'label' => $metricLabel.' ('.__('filament-short-url::default.stats_prev_period').')',
'data' => $prevValues,
'borderColor' => 'rgba(99, 102, 241, 0.35)',
'backgroundColor' => 'transparent',
'borderWidth' => 2,
'borderDash' => [5, 5],
'pointRadius' => 0,
'tension' => 0.4,
'fill' => false,
],
],
'labels' => array_map(function (string $date) {
try {
$carbon = Carbon::parse($date);
if (strlen($date) === 7) { // Y-m
return $carbon->format('m.Y');
}
return $carbon->format('d.m');
} catch (\Throwable $e) {
return $date;
}
}, array_keys($visitsByDay)),
'labels' => $labels,
];
}

View File

@@ -1,110 +0,0 @@
<?php
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Filament\Widgets\Widget;
class ShortUrlVisitsRightBreakdown extends Widget
{
public ?ShortUrl $record = null;
public ?string $dateFrom = null;
public ?string $dateTo = null;
protected string $view = 'filament-short-url::widgets.visits-right-breakdown';
protected int|string|array $columnSpan = 'full';
public function mount(?ShortUrl $record = null): void
{
$this->record = $record;
}
/**
* @return array<string, mixed>
*/
protected function getViewData(): array
{
$languageNames = __('filament-short-url::languages');
if (! is_array($languageNames)) {
$languageNames = [];
}
if (! $this->record) {
return [
'visitsByCountry' => [],
'visitsByLanguage' => [],
'languageNames' => $languageNames,
'totalVisits' => 0,
];
}
$stats = $this->record->getCachedStats($this->dateFrom, $this->dateTo);
return [
'visitsByCountry' => $stats['visitsByCountry'] ?? [],
'visitsByLanguage' => $stats['visitsByLanguage'] ?? [],
'languageNames' => $languageNames,
'totalVisits' => $stats['totalVisits'] ?? 0,
];
}
/**
* Dynamically translate an ISO language code to the current application locale.
*/
public static function getLanguageTranslation(string $langCode): string
{
$langCode = strtolower(trim($langCode));
// 1. Try loading from languages translation file (languages.php)
$translatedLanguages = __('filament-short-url::languages');
if (is_array($translatedLanguages) && isset($translatedLanguages[$langCode])) {
return $translatedLanguages[$langCode];
}
// 2. Try PHP Locale extension
if (class_exists(\Locale::class)) {
try {
$name = \Locale::getDisplayLanguage($langCode, app()->getLocale());
if ($name && $name !== $langCode) {
return mb_convert_case($name, MB_CASE_TITLE, 'UTF-8');
}
} catch (\Throwable) {
// Fallback
}
}
// 3. Simple fallback
return strtoupper($langCode);
}
/**
* Dynamically translate an English country name to the current application locale.
*/
public static function getCountryTranslation(string $englishName): string
{
$englishName = trim($englishName);
try {
$enCountries = trans('filament-short-url::countries', [], 'en');
if (is_array($enCountries)) {
$flipped = array_change_key_case(array_flip($enCountries), CASE_LOWER);
$lookupKey = strtolower($englishName);
$code = $flipped[$lookupKey] ?? null;
if ($code) {
$translated = __('filament-short-url::countries.'.strtoupper($code));
if ($translated && $translated !== 'filament-short-url::countries.'.strtoupper($code)) {
return $translated;
}
}
}
} catch (\Throwable) {
// Fallback
}
return $englishName;
}
}

View File

@@ -2,6 +2,7 @@
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\Concerns\HasStatsFilters;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit;
use Filament\Widgets\Widget;
@@ -10,6 +11,8 @@ use Illuminate\Support\Facades\DB;
class ShortUrlWorldMapWidget extends Widget
{
use HasStatsFilters;
public ?ShortUrl $record = null;
public ?string $dateFrom = null;
@@ -40,8 +43,9 @@ class ShortUrlWorldMapWidget extends Widget
$dateToClean = $this->dateTo ? Carbon::parse($this->dateTo)->toDateString() : null;
$today = Carbon::today()->toDateString();
$filtersHash = md5(json_encode($this->filters));
$cacheTtl = (int) config('filament-short-url.geo_ip.stats_cache_ttl', 300);
$cacheKey = "short_url_world_map_{$this->record->id}_".($dateFromClean ?: 'all').'_'.($dateToClean ?: 'all');
$cacheKey = "short_url_world_map_{$this->record->id}_".($dateFromClean ?: 'all').'_'.($dateToClean ?: 'all').'_'.$filtersHash;
return cache()->remember($cacheKey, $cacheTtl, function () use ($dateFromClean, $dateToClean) {
$counts = [];
@@ -61,6 +65,8 @@ class ShortUrlWorldMapWidget extends Widget
$query->whereDate('visited_at', '<=', $dateToClean);
}
$this->record->applyStatsFilters($query, $this->filters);
foreach ($query->groupBy('country_code')->get() as $row) {
$code = strtoupper(trim($row->country_code));
if ($code) {

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