Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
043920836d | ||
|
|
9989fb938b | ||
|
|
73b8b875a8 | ||
|
|
9493431017 | ||
|
|
822a3e6fa3 | ||
|
|
5b2eb35a0b | ||
|
|
964a7364ef | ||
|
|
ba601dcf1e | ||
|
|
e4ef194420 | ||
|
|
53a3c1138d | ||
|
|
4cef5de366 | ||
|
|
e0602e80ef | ||
|
|
3592828ab3 | ||
|
|
7082db495d | ||
|
|
f73803017f | ||
|
|
2107a3e6c8 | ||
|
|
aa05f026b5 | ||
|
|
15d992c5ad | ||
|
|
719901d4c6 | ||
|
|
3b9411808e | ||
|
|
3a9575f5b1 | ||
|
|
325a38bfe8 | ||
|
|
1cd85af0a1 |
2
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# GitHub Sponsors
|
||||
github: [janczakb]
|
||||
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
71
LICENSE
@@ -1,21 +1,58 @@
|
||||
MIT License
|
||||
Custom Source-Available & Dual-Commercial License
|
||||
|
||||
Copyright (c) 2026 Bartek Janczak
|
||||
Copyright (c) 2026 Bartek Janczak. All rights reserved.
|
||||
|
||||
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:
|
||||
IMPORTANT: PLEASE READ THIS LICENSE AGREEMENT CAREFULLY. BY DOWNLOADING, INSTALLING, OR USING THE SOFTWARE, YOU AGREE TO BE BOUND BY ALL THE TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT DOWNLOAD, INSTALL, OR USE THE SOFTWARE.
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
-------------------------------------------------------------------------------
|
||||
1. DEFINITIONS
|
||||
-------------------------------------------------------------------------------
|
||||
"Software" means the "Short URL Manager" PHP/Filament package, including all source code, modules, documentation, migrations, updates, and modified versions thereof.
|
||||
"Copyright Holder" means Bartek Janczak.
|
||||
"Commercial Distribution" means selling, renting, leasing, sublicensing, or otherwise distributing the Software (in whole or in part, or embedded into a larger work, boilerplate, theme, SaaS platform, or application) to third parties for monetary gain, fees, or any commercial consideration.
|
||||
"Internal Commercial Use" means deploying and using the Software within your own business operations or building a custom website/system for a single client, where the client does not resell the underlying code.
|
||||
|
||||
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. GRANT OF FREE LICENSE (NON-DISTRIBUTION ONLY)
|
||||
-------------------------------------------------------------------------------
|
||||
Subject to compliance with Section 3 (Restrictions), the Copyright Holder hereby grants you a worldwide, royalty-free, non-exclusive, non-sublicensable license to:
|
||||
a) Download, install, and use the Software free of charge for personal, educational, internal business purposes, or Internal Commercial Use across an unlimited number of projects.
|
||||
b) Modify the Software solely for your own private, internal, or Internal Commercial Use, provided that any such modifications are kept private and are never distributed publicly.
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
3. STRICT RESTRICTIONS & PROHIBITIONS
|
||||
-------------------------------------------------------------------------------
|
||||
Unless you have obtained an explicit, written Commercial License from the Copyright Holder pursuant to Section 4, you are STRICTLY PROHIBITED from:
|
||||
a) Public Redistribution: Publishing, hosting, or sharing 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 public package registries (such as Packagist).
|
||||
b) Commercial Exploitation & Bundling: Engaging in any Commercial Distribution. You may not bundle, embed, or include the Software, in whole or in part, or any modified versions thereof, into any product, theme, extension, boilerplate, framework, or software application that is offered for sale, license, subscription, or commercial distribution to third parties.
|
||||
c) Relabeling: Publishing, distributing, or sharing the Software under a different author, vendor, or package name.
|
||||
d) Sublicensing: Granting any third party the right to resell, redistribute, or further sublicense the Software.
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
4. DUAL-LICENSING & COMMERCIAL LICENSING REQUIREMENT
|
||||
-------------------------------------------------------------------------------
|
||||
Any individual, entity, or organization wishing to engage in Commercial Distribution, or use the Software in a manner not expressly permitted by Section 2, MUST secure a separate, written Commercial License from the Copyright Holder prior to such use or distribution.
|
||||
|
||||
To inquire about purchasing a Commercial License or to request written authorization, please contact the Copyright Holder via email at: barek122@gmail.com
|
||||
|
||||
Commercial Licenses may be subject to a licensing fee, revenue-sharing model, or other terms as agreed upon in writing by the Copyright Holder. Any Commercial Distribution attempted without a valid, active, written Commercial License from the Copyright Holder is null and void, constitutes a material breach of this Agreement, and is a direct infringement of international copyright laws.
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
5. INTELLECTUAL PROPERTY & COPYRIGHT NOTICE
|
||||
-------------------------------------------------------------------------------
|
||||
The Software is licensed, not sold. The Copyright Holder retains all right, title, and interest in and to the Software, including all copyrights, patents, trade secrets, and other intellectual property rights. The original copyright notice, author attribution, and this entire permission notice must be included in all copies, substantial portions, or private forks of the Software.
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
6. TERMINATION
|
||||
-------------------------------------------------------------------------------
|
||||
This License and your right to use the Software will terminate automatically and immediately, without notice from the Copyright Holder, if you fail to comply with any of its terms or restrictions. Upon termination, you must immediately cease all use of the Software and destroy all copies, full or partial, in your possession.
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
7. GOVERNING LAW AND JURISDICTION
|
||||
-------------------------------------------------------------------------------
|
||||
This License Agreement shall be governed by, and construed in accordance with, the substantive laws of Poland, without regard to its conflict of laws principles. Any legal action, suit, or proceeding arising out of or relating to this License shall be instituted exclusively in the competent courts located in Warsaw, Poland.
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
8. 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.
|
||||
|
||||
BIN
art/logo-fism.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
art/preview.webm
Normal file
BIN
art/v2_1.png
|
Before Width: | Height: | Size: 345 KiB |
BIN
art/v2_2.png
|
Before Width: | Height: | Size: 292 KiB |
BIN
art/v2_3.png
|
Before Width: | Height: | Size: 451 KiB |
BIN
art/v2_4.png
|
Before Width: | Height: | Size: 247 KiB |
BIN
art/v2_5.png
|
Before Width: | Height: | Size: 484 KiB |
BIN
art/v3_1.png
Normal file
|
After Width: | Height: | Size: 241 KiB |
BIN
art/v3_2.png
Normal file
|
After Width: | Height: | Size: 251 KiB |
BIN
art/v3_3.png
Normal file
|
After Width: | Height: | Size: 467 KiB |
BIN
art/v3_4.png
Normal file
|
After Width: | Height: | Size: 252 KiB |
BIN
art/v3_5.png
Normal file
|
After Width: | Height: | Size: 460 KiB |
@@ -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
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit;
|
||||
|
||||
@@ -42,6 +43,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 +89,7 @@ return [
|
||||
*/
|
||||
'tracking' => [
|
||||
'enabled' => true,
|
||||
'anonymize_ips' => env('SHORT_URL_ANONYMIZE_IPS', false),
|
||||
'fields' => [
|
||||
'ip_address' => true,
|
||||
'browser' => true,
|
||||
@@ -204,6 +224,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
|
||||
@@ -226,38 +255,21 @@ return [
|
||||
*/
|
||||
'deep_linking' => [
|
||||
'enabled' => env('SHORT_URL_DEEP_LINKING_ENABLED', false),
|
||||
'aasa_json' => env('SHORT_URL_AASA_JSON', <<<'JSON'
|
||||
{
|
||||
"applinks": {
|
||||
"apps": [],
|
||||
"details": [
|
||||
{
|
||||
"appID": "YOUR_TEAM_ID.com.yourcompany.app",
|
||||
"paths": [
|
||||
"/s/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
JSON),
|
||||
'assetlinks_json' => env('SHORT_URL_ASSETLINKS_JSON', <<<'JSON'
|
||||
[
|
||||
{
|
||||
"relation": [
|
||||
"delegate_permission/common.handle_all_urls"
|
||||
],
|
||||
"target": {
|
||||
"namespace": "android_app",
|
||||
"package_name": "com.yourcompany.app",
|
||||
"sha256_cert_fingerprints": [
|
||||
"14:6D:E9:..."
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
JSON),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 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' => User::class,
|
||||
'name_column' => 'name',
|
||||
'email_column' => 'email',
|
||||
'avatar_column' => 'avatar_url', // can be attribute/method on model or null to auto-detect HasAvatar
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('short_urls', function (Blueprint $table) {
|
||||
$table->string('pixel_meta_id', 100)->nullable();
|
||||
$table->string('pixel_google_id', 100)->nullable();
|
||||
$table->string('pixel_linkedin_id', 100)->nullable();
|
||||
$table->string('webhook_url', 2048)->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('short_urls', function (Blueprint $table) {
|
||||
$table->dropColumn([
|
||||
'pixel_meta_id',
|
||||
'pixel_google_id',
|
||||
'pixel_linkedin_id',
|
||||
'webhook_url',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,155 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// 1. Create short_url_pixels table
|
||||
Schema::create('short_url_pixels', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name', 150);
|
||||
$table->string('type', 50); // meta, google, linkedin, tiktok, pinterest
|
||||
$table->string('pixel_id', 100);
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['type', 'pixel_id']);
|
||||
});
|
||||
|
||||
// 2. Create short_url_pixel pivot table
|
||||
Schema::create('short_url_pixel', function (Blueprint $table) {
|
||||
$table->foreignId('short_url_id')->constrained('short_urls')->cascadeOnDelete();
|
||||
$table->foreignId('pixel_id')->constrained('short_url_pixels')->cascadeOnDelete();
|
||||
$table->primary(['short_url_id', 'pixel_id']);
|
||||
});
|
||||
|
||||
// 3. Data migration: Migrate existing pixel IDs from short_urls to short_url_pixels and pivot
|
||||
if (Schema::hasColumn('short_urls', 'pixel_meta_id')) {
|
||||
$oldUrls = DB::table('short_urls')
|
||||
->whereNotNull('pixel_meta_id')
|
||||
->orWhereNotNull('pixel_google_id')
|
||||
->orWhereNotNull('pixel_linkedin_id')
|
||||
->get();
|
||||
|
||||
foreach ($oldUrls as $url) {
|
||||
// Migrate Meta Pixel
|
||||
if (! empty($url->pixel_meta_id)) {
|
||||
$pixelId = DB::table('short_url_pixels')
|
||||
->where('type', 'meta')
|
||||
->where('pixel_id', $url->pixel_meta_id)
|
||||
->value('id');
|
||||
|
||||
if (! $pixelId) {
|
||||
$pixelId = DB::table('short_url_pixels')->insertGetId([
|
||||
'name' => 'Meta Pixel ('.$url->pixel_meta_id.')',
|
||||
'type' => 'meta',
|
||||
'pixel_id' => $url->pixel_meta_id,
|
||||
'is_active' => true,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
DB::table('short_url_pixel')->insertOrIgnore([
|
||||
'short_url_id' => $url->id,
|
||||
'pixel_id' => $pixelId,
|
||||
]);
|
||||
}
|
||||
|
||||
// Migrate Google Tag / GA4
|
||||
if (! empty($url->pixel_google_id)) {
|
||||
$pixelId = DB::table('short_url_pixels')
|
||||
->where('type', 'google')
|
||||
->where('pixel_id', $url->pixel_google_id)
|
||||
->value('id');
|
||||
|
||||
if (! $pixelId) {
|
||||
$pixelId = DB::table('short_url_pixels')->insertGetId([
|
||||
'name' => 'Google Tag ('.$url->pixel_google_id.')',
|
||||
'type' => 'google',
|
||||
'pixel_id' => $url->pixel_google_id,
|
||||
'is_active' => true,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
DB::table('short_url_pixel')->insertOrIgnore([
|
||||
'short_url_id' => $url->id,
|
||||
'pixel_id' => $pixelId,
|
||||
]);
|
||||
}
|
||||
|
||||
// Migrate LinkedIn Insight
|
||||
if (! empty($url->pixel_linkedin_id)) {
|
||||
$pixelId = DB::table('short_url_pixels')
|
||||
->where('type', 'linkedin')
|
||||
->where('pixel_id', $url->pixel_linkedin_id)
|
||||
->value('id');
|
||||
|
||||
if (! $pixelId) {
|
||||
$pixelId = DB::table('short_url_pixels')->insertGetId([
|
||||
'name' => 'LinkedIn Insight ('.$url->pixel_linkedin_id.')',
|
||||
'type' => 'linkedin',
|
||||
'pixel_id' => $url->pixel_linkedin_id,
|
||||
'is_active' => true,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
DB::table('short_url_pixel')->insertOrIgnore([
|
||||
'short_url_id' => $url->id,
|
||||
'pixel_id' => $pixelId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Drop the old columns
|
||||
Schema::table('short_urls', function (Blueprint $table) {
|
||||
$table->dropColumn(['pixel_meta_id', 'pixel_google_id', 'pixel_linkedin_id']);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
// 1. Re-add the columns
|
||||
Schema::table('short_urls', function (Blueprint $table) {
|
||||
$table->string('pixel_meta_id', 100)->nullable();
|
||||
$table->string('pixel_google_id', 100)->nullable();
|
||||
$table->string('pixel_linkedin_id', 100)->nullable();
|
||||
});
|
||||
|
||||
// 2. Re-populate the old columns from pivot data
|
||||
$associations = DB::table('short_url_pixel')
|
||||
->join('short_url_pixels', 'short_url_pixel.pixel_id', '=', 'short_url_pixels.id')
|
||||
->select('short_url_pixel.short_url_id', 'short_url_pixels.type', 'short_url_pixels.pixel_id')
|
||||
->get();
|
||||
|
||||
foreach ($associations as $assoc) {
|
||||
if ($assoc->type === 'meta') {
|
||||
DB::table('short_urls')->where('id', $assoc->short_url_id)->update(['pixel_meta_id' => $assoc->pixel_id]);
|
||||
} elseif ($assoc->type === 'google') {
|
||||
DB::table('short_urls')->where('id', $assoc->short_url_id)->update(['pixel_google_id' => $assoc->pixel_id]);
|
||||
} elseif ($assoc->type === 'linkedin') {
|
||||
DB::table('short_urls')->where('id', $assoc->short_url_id)->update(['pixel_linkedin_id' => $assoc->pixel_id]);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Drop tables
|
||||
Schema::dropIfExists('short_url_pixel');
|
||||
Schema::dropIfExists('short_url_pixels');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -6,17 +6,23 @@ use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('short_urls', function (Blueprint $table) {
|
||||
$table->boolean('auto_open_app_mobile')->default(false)->after('forward_query_params');
|
||||
$table->unsignedBigInteger('custom_domain_id')->nullable()->after('user_id')->index();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('short_urls', function (Blueprint $table) {
|
||||
$table->dropColumn('auto_open_app_mobile');
|
||||
$table->dropColumn('custom_domain_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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_folders', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('slug')->index();
|
||||
$table->string('color')->nullable();
|
||||
$table->unsignedBigInteger('user_id')->nullable()->index();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('short_url_tags', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('slug')->index();
|
||||
$table->string('color')->nullable();
|
||||
$table->unsignedBigInteger('user_id')->nullable()->index();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('short_url_tag', function (Blueprint $table) {
|
||||
$table->unsignedBigInteger('short_url_id')->index();
|
||||
$table->unsignedBigInteger('tag_id')->index();
|
||||
$table->primary(['short_url_id', 'tag_id']);
|
||||
});
|
||||
|
||||
Schema::table('short_urls', function (Blueprint $table) {
|
||||
$table->unsignedBigInteger('folder_id')->nullable()->after('custom_domain_id')->index();
|
||||
$table->boolean('is_archived')->default(false)->after('is_enabled')->index();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('short_urls', function (Blueprint $table) {
|
||||
$table->dropColumn(['folder_id', 'is_archived']);
|
||||
});
|
||||
|
||||
Schema::dropIfExists('short_url_tag');
|
||||
Schema::dropIfExists('short_url_tags');
|
||||
Schema::dropIfExists('short_url_folders');
|
||||
}
|
||||
};
|
||||
@@ -1,12 +1,23 @@
|
||||
@import "tailwindcss";
|
||||
@import "tailwindcss/theme";
|
||||
@import "tailwindcss/utilities";
|
||||
|
||||
@source "../../src/**/*.php";
|
||||
@source "../../resources/views/**/*.blade.php";
|
||||
@source "../views/**/*.blade.php";
|
||||
@source "packages/filament-short-url/src/**/*.php";
|
||||
@source "packages/filament-short-url/resources/views/**/*.blade.php";
|
||||
|
||||
.short-url-card {
|
||||
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 +26,100 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/* Folder Card overrides to reset default Filament record-content padding and force height/width stretch */
|
||||
.folder-card .fi-ta-record-content,
|
||||
.folder-card .fi-ta-record-content-ctn {
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
padding-top: 0 !important;
|
||||
padding-bottom: 0 !important;
|
||||
height: 100% !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.folder-card .fi-ta-record-content {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
/* Position action buttons absolute on the card wrapper to keep dropdown aligned */
|
||||
.folder-card .fi-ta-actions {
|
||||
position: absolute !important;
|
||||
top: 1rem !important;
|
||||
right: 1rem !important;
|
||||
z-index: 20 !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* Force height down the Filament table layout tree so justify-between works */
|
||||
.folder-card .fi-ta-stack,
|
||||
.folder-card .fi-ta-stack > div,
|
||||
.folder-card .fi-ta-text,
|
||||
.folder-card .fi-ta-text > div {
|
||||
height: 100% !important;
|
||||
}
|
||||
2
resources/dist/filament-short-url.css
vendored
@@ -1,9 +1,15 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'navigation_label' => 'Short Links',
|
||||
'navigation_group' => 'Tools',
|
||||
'navigation_label' => 'Links',
|
||||
'navigation_group' => 'URL Shortener',
|
||||
'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',
|
||||
@@ -21,6 +27,9 @@ return [
|
||||
'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',
|
||||
@@ -144,7 +153,10 @@ 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',
|
||||
'action_move' => 'Move',
|
||||
'share_title' => 'Share Link',
|
||||
'share_description' => 'Share this short link via:',
|
||||
'share_copy' => 'Copy',
|
||||
@@ -184,6 +196,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',
|
||||
@@ -210,6 +224,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.',
|
||||
@@ -231,7 +249,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',
|
||||
@@ -250,7 +268,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',
|
||||
@@ -269,6 +287,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',
|
||||
@@ -292,9 +319,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
|
||||
@@ -302,6 +331,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',
|
||||
@@ -322,6 +361,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',
|
||||
@@ -347,7 +411,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).',
|
||||
|
||||
@@ -364,6 +428,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',
|
||||
@@ -402,6 +469,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',
|
||||
@@ -425,6 +494,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',
|
||||
@@ -461,6 +532,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',
|
||||
@@ -511,4 +583,168 @@ return [
|
||||
'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',
|
||||
|
||||
// API Scopes & Rate Limits
|
||||
'api_key_scope' => 'API Scope',
|
||||
'api_key_scope_helper' => 'Choose the permission level for this API key.',
|
||||
'api_key_scope_read_write' => 'Read & Write (Full Access)',
|
||||
'api_key_scope_read_only' => 'Read Only',
|
||||
'api_key_rate_limit' => 'Rate Limit',
|
||||
'api_key_rate_limit_helper' => 'Define the maximum requests per minute allowed for this key.',
|
||||
'api_key_rate_limit_unlimited' => 'Unlimited',
|
||||
'api_key_rate_limit_rpm' => ':count requests / min',
|
||||
|
||||
// Folders
|
||||
'folders_navigation_label' => 'Folders',
|
||||
'folder_resource_title' => 'Folder',
|
||||
'folder_name' => 'Folder Name',
|
||||
'folder_slug' => 'Slug',
|
||||
'folder_color' => 'Color',
|
||||
'empty_state_folder_action' => 'Create Folder',
|
||||
'empty_state_folders_heading' => 'No folders yet',
|
||||
'empty_state_folders_description' => 'Organize your short links into folders.',
|
||||
'action_edit_folder' => 'Edit Folder',
|
||||
'action_delete_folder' => 'Delete Folder',
|
||||
'link_count_one' => '1 link',
|
||||
'link_count_many' => ':count links',
|
||||
|
||||
// Tags
|
||||
'tags_navigation_label' => 'Tags',
|
||||
'tag_resource_title' => 'Tag',
|
||||
'tag_name' => 'Tag Name',
|
||||
'tag_slug' => 'Slug',
|
||||
'tag_color' => 'Color',
|
||||
'empty_state_tag_action' => 'Create Tag',
|
||||
'empty_state_tags_heading' => 'No tags yet',
|
||||
'empty_state_tags_description' => 'Tag your short links to filter them easily.',
|
||||
'action_edit_tag' => 'Edit Tag',
|
||||
'action_delete_tag' => 'Delete Tag',
|
||||
|
||||
// Archiving
|
||||
'is_archived' => 'Archived',
|
||||
'is_archived_helper' => 'Archived links are hidden from the active list but continue to redirect.',
|
||||
'action_archive' => 'Archive',
|
||||
'action_restore' => 'Restore',
|
||||
'action_archive_selected' => 'Archive Selected',
|
||||
'action_restore_selected' => 'Restore Selected',
|
||||
'tab_active_links' => 'Active',
|
||||
'tab_archived_links' => 'Archived',
|
||||
|
||||
// Live Feed
|
||||
'stats_tab_live_feed' => 'Live Feed',
|
||||
'stats_live_feed_empty' => 'No visits recorded yet. Visits will appear here in real-time.',
|
||||
'stats_live_feed_poll_interval' => 'Updates every 5s',
|
||||
'stats_col_visitor' => 'Visitor / Location',
|
||||
|
||||
// Empty state mock values
|
||||
'empty_state_tags_mock_redirecting' => 'Redirecting...',
|
||||
'empty_state_tags_mock_promo' => 'promo',
|
||||
'empty_state_tags_mock_social' => 'social',
|
||||
'empty_state_tags_mock_dev' => 'dev',
|
||||
|
||||
// Folder links count
|
||||
'folder_one_link' => '1 link',
|
||||
'folder_many_links' => ':count links',
|
||||
|
||||
// Colors
|
||||
'color_gray' => 'Gray',
|
||||
'color_red' => 'Red',
|
||||
'color_blue' => 'Blue',
|
||||
'color_green' => 'Green',
|
||||
'color_yellow' => 'Yellow',
|
||||
'color_indigo' => 'Indigo',
|
||||
'color_purple' => 'Purple',
|
||||
'color_pink' => 'Pink',
|
||||
];
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'navigation_label' => 'Krótkie linki',
|
||||
'navigation_group' => 'Narzędzia',
|
||||
'navigation_label' => 'Linki',
|
||||
'navigation_group' => 'Skracacz linków',
|
||||
'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',
|
||||
@@ -17,6 +23,9 @@ return [
|
||||
'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',
|
||||
@@ -140,7 +149,10 @@ 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',
|
||||
'action_move' => 'Przenieś',
|
||||
'share_title' => 'Udostępnij Link',
|
||||
'share_description' => 'Udostępnij ten krótki link przez:',
|
||||
'share_copy' => 'Skopiuj',
|
||||
@@ -153,7 +165,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',
|
||||
@@ -180,6 +192,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',
|
||||
@@ -207,6 +221,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.',
|
||||
@@ -228,7 +246,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',
|
||||
@@ -247,7 +265,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',
|
||||
@@ -266,6 +284,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',
|
||||
@@ -289,9 +316,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
|
||||
@@ -299,6 +328,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',
|
||||
@@ -319,6 +358,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',
|
||||
@@ -344,7 +408,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).',
|
||||
|
||||
@@ -361,6 +425,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',
|
||||
@@ -399,6 +466,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',
|
||||
@@ -422,6 +491,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',
|
||||
@@ -458,6 +529,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ęć',
|
||||
@@ -508,4 +580,169 @@ return [
|
||||
'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',
|
||||
|
||||
// API Scopes & Rate Limits
|
||||
'api_key_scope' => 'Uprawnienia (Scope)',
|
||||
'api_key_scope_helper' => 'Wybierz poziom dostępu dla tego klucza API.',
|
||||
'api_key_scope_read_write' => 'Odczyt i Zapis (Pełny dostęp)',
|
||||
'api_key_scope_read_only' => 'Tylko Odczyt (Read-Only)',
|
||||
'api_key_rate_limit' => 'Limit zapytań',
|
||||
'api_key_rate_limit_helper' => 'Zdefiniuj maksymalną liczbę zapytań na minutę dopuszczalną dla tego klucza.',
|
||||
'api_key_rate_limit_unlimited' => 'Bez limitu',
|
||||
'api_key_rate_limit_rpm' => ':count zapytań / min',
|
||||
|
||||
// Folders
|
||||
'folders_navigation_label' => 'Foldery',
|
||||
'folder_resource_title' => 'Folder',
|
||||
'folder_name' => 'Nazwa folderu',
|
||||
'folder_slug' => 'Uproszczona nazwa (Slug)',
|
||||
'folder_color' => 'Kolor',
|
||||
'empty_state_folder_action' => 'Utwórz folder',
|
||||
'empty_state_folders_heading' => 'Brak folderów',
|
||||
'empty_state_folders_description' => 'Uporządkuj swoje krótkie linki w folderach.',
|
||||
'action_edit_folder' => 'Edytuj folder',
|
||||
'action_delete_folder' => 'Usuń folder',
|
||||
'link_count_one' => '1 link',
|
||||
'link_count_many' => ':count linków',
|
||||
|
||||
// Tagi
|
||||
'tags_navigation_label' => 'Tagi',
|
||||
'tag_resource_title' => 'Tag',
|
||||
'tag_name' => 'Nazwa tagu',
|
||||
'tag_slug' => 'Uproszczona nazwa (Slug)',
|
||||
'tag_color' => 'Kolor',
|
||||
'empty_state_tag_action' => 'Utwórz tag',
|
||||
'empty_state_tags_heading' => 'Brak tagów',
|
||||
'empty_state_tags_description' => 'Oznaczaj swoje krótkie linki tagami, by łatwo je filtrować.',
|
||||
'action_edit_tag' => 'Edytuj tag',
|
||||
'action_delete_tag' => 'Usuń tag',
|
||||
|
||||
// Archiving
|
||||
'is_archived' => 'Zarchiwizowany',
|
||||
'is_archived_helper' => 'Zarchiwizowane linki są ukrywane z głównej listy, ale nadal przekierowują użytkowników.',
|
||||
'action_archive' => 'Archiwizuj',
|
||||
'action_restore' => 'Przywróć',
|
||||
'action_archive_selected' => 'Zarchiwizuj zaznaczone',
|
||||
'action_restore_selected' => 'Przywróć zaznaczone',
|
||||
'tab_active_links' => 'Aktywne',
|
||||
'tab_archived_links' => 'Zarchiwizowane',
|
||||
|
||||
// Live Feed
|
||||
'stats_tab_live_feed' => 'Strumień aktywności',
|
||||
'stats_live_feed_empty' => 'Brak wizyt. Nowe wizyty pojawią się tutaj w czasie rzeczywistym.',
|
||||
'stats_live_feed_poll_interval' => 'Aktualizacja co 5s',
|
||||
'stats_col_visitor' => 'Odwiedzający / Lokalizacja',
|
||||
|
||||
// Puste stany - makiety
|
||||
'empty_state_tags_mock_redirecting' => 'Przekierowywanie...',
|
||||
'empty_state_tags_mock_promo' => 'promocja',
|
||||
'empty_state_tags_mock_social' => 'social-media',
|
||||
'empty_state_tags_mock_dev' => 'programisci',
|
||||
|
||||
// Liczba linków w folderze
|
||||
'folder_one_link' => '1 link',
|
||||
'folder_many_links' => ':count linków',
|
||||
|
||||
// Colors
|
||||
'color_gray' => 'Szary',
|
||||
'color_red' => 'Czerwony',
|
||||
'color_blue' => 'Niebieski',
|
||||
'color_green' => 'Zielony',
|
||||
'color_yellow' => 'Żółty',
|
||||
'color_indigo' => 'Indygo',
|
||||
'color_purple' => 'Fioletowy',
|
||||
'color_pink' => 'Różowy',
|
||||
];
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ __('filament-short-url::default.app_redirect_title') ?? 'Opening App...' }}</title>
|
||||
<title>{{ __('filament-short-url::default.app_redirect_title') }}</title>
|
||||
|
||||
<!-- Premium Google Fonts: Bricolage Grotesque -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
@@ -154,10 +154,10 @@
|
||||
</div>
|
||||
|
||||
<p class="text-xl font-bold text-neutral-900 dark:text-white mt-2">
|
||||
{{ __('filament-short-url::default.app_redirect_opening_in', ['app' => $appName]) ?? "Opening in {$appName}" }}
|
||||
{{ __('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') ?? 'We are redirecting you to the native application. Please wait a moment...' }}
|
||||
{{ __('filament-short-url::default.app_redirect_waiting_text') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -171,7 +171,7 @@
|
||||
onmouseover="this.style.filter='brightness(1.05)';"
|
||||
onmouseout="this.style.filter='brightness(0.95)';"
|
||||
id="deep-link-btn">
|
||||
<span>{{ __('filament-short-url::default.app_redirect_btn_open') ?? 'Open Native App' }}</span>
|
||||
<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>
|
||||
@@ -181,7 +181,7 @@
|
||||
<a href="{{ $destination }}"
|
||||
class="w-full py-3 rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 hover:bg-neutral-50 dark:hover:bg-neutral-800 text-neutral-500 dark:text-neutral-400 font-semibold text-sm transition duration-200 flex justify-center items-center gap-2 shadow-sm"
|
||||
id="fallback-btn">
|
||||
<span>{{ __('filament-short-url::default.app_redirect_btn_browser') ?? 'Open in Browser' }}</span>
|
||||
<span>{{ __('filament-short-url::default.app_redirect_btn_browser') }}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -191,10 +191,10 @@
|
||||
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span>{{ __('filament-short-url::default.app_redirect_in_app_warning_title') ?? 'In-App Browser Detected' }}</span>
|
||||
<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') ?? '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.' }}
|
||||
{{ __('filament-short-url::default.app_redirect_in_app_warning_desc') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ __('filament-short-url::default.expired_title') ?? 'Link Inactive or Expired' }}</title>
|
||||
<title>{{ __('filament-short-url::default.expired_title') }}</title>
|
||||
|
||||
<!-- Premium Google Fonts: Bricolage Grotesque -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
@@ -50,10 +50,10 @@
|
||||
</div>
|
||||
|
||||
<p class="text-xl font-medium text-neutral-900 dark:text-white mt-2">
|
||||
{{ __('filament-short-url::default.expired_title') ?? 'Link Inactive or Expired' }}
|
||||
{{ __('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') ?? 'This link is no longer active. It may have reached its click limit, expiration date, or has been deactivated.' }}
|
||||
{{ __('filament-short-url::default.expired_description') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
|
||||
{{-- Action Button --}}
|
||||
<a href="{{ url('/') }}" class="w-full mt-2 py-3.5 rounded-xl bg-neutral-900 hover:bg-neutral-800 dark:bg-white dark:hover:bg-neutral-200 dark:text-neutral-900 text-white font-semibold text-sm transition duration-200 flex justify-center items-center gap-2 text-center">
|
||||
<span>{{ __('filament-short-url::default.expired_btn_home') ?? 'Go to Homepage' }}</span>
|
||||
<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>
|
||||
|
||||
159
resources/views/forms/components/traffic-splitter.blade.php
Normal 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>
|
||||
35
resources/views/live.blade.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<x-filament-panels::page>
|
||||
<x-filament::tabs class="mb-6">
|
||||
<x-filament::tabs.item
|
||||
tag="a"
|
||||
href="{{ \Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource::getUrl('stats', ['record' => $record]) }}"
|
||||
icon="heroicon-m-presentation-chart-line"
|
||||
>
|
||||
{{ __('filament-short-url::default.stats_tab_statistics') }}
|
||||
</x-filament::tabs.item>
|
||||
|
||||
<x-filament::tabs.item
|
||||
:active="true"
|
||||
icon="heroicon-m-bolt"
|
||||
>
|
||||
{{ __('filament-short-url::default.stats_tab_live_feed') }}
|
||||
</x-filament::tabs.item>
|
||||
|
||||
<x-filament::tabs.item
|
||||
tag="a"
|
||||
href="{{ \Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource::getUrl('stats.logs', ['record' => $record]) }}"
|
||||
icon="heroicon-m-list-bullet"
|
||||
>
|
||||
{{ __('filament-short-url::default.stats_tab_visit_logs') }}
|
||||
</x-filament::tabs.item>
|
||||
</x-filament::tabs>
|
||||
|
||||
<div class="mt-2">
|
||||
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlLiveFeedWidget::class, [
|
||||
'record' => $record,
|
||||
'dateFrom' => null,
|
||||
'dateTo' => null,
|
||||
'filters' => [],
|
||||
], key('live-feed-page-' . $record->id))
|
||||
</div>
|
||||
</x-filament-panels::page>
|
||||
@@ -8,6 +8,14 @@
|
||||
{{ __('filament-short-url::default.stats_tab_statistics') }}
|
||||
</x-filament::tabs.item>
|
||||
|
||||
<x-filament::tabs.item
|
||||
tag="a"
|
||||
href="{{ \Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource::getUrl('stats.live', ['record' => $record]) }}"
|
||||
icon="heroicon-m-bolt"
|
||||
>
|
||||
{{ __('filament-short-url::default.stats_tab_live_feed') }}
|
||||
</x-filament::tabs.item>
|
||||
|
||||
<x-filament::tabs.item
|
||||
:active="true"
|
||||
icon="heroicon-m-list-bullet"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
@@ -148,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>
|
||||
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
<x-filament-panels::page>
|
||||
<x-filament::tabs class="mb-6">
|
||||
<x-filament::tabs.item
|
||||
:active="true"
|
||||
wire:click="$set('activeTab', 'statistics')"
|
||||
:active="$activeTab === 'statistics'"
|
||||
icon="heroicon-m-presentation-chart-line"
|
||||
>
|
||||
{{ __('filament-short-url::default.stats_tab_statistics') }}
|
||||
</x-filament::tabs.item>
|
||||
|
||||
<x-filament::tabs.item
|
||||
tag="a"
|
||||
href="{{ \Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource::getUrl('stats.live', ['record' => $record]) }}"
|
||||
icon="heroicon-m-bolt"
|
||||
>
|
||||
{{ __('filament-short-url::default.stats_tab_live_feed') }}
|
||||
</x-filament::tabs.item>
|
||||
|
||||
<x-filament::tabs.item
|
||||
tag="a"
|
||||
href="{{ \Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource::getUrl('stats.logs', ['record' => $record]) }}"
|
||||
@@ -20,6 +29,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 +42,98 @@
|
||||
</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>
|
||||
|
||||
|
||||
247
resources/views/table/custom-domain-row.blade.php
Normal 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>
|
||||
22
resources/views/table/destination-column.blade.php
Normal 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>
|
||||
122
resources/views/table/dns-guide-panel.blade.php
Normal 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>
|
||||
38
resources/views/table/domain-column.blade.php
Normal 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>
|
||||
67
resources/views/table/empty-state-domains.blade.php
Normal 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>
|
||||
133
resources/views/table/empty-state-folders.blade.php
Normal file
@@ -0,0 +1,133 @@
|
||||
<div class="flex flex-col items-center justify-center gap-6 px-4 py-10 md:min-h-[550px]">
|
||||
<!-- Folders Visual Grid -->
|
||||
<div class="relative flex items-center justify-center h-40 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>
|
||||
|
||||
<!-- 3D Folder Container -->
|
||||
<div class="relative w-48 h-32 flex items-center justify-center z-10" style="perspective: 1000px;">
|
||||
<!-- Folder Back -->
|
||||
<div class="absolute bottom-2 w-28 h-18 bg-neutral-300 dark:bg-neutral-800 rounded-lg shadow-sm border border-neutral-400/30 dark:border-neutral-700/50" style="transform: translateZ(-30px);">
|
||||
<!-- Folder Tab -->
|
||||
<div class="absolute -top-2 left-3 w-10 h-3 bg-neutral-300 dark:bg-neutral-800 rounded-t-md border-t border-x border-neutral-400/30 dark:border-neutral-700/50"></div>
|
||||
</div>
|
||||
|
||||
<!-- Floating Document/Link Cards (Middle Layer) -->
|
||||
<!-- Card 1 -->
|
||||
<div class="absolute w-24 h-12 rounded-lg bg-gradient-to-br from-blue-500/90 to-indigo-600/90 text-white shadow-md border border-white/20 flex flex-col justify-between p-2 select-none"
|
||||
style="animation: fsu-folder-card-1 4s infinite cubic-bezier(0.25, 1, 0.5, 1); transform-style: preserve-3d;">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="w-3.5 h-3.5 rounded-full bg-white/25 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-2.5 h-2.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="h-1.5 w-8 bg-white/30 rounded"></div>
|
||||
</div>
|
||||
<div class="h-1 w-12 bg-white/20 rounded"></div>
|
||||
</div>
|
||||
|
||||
<!-- Card 2 -->
|
||||
<div class="absolute w-24 h-12 rounded-lg bg-gradient-to-br from-violet-500/90 to-purple-600/90 text-white shadow-md border border-white/20 flex flex-col justify-between p-2 select-none"
|
||||
style="animation: fsu-folder-card-2 4s infinite cubic-bezier(0.25, 1, 0.5, 1); animation-delay: 2s; transform-style: preserve-3d;">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="w-3.5 h-3.5 rounded-full bg-white/25 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-2.5 h-2.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="h-1.5 w-8 bg-white/30 rounded"></div>
|
||||
</div>
|
||||
<div class="h-1 w-12 bg-white/20 rounded"></div>
|
||||
</div>
|
||||
|
||||
<!-- Folder Front (Glassmorphic & Tilted) -->
|
||||
<div class="absolute bottom-2 w-28 h-16 bg-neutral-100/50 dark:bg-neutral-900/50 border border-white/30 dark:border-neutral-800/80 rounded-lg shadow-lg origin-bottom transition-all"
|
||||
style="backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); transform: rotateX(-16deg); transform-style: preserve-3d; animation: fsu-folder-front 4s infinite ease-in-out; box-shadow: 0 12px 20px -8px rgba(0,0,0,0.15), 0 4px 6px -2px rgba(0,0,0,0.05);">
|
||||
<!-- Folder lock / branding line -->
|
||||
<div class="absolute top-2 left-3 w-6 h-1 bg-neutral-300 dark:bg-neutral-700 rounded-full"></div>
|
||||
<div class="absolute bottom-2 right-3 w-3 h-3 rounded-full border border-neutral-300 dark:border-neutral-750 flex items-center justify-center">
|
||||
<div class="w-1 h-1 rounded-full bg-neutral-400 dark:bg-neutral-650"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes fsu-folder-front {
|
||||
0%, 100% {
|
||||
transform: rotateX(-15deg);
|
||||
}
|
||||
50% {
|
||||
transform: rotateX(-22deg);
|
||||
}
|
||||
}
|
||||
@keyframes fsu-folder-card-1 {
|
||||
0% {
|
||||
transform: translate3d(35px, -60px, 10px) rotate(12deg) scale(0.8);
|
||||
opacity: 0;
|
||||
}
|
||||
15% {
|
||||
transform: translate3d(20px, -45px, 20px) rotate(6deg) scale(0.95);
|
||||
opacity: 1;
|
||||
}
|
||||
35% {
|
||||
transform: translate3d(0px, -20px, 0px) rotate(0deg) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
55% {
|
||||
transform: translate3d(0px, 10px, -15px) rotate(-2deg) scale(0.9);
|
||||
opacity: 0.8;
|
||||
}
|
||||
75%, 100% {
|
||||
transform: translate3d(0px, 25px, -25px) rotate(-4deg) scale(0.75);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes fsu-folder-card-2 {
|
||||
0% {
|
||||
transform: translate3d(-35px, -60px, 10px) rotate(-12deg) scale(0.8);
|
||||
opacity: 0;
|
||||
}
|
||||
15% {
|
||||
transform: translate3d(-20px, -45px, 20px) rotate(-6deg) scale(0.95);
|
||||
opacity: 1;
|
||||
}
|
||||
35% {
|
||||
transform: translate3d(0px, -20px, 0px) rotate(0deg) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
55% {
|
||||
transform: translate3d(0px, 10px, -15px) rotate(2deg) scale(0.9);
|
||||
opacity: 0.8;
|
||||
}
|
||||
75%, 100% {
|
||||
transform: translate3d(0px, 25px, -25px) rotate(4deg) scale(0.75);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 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_folders_heading') }}
|
||||
</span>
|
||||
<div class="mt-2 text-pretty text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{{ __('filament-short-url::default.empty_state_folders_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_folder_action') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
173
resources/views/table/empty-state-pixels.blade.php
Normal 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>
|
||||
128
resources/views/table/empty-state-tags.blade.php
Normal file
@@ -0,0 +1,128 @@
|
||||
<div class="flex flex-col items-center justify-center gap-6 px-4 py-10 md:min-h-[550px]">
|
||||
<!-- Tags Visual Grid -->
|
||||
<div class="relative flex items-center justify-center h-40 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>
|
||||
|
||||
<!-- Orbiting Tags Container -->
|
||||
<div class="relative w-64 h-32 flex items-center justify-center z-10">
|
||||
<!-- Orbit Path Visual -->
|
||||
<svg class="absolute w-56 h-20 opacity-20 dark:opacity-30 text-neutral-300 dark:text-neutral-700" viewBox="0 0 220 80" fill="none">
|
||||
<ellipse cx="110" cy="40" rx="100" ry="32" stroke="currentColor" stroke-width="1.5" stroke-dasharray="4 4" />
|
||||
</svg>
|
||||
|
||||
<!-- Central URL Card -->
|
||||
<div class="relative w-36 bg-white dark:bg-neutral-900 border border-neutral-200/80 dark:border-neutral-800 rounded-xl px-2.5 py-2 shadow-md flex items-center gap-2 select-none"
|
||||
style="animation: fsu-tag-center-card-float 4s infinite ease-in-out; z-index: 10;">
|
||||
<div class="w-5 h-5 rounded-lg bg-purple-500/10 dark:bg-purple-500/20 flex items-center justify-center text-purple-600 dark:text-purple-400 flex-shrink-0">
|
||||
<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="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex flex-col overflow-hidden">
|
||||
<span class="text-[10px] font-bold text-neutral-800 dark:text-neutral-200 font-mono leading-none truncate max-w-[85px] block" title="{{ parse_url(config('app.url'), PHP_URL_HOST) ?? request()->getHost() }}/{{ __('filament-short-url::default.empty_state_tags_mock_promo') }}">
|
||||
{{ parse_url(config('app.url'), PHP_URL_HOST) ?? request()->getHost() }}/{{ __('filament-short-url::default.empty_state_tags_mock_promo') }}
|
||||
</span>
|
||||
<span class="text-[7.5px] text-neutral-400 dark:text-neutral-500 font-sans mt-0.5">{{ __('filament-short-url::default.empty_state_tags_mock_redirecting') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Orbiting Badges -->
|
||||
<!-- Tag 1: promo (Purple) -->
|
||||
<div class="absolute px-2 py-0.5 rounded-full text-[9px] font-semibold bg-purple-100 dark:bg-purple-950/60 text-purple-700 dark:text-purple-300 border border-purple-200 dark:border-purple-800/50 shadow-sm flex items-center gap-1 select-none"
|
||||
style="animation: fsu-tag-orbit 8s infinite linear; animation-delay: 0s;">
|
||||
<span class="text-purple-400 font-bold">#</span>
|
||||
<span>{{ __('filament-short-url::default.empty_state_tags_mock_promo') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Tag 2: social (Teal) -->
|
||||
<div class="absolute px-2 py-0.5 rounded-full text-[9px] font-semibold bg-teal-100 dark:bg-teal-950/60 text-teal-700 dark:text-teal-300 border border-teal-200 dark:border-teal-800/50 shadow-sm flex items-center gap-1 select-none"
|
||||
style="animation: fsu-tag-orbit 8s infinite linear; animation-delay: -2.66s;">
|
||||
<span class="text-teal-400 font-bold">#</span>
|
||||
<span>{{ __('filament-short-url::default.empty_state_tags_mock_social') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Tag 3: dev (Amber) -->
|
||||
<div class="absolute px-2 py-0.5 rounded-full text-[9px] font-semibold bg-amber-100 dark:bg-amber-950/60 text-amber-700 dark:text-amber-300 border border-amber-200 dark:border-amber-800/50 shadow-sm flex items-center gap-1 select-none"
|
||||
style="animation: fsu-tag-orbit 8s infinite linear; animation-delay: -5.33s;">
|
||||
<span class="text-amber-400 font-bold">#</span>
|
||||
<span>{{ __('filament-short-url::default.empty_state_tags_mock_dev') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes fsu-tag-center-card-float {
|
||||
0%, 100% {
|
||||
transform: translateY(0) rotate(-1deg);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-6px) rotate(1deg);
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
}
|
||||
@keyframes fsu-tag-orbit {
|
||||
0% {
|
||||
transform: translate3d(-100px, 0px, 0px) scale(0.9);
|
||||
z-index: 2;
|
||||
opacity: 0.8;
|
||||
}
|
||||
4% {
|
||||
z-index: 20;
|
||||
}
|
||||
25% {
|
||||
transform: translate3d(0px, 32px, 0px) scale(1.05);
|
||||
z-index: 20;
|
||||
opacity: 1;
|
||||
}
|
||||
46% {
|
||||
z-index: 20;
|
||||
}
|
||||
50% {
|
||||
transform: translate3d(100px, 0px, 0px) scale(0.9);
|
||||
z-index: 2;
|
||||
opacity: 0.8;
|
||||
}
|
||||
54% {
|
||||
z-index: 2;
|
||||
}
|
||||
75% {
|
||||
transform: translate3d(0px, -32px, 0px) scale(0.8);
|
||||
z-index: 2;
|
||||
opacity: 0.5;
|
||||
}
|
||||
96% {
|
||||
z-index: 2;
|
||||
}
|
||||
100% {
|
||||
transform: translate3d(-100px, 0px, 0px) scale(0.9);
|
||||
z-index: 2;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 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_tags_heading') }}
|
||||
</span>
|
||||
<div class="mt-2 text-pretty text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{{ __('filament-short-url::default.empty_state_tags_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_tag_action') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
245
resources/views/table/empty-state.blade.php
Normal 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>
|
||||
107
resources/views/table/folder-card-column.blade.php
Normal file
@@ -0,0 +1,107 @@
|
||||
@php
|
||||
$record = $getRecord();
|
||||
$color = $record->color ?? 'gray';
|
||||
|
||||
// Map folder color to valid Tailwind classes matching the premium demo design
|
||||
$colorClasses = match ($color) {
|
||||
'gray' => [
|
||||
'border_outer' => 'border-neutral-200 dark:border-neutral-700',
|
||||
'bg_inner' => 'bg-neutral-100 dark:bg-neutral-800',
|
||||
'text' => 'text-neutral-600 dark:text-neutral-400',
|
||||
'badge_bg' => 'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-350',
|
||||
],
|
||||
'red' => [
|
||||
'border_outer' => 'border-red-200 dark:border-red-900/30',
|
||||
'bg_inner' => 'bg-red-100 dark:bg-red-950/40',
|
||||
'text' => 'text-red-800 dark:text-red-400',
|
||||
'badge_bg' => 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-350',
|
||||
],
|
||||
'blue' => [
|
||||
'border_outer' => 'border-blue-200 dark:border-blue-900/30',
|
||||
'bg_inner' => 'bg-blue-100 dark:bg-blue-950/40',
|
||||
'text' => 'text-blue-800 dark:text-blue-400',
|
||||
'badge_bg' => 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-350',
|
||||
],
|
||||
'green' => [
|
||||
'border_outer' => 'border-emerald-200 dark:border-emerald-900/30',
|
||||
'bg_inner' => 'bg-emerald-100 dark:bg-emerald-950/40',
|
||||
'text' => 'text-emerald-800 dark:text-emerald-400',
|
||||
'badge_bg' => 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-350',
|
||||
],
|
||||
'yellow' => [
|
||||
'border_outer' => 'border-amber-200 dark:border-amber-900/30',
|
||||
'bg_inner' => 'bg-amber-100 dark:bg-amber-950/40',
|
||||
'text' => 'text-amber-800 dark:text-amber-400',
|
||||
'badge_bg' => 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-350',
|
||||
],
|
||||
'indigo' => [
|
||||
'border_outer' => 'border-indigo-200 dark:border-indigo-900/30',
|
||||
'bg_inner' => 'bg-indigo-100 dark:bg-indigo-950/40',
|
||||
'text' => 'text-indigo-800 dark:text-indigo-400',
|
||||
'badge_bg' => 'bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-350',
|
||||
],
|
||||
'purple' => [
|
||||
'border_outer' => 'border-purple-200 dark:border-purple-900/30',
|
||||
'bg_inner' => 'bg-purple-100 dark:bg-purple-950/40',
|
||||
'text' => 'text-purple-800 dark:text-purple-400',
|
||||
'badge_bg' => 'bg-purple-100 dark:bg-purple-900/30 text-purple-750 dark:text-purple-350',
|
||||
],
|
||||
'pink' => [
|
||||
'border_outer' => 'border-pink-200 dark:border-pink-900/30',
|
||||
'bg_inner' => 'bg-pink-100 dark:bg-pink-950/40',
|
||||
'text' => 'text-pink-800 dark:text-pink-400',
|
||||
'badge_bg' => 'bg-pink-100 dark:bg-pink-900/30 text-pink-700 dark:text-pink-350',
|
||||
],
|
||||
default => [
|
||||
'border_outer' => 'border-neutral-200 dark:border-neutral-700',
|
||||
'bg_inner' => 'bg-neutral-100 dark:bg-neutral-800',
|
||||
'text' => 'text-neutral-600 dark:text-neutral-400',
|
||||
'badge_bg' => 'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-350',
|
||||
],
|
||||
};
|
||||
|
||||
$linkCount = $record->short_urls_count ?? 0;
|
||||
$linkString = $linkCount === 1 ? __('filament-short-url::default.link_count_one') : __('filament-short-url::default.link_count_many', ['count' => $linkCount]);
|
||||
if (str_contains($linkString, 'filament-short-url::')) {
|
||||
$linkString = $linkCount === 1 ? '1 link' : "{$linkCount} links";
|
||||
}
|
||||
@endphp
|
||||
|
||||
<div class="flex flex-col justify-between h-full w-full select-none">
|
||||
|
||||
<!-- Top Row: Icon Container -->
|
||||
<div class="flex items-center justify-between z-10 pointer-events-none">
|
||||
<div class="border rounded-full bg-white dark:bg-neutral-850 p-0.5 {{ $colorClasses['border_outer'] }}">
|
||||
<div class="rounded-full p-2 {{ $colorClasses['bg_inner'] }} {{ $colorClasses['text'] }}">
|
||||
<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="M2.25 12.75V12A2.25 2.25 0 0 1 4.5 9.75h15A2.25 2.25 0 0 1 21.75 12v.75m-8.69-6.44-2.12-2.12a1.5 1.5 0 0 0-1.061-.44H4.5A2.25 2.25 0 0 0 2.25 6v12a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9a2.25 2.25 0 0 0-2.25-2.25h-5.379a1.5 1.5 0 0 1-1.06-.44Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Row: Title, Badges, Link count -->
|
||||
<div class="z-10 pointer-events-none">
|
||||
<span class="flex items-center justify-start gap-1.5 truncate text-sm font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
<span class="truncate">{{ $record->name }}</span>
|
||||
<span class="text-[10px] font-medium px-1.5 py-0.5 rounded {{ $colorClasses['badge_bg'] }} font-mono">
|
||||
/{{ $record->slug }}
|
||||
</span>
|
||||
@if($record->slug === 'default' || $record->slug === 'links')
|
||||
<span class="text-[10px] font-medium px-1.5 py-0.5 rounded bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300">
|
||||
Default
|
||||
</span>
|
||||
@endif
|
||||
</span>
|
||||
<div class="mt-1.5 flex items-center gap-1 text-neutral-500 dark:text-neutral-400">
|
||||
<svg height="18" width="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5">
|
||||
<g fill="currentColor">
|
||||
<ellipse cx="9" cy="9" fill="none" rx="7.25" ry="3" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></ellipse>
|
||||
<ellipse cx="9" cy="9" fill="none" rx="3" ry="7.25" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></ellipse>
|
||||
<circle cx="9" cy="9" fill="none" r="7.25" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></circle>
|
||||
</g>
|
||||
</svg>
|
||||
<span class="text-sm font-normal">{{ $linkString }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
229
resources/views/table/metadata-badges.blade.php
Normal 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>
|
||||
261
resources/views/table/qr-code-modal.blade.php
Normal 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>
|
||||
36
resources/views/table/share-copy-field.blade.php
Normal 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>
|
||||
61
resources/views/table/share-platforms.blade.php
Normal 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>
|
||||
53
resources/views/table/tag-row-column.blade.php
Normal file
@@ -0,0 +1,53 @@
|
||||
@php
|
||||
$record = $getRecord();
|
||||
$color = $record->color ?? 'gray';
|
||||
|
||||
// Map tag color to icon + badge colors — same hex values as the Select options
|
||||
$colorMap = match ($color) {
|
||||
'red' => ['icon_bg' => '#fee2e2', 'icon_color' => '#ef4444'],
|
||||
'blue' => ['icon_bg' => '#dbeafe', 'icon_color' => '#3b82f6'],
|
||||
'green' => ['icon_bg' => '#d1fae5', 'icon_color' => '#10b981'],
|
||||
'yellow' => ['icon_bg' => '#fef3c7', 'icon_color' => '#f59e0b'],
|
||||
'indigo' => ['icon_bg' => '#e0e7ff', 'icon_color' => '#6366f1'],
|
||||
'purple' => ['icon_bg' => '#f3e8ff', 'icon_color' => '#a855f7'],
|
||||
'pink' => ['icon_bg' => '#fce7f3', 'icon_color' => '#ec4899'],
|
||||
default => ['icon_bg' => '#f3f4f6', 'icon_color' => '#737373'],
|
||||
};
|
||||
|
||||
$linkCount = $record->short_urls_count ?? 0;
|
||||
$linkString = $linkCount === 1 ? __('filament-short-url::default.link_count_one') : __('filament-short-url::default.link_count_many', ['count' => $linkCount]);
|
||||
if (str_contains($linkString, 'filament-short-url::')) {
|
||||
$linkString = $linkCount === 1 ? '1 link' : "{$linkCount} links";
|
||||
}
|
||||
@endphp
|
||||
|
||||
<div class="flex items-center justify-between w-full gap-3 select-none py-0.5">
|
||||
{{-- Left: icon + name --}}
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
{{-- Colored tag icon --}}
|
||||
<div class="flex-shrink-0 flex items-center justify-center w-8 h-8 rounded-full"
|
||||
style="background-color: {{ $colorMap['icon_bg'] }};">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="{{ $colorMap['icon_color'] }}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 2H7a2 2 0 0 0-2 2v5.586a1 1 0 0 0 .293.707l9.414 9.414a2 2 0 0 0 2.828 0l4.172-4.172a2 2 0 0 0 0-2.828L12.707 2.293A1 1 0 0 0 12 2z"/>
|
||||
<circle cx="7.5" cy="7.5" r="1" fill="{{ $colorMap['icon_color'] }}" stroke="none"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{{-- Name --}}
|
||||
<span class="text-sm font-semibold text-neutral-900 dark:text-neutral-100 truncate">
|
||||
{{ $record->name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{{-- Right: link count badge --}}
|
||||
<div class="flex items-center gap-1.5 flex-shrink-0 mr-2 border border-neutral-200 dark:border-neutral-800 rounded-full px-2.5 py-1 text-neutral-500 dark:text-neutral-400 text-xs font-medium bg-white dark:bg-neutral-900">
|
||||
<svg height="14" width="14" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5 flex-shrink-0">
|
||||
<g fill="currentColor">
|
||||
<ellipse cx="9" cy="9" fill="none" rx="7.25" ry="3" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></ellipse>
|
||||
<ellipse cx="9" cy="9" fill="none" rx="3" ry="7.25" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></ellipse>
|
||||
<circle cx="9" cy="9" fill="none" r="7.25" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></circle>
|
||||
</g>
|
||||
</svg>
|
||||
<span>{{ $linkString }}</span>
|
||||
</div>
|
||||
</div>
|
||||
53
resources/views/table/url-key-column.blade.php
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
108
resources/views/webhook-payload-example.blade.php
Normal 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>
|
||||
16
resources/views/widgets/chart-empty.blade.php
Normal 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>
|
||||
164
resources/views/widgets/device-breakdown.blade.php
Normal 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>
|
||||
1
resources/views/widgets/empty.blade.php
Normal file
@@ -0,0 +1 @@
|
||||
<div></div>
|
||||
133
resources/views/widgets/geo-breakdown.blade.php
Normal 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>
|
||||
153
resources/views/widgets/live-feed.blade.php
Normal file
@@ -0,0 +1,153 @@
|
||||
<x-filament-widgets::widget>
|
||||
{{--
|
||||
wire:poll calls checkForUpdates() instead of blindly re-rendering.
|
||||
checkForUpdates() does a single MAX(id) query; if nothing changed it
|
||||
calls skipRender() and Livewire returns a ~100-byte "no diff" response.
|
||||
Only when a new visit is detected does the full render happen.
|
||||
.visible stops polling entirely when the widget is scrolled out of view.
|
||||
--}}
|
||||
<div wire:poll.5s.visible="checkForUpdates"
|
||||
class="fi-section rounded-xl border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-900 overflow-hidden">
|
||||
|
||||
<!-- 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 py-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="flex h-2 w-2 relative">
|
||||
<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 w-2 bg-emerald-500"></span>
|
||||
</span>
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ __('filament-short-url::default.stats_tab_live_feed') }}
|
||||
</h3>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500 font-mono">
|
||||
{{ __('filament-short-url::default.stats_live_feed_poll_interval') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="p-6 relative min-h-[300px]">
|
||||
<!-- Spinner shown only during re-renders that actually happen -->
|
||||
<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>
|
||||
|
||||
<div class="space-y-4" wire:loading.class="opacity-40 pointer-events-none transition-opacity">
|
||||
@forelse ($visits as $visit)
|
||||
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4 p-4 border border-gray-100 dark:border-white/5 rounded-xl hover:bg-gray-50/50 dark:hover:bg-white/5 transition-all">
|
||||
|
||||
<!-- Visitor & Location Info -->
|
||||
<div class="flex items-start gap-3 min-w-0 flex-1">
|
||||
{{-- Flag from flagcdn.com. Alpine handles broken image gracefully without
|
||||
inline onerror JS (which can violate CSP nonce policies). --}}
|
||||
<div class="mt-0.5 shrink-0 w-6 flex items-center" title="{{ $visit['country'] }}">
|
||||
@if ($visit['flag_url'])
|
||||
<span x-data="{ error: false }">
|
||||
<img x-show="!error"
|
||||
x-on:error="error = true"
|
||||
src="{{ $visit['flag_url'] }}"
|
||||
width="20"
|
||||
height="15"
|
||||
alt="{{ $visit['country_code'] }}"
|
||||
class="rounded-[2px] object-cover shadow-sm">
|
||||
<span x-show="error" class="text-base leading-none">🌐</span>
|
||||
</span>
|
||||
@else
|
||||
<span class="text-base leading-none">🌐</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<button type="button"
|
||||
x-on:click="$wire.dispatch('set-stats-filter', { key: 'country_code', value: '{{ $visit['country_code'] }}' })"
|
||||
class="text-sm font-semibold text-gray-950 dark:text-white hover:underline truncate">
|
||||
{{ $visit['city'] ?: 'Unknown City' }}, {{ $visit['country'] ?: 'Unknown Country' }}
|
||||
</button>
|
||||
|
||||
@if ($visit['is_qr_scan'])
|
||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium bg-purple-50 text-purple-700 dark:bg-purple-950/40 dark:text-purple-300 border border-purple-100 dark:border-purple-900/40">
|
||||
<x-filament::icon icon="heroicon-m-qr-code" class="w-3.5 h-3.5" />
|
||||
QR Scan
|
||||
</span>
|
||||
@endif
|
||||
|
||||
@if ($visit['selected_variant'])
|
||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium bg-amber-50 text-amber-700 dark:bg-amber-950/40 dark:text-amber-300 border border-amber-100 dark:border-amber-900/40">
|
||||
Variant: {{ $visit['selected_variant'] }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-gray-400 dark:text-gray-500 mt-1 flex items-center gap-2">
|
||||
{{-- time_ago precomputed in PHP — no Carbon::parse() per-row in Blade --}}
|
||||
<span>{{ $visit['time_ago'] }}</span>
|
||||
<span>•</span>
|
||||
<span class="font-mono text-[11px]">{{ $visit['ip_address'] ?: '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Referrer -->
|
||||
<div class="flex items-center gap-2 min-w-[150px] max-w-[220px]">
|
||||
<x-filament::icon icon="heroicon-m-link" class="w-4 h-4 text-gray-400 dark:text-gray-500 shrink-0" />
|
||||
<span class="text-xs font-medium text-gray-600 dark:text-gray-400 truncate"
|
||||
title="{{ $visit['referer_url'] }}">
|
||||
{{ $visit['referer_host'] ?: __('filament-short-url::default.stats_referer_direct') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Device / Browser / OS filters -->
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<!-- Device -->
|
||||
<button type="button"
|
||||
x-on:click="$wire.dispatch('set-stats-filter', { key: 'device_type', value: '{{ $visit['device_type'] }}' })"
|
||||
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg border border-gray-100 dark:border-white/5 bg-gray-50/50 dark:bg-white/5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/10 transition-colors"
|
||||
title="Filter by device: {{ $visit['device_type'] }}">
|
||||
@if (strtolower($visit['device_type'] ?? '') === 'desktop')
|
||||
<x-filament::icon icon="heroicon-m-computer-desktop" class="w-3.5 h-3.5 text-gray-400 dark:text-gray-500" />
|
||||
@elseif (strtolower($visit['device_type'] ?? '') === 'mobile')
|
||||
<x-filament::icon icon="heroicon-m-device-phone-mobile" class="w-3.5 h-3.5 text-gray-400 dark:text-gray-500" />
|
||||
@elseif (strtolower($visit['device_type'] ?? '') === 'tablet')
|
||||
<x-filament::icon icon="heroicon-m-device-tablet" class="w-3.5 h-3.5 text-gray-400 dark:text-gray-500" />
|
||||
@else
|
||||
<x-filament::icon icon="heroicon-m-question-mark-circle" class="w-3.5 h-3.5 text-gray-400 dark:text-gray-500" />
|
||||
@endif
|
||||
<span class="capitalize">{{ $visit['device_type'] ?: 'Unknown' }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Browser -->
|
||||
<button type="button"
|
||||
x-on:click="$wire.dispatch('set-stats-filter', { key: 'browser', value: '{{ $visit['browser'] }}' })"
|
||||
class="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg border border-gray-100 dark:border-white/5 bg-gray-50/50 dark:bg-white/5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/10 transition-colors"
|
||||
title="Filter by browser: {{ $visit['browser'] }}">
|
||||
<x-filament::icon icon="heroicon-m-globe-alt" class="w-3.5 h-3.5 text-gray-400 dark:text-gray-500" />
|
||||
<span>{{ $visit['browser'] ?: 'Browser' }}</span>
|
||||
</button>
|
||||
|
||||
<!-- OS -->
|
||||
<button type="button"
|
||||
x-on:click="$wire.dispatch('set-stats-filter', { key: 'operating_system', value: '{{ $visit['operating_system'] }}' })"
|
||||
class="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg border border-gray-100 dark:border-white/5 bg-gray-50/50 dark:bg-white/5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/10 transition-colors"
|
||||
title="Filter by OS: {{ $visit['operating_system'] }}">
|
||||
<x-filament::icon icon="heroicon-m-cpu-chip" class="w-3.5 h-3.5 text-gray-400 dark:text-gray-500" />
|
||||
<span>{{ $visit['operating_system'] ?: 'OS' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div class="mb-4 flex h-12 w-12 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-chat-bubble-left-right" class="h-6 w-6" />
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ __('filament-short-url::default.stats_live_feed_empty') }}
|
||||
</p>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-filament-widgets::widget>
|
||||
107
resources/views/widgets/security-breakdown.blade.php
Normal 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>
|
||||
205
resources/views/widgets/sources-breakdown.blade.php
Normal 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>
|
||||
38
resources/views/widgets/variants.blade.php
Normal 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>
|
||||
@@ -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>
|
||||
102
resources/views/widgets/visits-chart.blade.php
Normal 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>
|
||||
@@ -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>
|
||||
@@ -1,10 +1,9 @@
|
||||
<?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'])
|
||||
@@ -21,26 +20,38 @@ Route::match(
|
||||
)
|
||||
->name('short-url.redirect')
|
||||
->where('key', '[a-zA-Z0-9_-]+')
|
||||
->middleware(config('filament-short-url.middleware', ['web', 'throttle:120,1']));
|
||||
->middleware(config('filament-short-url.middleware', ['throttle:120,1']));
|
||||
|
||||
Route::match(
|
||||
['GET', 'POST'],
|
||||
config('filament-short-url.route_prefix', 's').'-auth/{key}',
|
||||
[ShortUrlRedirectController::class, 'handlePasswordAuth']
|
||||
)
|
||||
->name('short-url.password-auth')
|
||||
->where('key', '[a-zA-Z0-9_-]+')
|
||||
->middleware(array_merge(['web'], config('filament-short-url.middleware', ['throttle:120,1'])));
|
||||
|
||||
Route::prefix('api/short-url')
|
||||
->middleware([AuthenticateShortUrlApi::class])
|
||||
->middleware([
|
||||
AuthenticateShortUrlApi::class,
|
||||
])
|
||||
->group(function () {
|
||||
Route::get('links', [ShortUrlApiController::class, 'index']);
|
||||
Route::post('links', [ShortUrlApiController::class, 'store']);
|
||||
Route::delete('links/{id}', [ShortUrlApiController::class, 'destroy']);
|
||||
Route::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', ['throttle:120,1']));
|
||||
}
|
||||
|
||||
22
src/Assets/ShortUrlCss.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -27,12 +27,22 @@ class AggregateAndPruneVisitsCommand extends Command
|
||||
default => 'DATE(visited_at)',
|
||||
};
|
||||
|
||||
// 1. Find all unique dates before today that have visits (optimized range and compatible DATE extract)
|
||||
$dates = ShortUrlVisit::where('visited_at', '<', $today)
|
||||
->selectRaw("{$dateExpression} as visit_date")
|
||||
->distinct()
|
||||
->pluck('visit_date')
|
||||
->toArray();
|
||||
// 1. Find the oldest and newest visit dates before today using highly optimized min/max index scanning
|
||||
$oldestVisit = ShortUrlVisit::where('visited_at', '<', $today)->min('visited_at');
|
||||
if (! $oldestVisit) {
|
||||
$dates = [];
|
||||
} else {
|
||||
$latestVisit = ShortUrlVisit::where('visited_at', '<', $today)->max('visited_at');
|
||||
$startCarbon = Carbon::parse($oldestVisit)->startOfDay();
|
||||
$endCarbon = Carbon::parse($latestVisit)->startOfDay();
|
||||
|
||||
$dates = [];
|
||||
$current = $startCarbon->copy();
|
||||
while ($current->lte($endCarbon)) {
|
||||
$dates[] = $current->toDateString();
|
||||
$current->addDay();
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($dates)) {
|
||||
$this->info('No historical visits to aggregate.');
|
||||
@@ -42,97 +52,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 +173,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'],
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
32
src/Filament/Forms/Components/TrafficSplitter.php
Normal 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;
|
||||
}
|
||||
}
|
||||
182
src/Filament/Resources/ShortUrlCustomDomainResource.php
Normal 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('/'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
158
src/Filament/Resources/ShortUrlFolderResource.php
Normal file
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlFolderResource\Pages\ListShortUrlFolders;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlFolder;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\Layout\Stack;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ShortUrlFolderResource extends Resource
|
||||
{
|
||||
protected static ?string $model = ShortUrlFolder::class;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-folder';
|
||||
|
||||
protected static ?int $navigationSort = 53;
|
||||
|
||||
protected static ?string $recordTitleAttribute = 'name';
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('filament-short-url::default.folders_navigation_label');
|
||||
}
|
||||
|
||||
public static function getModelLabel(): string
|
||||
{
|
||||
return __('filament-short-url::default.folder_resource_title');
|
||||
}
|
||||
|
||||
public static function getPluralModelLabel(): string
|
||||
{
|
||||
return __('filament-short-url::default.folders_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() + 3;
|
||||
} catch (\Throwable) {
|
||||
return static::$navigationSort;
|
||||
}
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
TextInput::make('name')
|
||||
->label(__('filament-short-url::default.folder_name'))
|
||||
->required()
|
||||
->maxLength(100)
|
||||
->live(onBlur: true)
|
||||
->afterStateUpdated(fn ($state, callable $set) => $set('slug', Str::slug($state))),
|
||||
|
||||
TextInput::make('slug')
|
||||
->label(__('filament-short-url::default.folder_slug'))
|
||||
->required()
|
||||
->maxLength(100)
|
||||
->unique('short_url_folders', 'slug', ignoreRecord: true),
|
||||
|
||||
Select::make('color')
|
||||
->label(__('filament-short-url::default.folder_color'))
|
||||
->allowHtml()
|
||||
->options([
|
||||
'gray' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #737373;"></span><span>'.__('filament-short-url::default.color_gray').'</span></span>',
|
||||
'red' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #ef4444;"></span><span>'.__('filament-short-url::default.color_red').'</span></span>',
|
||||
'blue' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #3b82f6;"></span><span>'.__('filament-short-url::default.color_blue').'</span></span>',
|
||||
'green' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #10b981;"></span><span>'.__('filament-short-url::default.color_green').'</span></span>',
|
||||
'yellow' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #f59e0b;"></span><span>'.__('filament-short-url::default.color_yellow').'</span></span>',
|
||||
'indigo' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #6366f1;"></span><span>'.__('filament-short-url::default.color_indigo').'</span></span>',
|
||||
'purple' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #a855f7;"></span><span>'.__('filament-short-url::default.color_purple').'</span></span>',
|
||||
'pink' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #ec4899;"></span><span>'.__('filament-short-url::default.color_pink').'</span></span>',
|
||||
])
|
||||
->default('gray')
|
||||
->required()
|
||||
->native(false),
|
||||
])
|
||||
->columns(1);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Stack::make([
|
||||
TextColumn::make('name')
|
||||
->label(__('filament-short-url::default.folder_name'))
|
||||
->view('filament-short-url::table.folder-card-column')
|
||||
->counts('shortUrls')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->extraAttributes([
|
||||
'class' => 'h-full',
|
||||
]),
|
||||
])->extraAttributes([
|
||||
'class' => 'h-full',
|
||||
]),
|
||||
])
|
||||
->contentGrid([
|
||||
'md' => 2,
|
||||
'lg' => 3,
|
||||
])
|
||||
->recordUrl(
|
||||
fn (ShortUrlFolder $record): string => ShortUrlResource::getUrl('index', [
|
||||
'filters' => ['folder' => ['values' => [$record->id]]],
|
||||
])
|
||||
)
|
||||
->recordClasses(fn (): string => 'folder-card relative flex flex-col justify-between rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-5 py-4 transition-all duration-200 h-[120px] sm:h-[132px] group/card hover:shadow-md hover:border-neutral-350 dark:hover:border-neutral-700')
|
||||
->filters([])
|
||||
->actions([
|
||||
ActionGroup::make([
|
||||
EditAction::make()
|
||||
->icon('heroicon-o-pencil-square')
|
||||
->color('gray')
|
||||
->label(__('filament-short-url::default.action_edit_folder'))
|
||||
->modalWidth('md'),
|
||||
DeleteAction::make()
|
||||
->icon('heroicon-o-trash')
|
||||
->label(__('filament-short-url::default.action_delete_folder')),
|
||||
])
|
||||
->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-transparent hover:bg-bg-muted data-[state=open]:ring-4 data-[state=open]:ring-border-subtle sm:inline-flex h-8 w-8 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;',
|
||||
]),
|
||||
])
|
||||
->bulkActions([])
|
||||
->defaultSort('created_at', 'desc')
|
||||
->emptyState(view('filament-short-url::table.empty-state-folders'));
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListShortUrlFolders::route('/'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlFolderResource\Pages;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlFolderResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ManageRecords;
|
||||
|
||||
class ListShortUrlFolders extends ManageRecords
|
||||
{
|
||||
protected static string $resource = ShortUrlFolderResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make()
|
||||
->label(__('filament-short-url::default.empty_state_folder_action'))
|
||||
->modalHeading(__('filament-short-url::default.empty_state_folder_action'))
|
||||
->icon('heroicon-o-plus')
|
||||
->size('sm')
|
||||
->color('primary')
|
||||
->modalWidth('md')
|
||||
->modalAutofocus(false)
|
||||
->createAnother(false),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,13 @@ 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\Actions\DeleteAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Columns\ToggleColumn;
|
||||
use Filament\Tables\Table;
|
||||
@@ -28,17 +27,17 @@ class ShortUrlPixelResource extends Resource
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('filament-short-url::default.pixels_navigation_label') ?? 'Retargeting Pixels';
|
||||
return __('filament-short-url::default.pixels_navigation_label');
|
||||
}
|
||||
|
||||
public static function getModelLabel(): string
|
||||
{
|
||||
return __('filament-short-url::default.pixel_resource_title') ?? 'Retargeting Pixel';
|
||||
return __('filament-short-url::default.pixel_resource_title');
|
||||
}
|
||||
|
||||
public static function getPluralModelLabel(): string
|
||||
{
|
||||
return __('filament-short-url::default.pixels_navigation_label') ?? 'Retargeting Pixels';
|
||||
return __('filament-short-url::default.pixels_navigation_label');
|
||||
}
|
||||
|
||||
public static function getNavigationGroup(): string|\UnitEnum|null
|
||||
@@ -64,13 +63,13 @@ class ShortUrlPixelResource extends Resource
|
||||
return $schema
|
||||
->components([
|
||||
TextInput::make('name')
|
||||
->label(__('filament-short-url::default.pixel_name') ?? 'Pixel 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') ?? 'Provider')
|
||||
->label(__('filament-short-url::default.pixel_type'))
|
||||
->options([
|
||||
'meta' => 'Meta / Facebook Pixel',
|
||||
'google' => 'Google Tag (GA4 / GTM)',
|
||||
@@ -82,13 +81,13 @@ class ShortUrlPixelResource extends Resource
|
||||
->native(false),
|
||||
|
||||
TextInput::make('pixel_id')
|
||||
->label(__('filament-short-url::default.pixel_id_label') ?? 'Pixel ID / Tag 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') ?? 'Active')
|
||||
->label(__('filament-short-url::default.pixel_status_active'))
|
||||
->default(true),
|
||||
])
|
||||
->columns(1);
|
||||
@@ -99,13 +98,13 @@ class ShortUrlPixelResource extends Resource
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->label(__('filament-short-url::default.pixel_name') ?? 'Pixel Name')
|
||||
->label(__('filament-short-url::default.pixel_name'))
|
||||
->searchable()
|
||||
->sortable()
|
||||
->weight('bold'),
|
||||
|
||||
TextColumn::make('type')
|
||||
->label(__('filament-short-url::default.pixel_type') ?? 'Provider')
|
||||
->label(__('filament-short-url::default.pixel_type'))
|
||||
->badge()
|
||||
->formatStateUsing(fn (string $state): string => match ($state) {
|
||||
'meta' => 'Meta',
|
||||
@@ -125,14 +124,14 @@ class ShortUrlPixelResource extends Resource
|
||||
}),
|
||||
|
||||
TextColumn::make('pixel_id')
|
||||
->label(__('filament-short-url::default.pixel_id_label') ?? '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') ?? 'Active')
|
||||
->label(__('filament-short-url::default.pixel_status_active'))
|
||||
->sortable(),
|
||||
])
|
||||
->filters([])
|
||||
@@ -149,7 +148,9 @@ class ShortUrlPixelResource extends Resource
|
||||
->iconButton()
|
||||
->tooltip(__('filament-short-url::default.action_delete')),
|
||||
])
|
||||
->bulkActions([]);
|
||||
->bulkActions([])
|
||||
->defaultSort('created_at', 'desc')
|
||||
->emptyState(view('filament-short-url::table.empty-state-pixels'));
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
|
||||
@@ -14,10 +14,13 @@ class ListShortUrlPixels extends ManageRecords
|
||||
{
|
||||
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'),
|
||||
->modalWidth('md')
|
||||
->modalAutofocus(false),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\ListShortUrls;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\ViewShortUrlLiveFeed;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\ViewShortUrlLogs;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\ViewShortUrlStats;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\ShortUrlForm;
|
||||
@@ -111,6 +112,7 @@ class ShortUrlResource extends Resource
|
||||
return [
|
||||
'index' => ListShortUrls::route('/'),
|
||||
'stats' => ViewShortUrlStats::route('/{record}/stats'),
|
||||
'stats.live' => ViewShortUrlLiveFeed::route('/{record}/stats/live'),
|
||||
'stats.logs' => ViewShortUrlLogs::route('/{record}/stats/logs'),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ use Filament\Actions\Action;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Forms;
|
||||
use Filament\Resources\Pages\ManageRecords;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Illuminate\Support\HtmlString;
|
||||
|
||||
class ListShortUrls extends ManageRecords
|
||||
@@ -20,13 +21,16 @@ class ListShortUrls extends ManageRecords
|
||||
{
|
||||
return [
|
||||
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;
|
||||
@@ -68,16 +72,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', []);
|
||||
@@ -392,4 +396,14 @@ HTML;
|
||||
ShortUrlGlobalOverview::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function getTabs(): array
|
||||
{
|
||||
return [
|
||||
'active' => Tab::make(__('filament-short-url::default.tab_active_links'))
|
||||
->modifyQueryUsing(fn ($query) => $query->where('is_archived', false)),
|
||||
'archived' => Tab::make(__('filament-short-url::default.tab_archived_links'))
|
||||
->modifyQueryUsing(fn ($query) => $query->where('is_archived', true)),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
};
|
||||
},
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
<?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))),
|
||||
Select::make('scope')
|
||||
->label(__('filament-short-url::default.api_key_scope'))
|
||||
->options([
|
||||
'links:read-write' => __('filament-short-url::default.api_key_scope_read_write'),
|
||||
'links:read-only' => __('filament-short-url::default.api_key_scope_read_only'),
|
||||
])
|
||||
->default('links:read-write')
|
||||
->required(),
|
||||
Select::make('rate_limit')
|
||||
->label(__('filament-short-url::default.api_key_rate_limit'))
|
||||
->options([
|
||||
'60' => __('filament-short-url::default.api_key_rate_limit_rpm', ['count' => 60]),
|
||||
'120' => __('filament-short-url::default.api_key_rate_limit_rpm', ['count' => 120]),
|
||||
'300' => __('filament-short-url::default.api_key_rate_limit_rpm', ['count' => 300]),
|
||||
'0' => __('filament-short-url::default.api_key_rate_limit_unlimited'),
|
||||
])
|
||||
->default('60')
|
||||
->required(),
|
||||
Toggle::make('is_active')
|
||||
->label(__('filament-short-url::default.active'))
|
||||
->default(true),
|
||||
])
|
||||
->columns(5)
|
||||
->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')),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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')),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||