Release v2.0.0: SVG QR Code Designer, visit counter buffering, Geo-IP and browser language tracking, dashboard redesign

This commit is contained in:
Bartłomiej Janczak
2026-06-03 13:48:24 +02:00
parent f3623df340
commit 26247fd94a
42 changed files with 2615 additions and 430 deletions

View File

@@ -20,15 +20,15 @@ A professional, high-performance **Short URL Manager** plugin for [Filament v5](
<p align="center">
<table align="center" style="border-collapse: collapse; border: none;">
<tr style="border: none;">
<td width="50%" style="border: none; padding: 5px;"><img src="art/1.png" alt="Dashboard Stats" style="border-radius: 8px;"></td>
<td width="50%" style="border: none; padding: 5px;"><img src="art/2.png" alt="Short URL Creation Form" style="border-radius: 8px;"></td>
<td width="50%" style="border: none; padding: 5px;"><img src="art/v2_1.png" alt="Dashboard Stats" style="border-radius: 8px;"></td>
<td width="50%" style="border: none; padding: 5px;"><img src="art/v2_2.png" alt="Short URL Creation Form" style="border-radius: 8px;"></td>
</tr>
<tr style="border: none;">
<td width="50%" style="border: none; padding: 5px;"><img src="art/3.png" alt="Targeting and Rules" style="border-radius: 8px;"></td>
<td width="50%" style="border: none; padding: 5px;"><img src="art/4.png" alt="Interactive Settings Panel" style="border-radius: 8px;"></td>
<td width="50%" style="border: none; padding: 5px;"><img src="art/v2_3.png" alt="Targeting and Rules" style="border-radius: 8px;"></td>
<td width="50%" style="border: none; padding: 5px;"><img src="art/v2_4.png" alt="Interactive Settings Panel" style="border-radius: 8px;"></td>
</tr>
<tr style="border: none;">
<td colspan="2" style="border: none; padding: 5px;"><img src="art/5.png" alt="Visit Logs Table" style="border-radius: 8px; width: 100%;"></td>
<td colspan="2" style="border: none; padding: 5px;"><img src="art/v2_5.png" alt="Visit Logs Table" style="border-radius: 8px; width: 100%;"></td>
</tr>
</table>
</p>
@@ -43,7 +43,9 @@ A professional, high-performance **Short URL Manager** plugin for [Filament v5](
- 📈 **Real-Time Statistics Dashboard** — Track visits in real-time with cached aggregate metrics (countries, devices, browsers, operating systems, referrers, traffic charts, and maps).
- 🛡️ **VPN, Proxy & Bot Filtering** — Exclude VPNs, proxies, Tor exit nodes, and automated bot clicks from your analytics to keep visitor statistics accurate.
- 🔍 **Google Safe Browsing Integration** — Automatically verify target URLs on creation/edit to block malicious, phishing, malware, or social engineering links.
- 🎨 **SVG QR Code Designer** — Built-in interactive design canvas in Filament to customize dot styles, margins, gradient coloring, and background transparency with instant SVG download.
- 🎨 **SVG QR Code Designer & Custom Logo Overlay** *(new in v2.0)* — Built-in interactive design canvas to customize dot styles, margins, gradient coloring, and background transparency. Upload custom brand logos with shape configuration (square/circle), margins, sizes, and auto-clear overlapping dots, with instant SVG/PNG download.
- 📊 **Dedicated QR Code Scan Tracking** *(new in v2.0)* — Separates direct visits from QR code scans in analytics. Dynamically appends tracking tags and showcases QR scans in its own badge within tables.
- 🌐 **Browser Language Analytics** *(new in v2.0)* — Detects client browser preferred language settings to showcase a "Top Languages" analytics breakdown in the link statistics dashboard.
-**Ultra-Fast Redirects** — Redirections resolve in milliseconds. Analytical tasks, event dispatching, and GA4 payloads are processed asynchronously via Laravel Queue jobs.
- 🎯 **Google Analytics 4 server-side tracking** — Native integration with the GA4 Measurement Protocol to bypass client-side AdBlockers completely.
- ⚙️ **Dual-way UTM Campaign Builder** — Built-in form builder synchronizes UTM parameters with your destination URLs in real-time (two-way binding).
@@ -410,6 +412,8 @@ The `short_url_daily_stats` table stores pre-aggregated daily summaries per shor
| `utm_source_stats` | JSON — visit counts by UTM source |
| `utm_medium_stats` | JSON — visit counts by UTM medium |
| `utm_campaign_stats` | JSON — visit counts by UTM campaign |
| `qr_visits_count` | Pre-aggregated daily QR code scans |
| `language_stats` | JSON — visit counts by browser language preferences |
The `getCachedStats()` model method **automatically merges** data from both tables: historical days come from `short_url_daily_stats`, while today's data comes directly from `short_url_visits` — completely transparent to the dashboard.
@@ -853,6 +857,15 @@ All migrations are compatible with **SQLite**, **MySQL**, and **PostgreSQL**:
## Changelog
### v2.0.0
- **Interactive QR Code Designer Branding Logo** — Upload custom brand logos inside the QR designer canvas in Filament. Configure logo sizing, margins, shapes (square/circle), and toggle dot backing removal to prevent dots overlapping with the logo.
- **Dedicated QR Code Scan Tracking** — Differentiates visitor clicks from physical QR code scans by dynamically appending source tags (`?source=qr`). Added a new database tracking column (`is_qr_scan` on visits, `qr_scans` on short URLs, and `qr_visits_count` on daily stats). Displays a dedicated scans counter badge in the Filament list table.
- **Browser Language Detection & Statistics** — Captures visitor browser preferred language headers (`browser_language` field) and aggregates them into the daily stats table. Displays a new "Top Languages" widget breakdown in the link statistics dashboard.
- **High-Traffic Performance Safeguards & Robust Rollbacks** — Atomic database transactions for buffered counter updates with fail-safe rollback that restores cache values in case of DB connection failures. Prevents N+1 queries by preloading request-wide counters in a single batch cache lookup.
- **Early Boot & Test Container Safety** — Safe settings caching that checks app container state before resolving `cache`, preventing early boot container exceptions during tests or artisan boots.
- **Atomic Duplicate Visit Caching** — Bypasses database exists checks for duplicate visitors by utilizing atomic `cache()->add` keys to prevent database bottlenecking.
- **Support for Empty Route Prefix (Root-Level URLs)** — Enhanced `getShortUrl()` to support empty route prefixes without generating double slashes (e.g. `domain.com/abc123` instead of `domain.com//abc123`). This allows clean root-level redirection domains. Added defensive slash trimming to standard prefixes.
### v1.7.0
- **Role-based Settings Access Control** — New `authorizeSettingsUsing(Closure)` method on the plugin to restrict who can access the Settings page. Supports any callable returning a `bool`. Also auto-detects a `manageSettings` method on a registered `ShortUrl` policy. The Settings button in the table header is hidden automatically when access is denied.

BIN
art/1.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

BIN
art/2.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

BIN
art/3.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 362 KiB

BIN
art/4.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 KiB

BIN
art/v2_1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

BIN
art/v2_2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

BIN
art/v2_3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 451 KiB

BIN
art/v2_4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

View File

Before

Width:  |  Height:  |  Size: 484 KiB

After

Width:  |  Height:  |  Size: 484 KiB

View File

@@ -78,6 +78,7 @@ return [
'operating_system_version' => true,
'referer_url' => true,
'device_type' => true,
'browser_language' => true,
],
],

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('short_urls', function (Blueprint $table) {
$table->string('qr_logo')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('short_urls', function (Blueprint $table) {
$table->dropColumn('qr_logo');
});
}
};

View File

@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('short_urls', function (Blueprint $table): void {
$table->integer('qr_scans')->default(0);
});
Schema::table('short_url_visits', function (Blueprint $table): void {
$table->boolean('is_qr_scan')->default(false)->index();
$table->string('browser_language', 10)->nullable()->index();
});
Schema::table('short_url_daily_stats', function (Blueprint $table): void {
$table->integer('qr_visits_count')->default(0);
$table->json('language_stats')->nullable();
});
}
public function down(): void
{
Schema::table('short_urls', function (Blueprint $table): void {
$table->dropColumn('qr_scans');
});
Schema::table('short_url_visits', function (Blueprint $table): void {
$table->dropIndex(['is_qr_scan']);
$table->dropColumn('is_qr_scan');
$table->dropIndex(['browser_language']);
$table->dropColumn('browser_language');
});
Schema::table('short_url_daily_stats', function (Blueprint $table): void {
$table->dropColumn(['qr_visits_count', 'language_stats']);
});
}
};

View File

@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('short_urls', function (Blueprint $table): void {
$table->boolean('track_browser_language')->default(true);
});
}
public function down(): void
{
Schema::table('short_urls', function (Blueprint $table): void {
$table->dropColumn('track_browser_language');
});
}
};

View File

@@ -58,6 +58,7 @@ return [
'track_os_version' => 'Track OS Version',
'track_device_type' => 'Track Device Type (desktop/mobile/tablet)',
'track_referer' => 'Track Referer URL',
'track_browser_language' => 'Track Browser Language',
// QR Design Fields
'qr_size' => 'QR Code Size (px)',
@@ -98,8 +99,26 @@ return [
'qr_option_classy' => 'Classy',
'qr_option_classy_rounded' => 'Classy Rounded',
'qr_option_extra_rounded' => 'Extra Rounded',
'qr_option_dot' => 'Dot',
'qr_chart_visits_label' => 'Visits',
'qr_label_dots_background' => 'Dots & Background',
'qr_label_custom_eye_config' => 'Custom Eye Config',
'qr_label_logo_overlay' => 'Logo & Icon Overlay',
'qr_label_drag_drop_upload' => 'Drag & drop or click to upload logo',
'qr_label_upload_supports' => 'Supports PNG, JPG, SVG up to 10MB',
'qr_label_drag_drop_replace' => 'Drag & drop or click to replace',
'qr_label_uploading_logo' => 'Uploading logo...',
'qr_logo_upload_parse_error' => 'Error parsing server response!',
'qr_logo_upload_error' => 'Error during logo file upload!',
'qr_logo_upload_connection_error' => 'Connection error during logo file upload!',
'qr_label_remove_logo' => 'Remove logo',
'qr_label_logo_shape' => 'Logo Shape',
'qr_label_logo_size' => 'Logo Size',
'qr_label_logo_margin' => 'Logo Margin',
'qr_label_clear_dots' => 'Clear Dots Behind Logo',
'qr_label_live_preview' => 'Live Preview',
'qr_option_circle' => 'Circle',
'qr_label_png' => 'PNG',
'qr_label_svg' => 'SVG',
// Table Columns
'col_short_url' => 'Short URL',
@@ -120,6 +139,15 @@ return [
'share_description' => 'Share this short link via:',
'share_copy' => 'Copy',
'share_copied' => 'Short link copied to clipboard!',
'qr_modal_helper' => 'Scan, copy, or download your custom QR code.',
'qr_download_svg' => 'Download SVG',
'qr_download_png' => 'Download PNG',
'success_modal_title' => 'Your link & QR code are ready!',
'success_modal_subtitle' => 'Time to get some clicks 🎉',
'success_modal_helper' => 'Copy and share manually or choose a platform.',
'open_link' => 'Open link',
'close_button' => 'Close',
'dont_show_again' => "Don't show sharing options after creating a link",
// Stats Page
'stats_title' => 'Statistics',
@@ -128,6 +156,7 @@ return [
'stats_card_total' => 'Total Visits',
'stats_card_unique' => 'Unique Visitors',
'stats_card_today' => 'Today',
'stats_card_today_clicks' => 'Clicks Today',
'stats_card_week' => 'This Week',
'stats_card_month' => 'This Month',
'stats_chart_title' => 'Visits — Last 30 Days',
@@ -320,6 +349,7 @@ return [
'settings_track_os_version_default' => 'Default OS Version Tracking',
'settings_track_referer_default' => 'Default Referer URL Tracking',
'settings_track_device_type_default' => 'Default Device Type Tracking',
'settings_track_browser_language_default' => 'Default Browser Language Tracking',
// QR Defaults Tab
'settings_tab_qr' => 'QR Defaults',
@@ -408,12 +438,22 @@ return [
'settings_safe_browsing_test_error' => 'API connection error.',
// World Map Widget
'world_map_title' => 'Visitor World Map',
'world_map_total_clicks' => 'total clicks',
'world_map_countries' => 'countries',
'world_map_fewer' => 'Fewer',
'world_map_more' => 'More',
'world_map_title' => 'Visitor World Map',
'world_map_total_clicks' => 'total clicks',
'world_map_countries' => 'countries',
'world_map_fewer' => 'Fewer',
'world_map_more' => 'More',
'world_map_top_countries' => 'Top Countries',
'world_map_no_data' => 'No geographic data yet.',
'world_map_no_data_sub' => 'Enable Geo-IP detection to start recording visitor locations.',
'world_map_no_data' => 'No geographic data yet.',
'world_map_no_data_sub' => 'Enable Geo-IP detection to start recording visitor locations.',
'stats_card_qr_scans' => 'QR Code Scans',
'stats_breakdown_languages' => 'Top Languages',
'stats_no_language_data' => 'No language data.',
'badge_clicks' => 'clicks',
'badge_unique' => 'unique',
'badge_qr' => 'QR',
'badge_qr_scans' => 'QR scans',
'badge_expires' => 'Expires: :date',
'badge_no_expiry' => 'No expiry',
'badge_redirect' => ':code redirect',
];

View File

@@ -58,6 +58,7 @@ return [
'track_os_version' => 'Śledź wersję systemu operacyjnego',
'track_device_type' => 'Śledź typ urządzenia (desktop/mobile/tablet)',
'track_referer' => 'Śledź referer URL',
'track_browser_language' => 'Śledź język przeglądarki',
// QR Design Fields
'qr_size' => 'Rozmiar kodu QR (px)',
@@ -98,8 +99,26 @@ return [
'qr_option_classy' => 'Elegancki',
'qr_option_classy_rounded' => 'Zaokrąglony elegancki',
'qr_option_extra_rounded' => 'Bardzo zaokrąglony',
'qr_option_dot' => 'Kropka',
'qr_chart_visits_label' => 'Wizyty',
'qr_label_dots_background' => 'Punkty i tło',
'qr_label_custom_eye_config' => 'Własny styl oczu',
'qr_label_logo_overlay' => 'Nakładka logo',
'qr_label_drag_drop_upload' => 'Przeciągnij i upuść lub kliknij, aby wgrać logo',
'qr_label_upload_supports' => 'Obsługuje PNG, JPG, SVG do 10MB',
'qr_label_drag_drop_replace' => 'Przeciągnij i upuść lub kliknij, aby zastąpić logo',
'qr_label_uploading_logo' => 'Wgrywanie logo...',
'qr_logo_upload_parse_error' => 'Błąd przetwarzania odpowiedzi serwera!',
'qr_logo_upload_error' => 'Błąd podczas wgrywania pliku logo!',
'qr_logo_upload_connection_error' => 'Błąd połączenia podczas wgrywania pliku logo!',
'qr_label_remove_logo' => 'Usuń logo',
'qr_label_logo_shape' => 'Kształt logo',
'qr_label_logo_size' => 'Rozmiar logo',
'qr_label_logo_margin' => 'Margines logo',
'qr_label_clear_dots' => 'Usuń punkty pod logo',
'qr_label_live_preview' => 'Podgląd na żywo',
'qr_option_circle' => 'Koło',
'qr_label_png' => 'PNG',
'qr_label_svg' => 'SVG',
// Table Columns
'col_short_url' => 'Krótki URL',
@@ -120,6 +139,15 @@ return [
'share_description' => 'Udostępnij ten krótki link przez:',
'share_copy' => 'Skopiuj',
'share_copied' => 'Krótki link skopiowany do schowka!',
'qr_modal_helper' => 'Zeskanuj, skopiuj lub pobierz swój spersonalizowany kod QR.',
'qr_download_svg' => 'Pobierz SVG',
'qr_download_png' => 'Pobierz PNG',
'success_modal_title' => 'Twój link i kod QR są gotowe!',
'success_modal_subtitle' => 'Czas na kliknięcia 🎉',
'success_modal_helper' => 'Skopiuj i udostępnij ręcznie lub wybierz platformę.',
'open_link' => 'Otwórz link',
'close_button' => 'Zamknij',
'dont_show_again' => "Nie pokazuj opcji udostępniania po utworzeniu linku",
// Stats Page
'stats_title' => 'Statystyki',
@@ -128,6 +156,7 @@ return [
'stats_card_total' => 'Wszystkie wizyty',
'stats_card_unique' => 'Unikalni goście',
'stats_card_today' => 'Dzisiaj',
'stats_card_today_clicks' => 'Kliknięcia dzisiaj',
'stats_card_week' => 'W tym tygodniu',
'stats_card_month' => 'W tym miesiącu',
'stats_chart_title' => 'Wizyty — Ostatnie 30 dni',
@@ -321,6 +350,7 @@ return [
'settings_track_os_version_default' => 'Domyślne śledzenie wersji systemu operacyjnego',
'settings_track_referer_default' => 'Domyślne śledzenie adresu referera',
'settings_track_device_type_default' => 'Domyślne śledzenie typu urządzenia',
'settings_track_browser_language_default' => 'Domyślne śledzenie języka przeglądarki',
// QR Defaults Tab
'settings_tab_qr' => 'Domyślne QR',
@@ -409,12 +439,22 @@ return [
'settings_safe_browsing_test_error' => 'Błąd połączenia z API.',
// World Map Widget
'world_map_title' => 'Mapa odwiedzin',
'world_map_total_clicks' => 'wszystkich kliknięć',
'world_map_countries' => 'krajów',
'world_map_fewer' => 'Mniej',
'world_map_more' => 'Więcej',
'world_map_title' => 'Mapa odwiedzin',
'world_map_total_clicks' => 'wszystkich kliknięć',
'world_map_countries' => 'krajów',
'world_map_fewer' => 'Mniej',
'world_map_more' => 'Więcej',
'world_map_top_countries' => 'Najpopularniejsze kraje',
'world_map_no_data' => 'Brak danych geograficznych.',
'world_map_no_data_sub' => 'Włącz wykrywanie Geo-IP, aby zacząć rejestrować lokalizacje odwiedzających.',
'world_map_no_data' => 'Brak danych geograficznych.',
'world_map_no_data_sub' => 'Włącz wykrywanie Geo-IP, aby zacząć rejestrować lokalizacje odwiedzających.',
'stats_card_qr_scans' => 'Zeskanowania QR',
'stats_breakdown_languages' => 'Najpopularniejsze języki',
'stats_no_language_data' => 'Brak danych o językach.',
'badge_clicks' => 'kliknięć',
'badge_unique' => 'unikalnych',
'badge_qr' => 'QR',
'badge_qr_scans' => 'odczytów QR',
'badge_expires' => 'Wygasa: :date',
'badge_no_expiry' => 'Bez wygasania',
'badge_redirect' => 'Przekierowanie :code',
];

File diff suppressed because it is too large Load Diff

View File

@@ -37,22 +37,17 @@
'dateTo' => $dateTo,
], key('stats-overview-' . $dateFrom . '-' . $dateTo))
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div class="lg:col-span-2">
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsChart::class, [
'record' => $record,
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
], key('stats-chart-' . $dateFrom . '-' . $dateTo))
</div>
<div>
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsRightBreakdown::class, [
'record' => $record,
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
], key('stats-right-breakdown-' . $dateFrom . '-' . $dateTo))
</div>
</div>
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsChart::class, [
'record' => $record,
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
], key('stats-chart-' . $dateFrom . '-' . $dateTo))
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsRightBreakdown::class, [
'record' => $record,
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
], key('stats-right-breakdown-' . $dateFrom . '-' . $dateTo))
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlWorldMapWidget::class, [
'record' => $record,

View File

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

View File

@@ -3,6 +3,8 @@
use Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlApiController;
use Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlRedirectController;
use Bjanczak\FilamentShortUrl\Http\Middleware\AuthenticateShortUrlApi;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Route;
Route::match(
@@ -21,3 +23,16 @@ Route::prefix('api/short-url')
Route::post('links', [ShortUrlApiController::class, 'store']);
Route::delete('links/{id}', [ShortUrlApiController::class, 'destroy']);
});
Route::post('admin/short-url/upload-logo', [ShortUrlApiController::class, 'uploadLogo'])
->name('short-url.upload-logo')
->middleware(['web']);
Route::post('admin/short-url/log-debug', function (Request $request) {
Log::info('QR DESIGNER JS DEBUG: '.json_encode($request->all()));
return response()->json(['status' => 'ok']);
})->middleware(['web']);
Route::get('short-url/logo/{filename}', [ShortUrlApiController::class, 'serveLogo'])
->name('short-url.logo');

View File

@@ -7,6 +7,7 @@ use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
class AggregateAndPruneVisitsCommand extends Command
{
@@ -65,12 +66,18 @@ class AggregateAndPruneVisitsCommand extends Command
'utm_source_stats' => [],
'utm_medium_stats' => [],
'utm_campaign_stats' => [],
'qr_scans' => 0,
'language_stats' => [],
];
}
$s = &$statsByUrl[$urlId];
$s['total']++;
if ($visit->is_qr_scan) {
$s['qr_scans']++;
}
if ($visit->ip_hash) {
$s['ip_hashes'][$visit->ip_hash] = true;
}
@@ -88,6 +95,7 @@ class AggregateAndPruneVisitsCommand extends Command
$inc($visit->utm_source, 'utm_source_stats');
$inc($visit->utm_medium, 'utm_medium_stats');
$inc($visit->utm_campaign, 'utm_campaign_stats');
$inc($visit->browser_language, 'language_stats');
if ($visit->city) {
$cityKey = "{$visit->city} ({$visit->country_code})";
@@ -116,6 +124,8 @@ class AggregateAndPruneVisitsCommand extends Command
'utm_source_stats' => $s['utm_source_stats'],
'utm_medium_stats' => $s['utm_medium_stats'],
'utm_campaign_stats' => $s['utm_campaign_stats'],
'qr_visits_count' => $s['qr_scans'],
'language_stats' => $s['language_stats'],
]);
}
});
@@ -134,6 +144,26 @@ class AggregateAndPruneVisitsCommand extends Command
$this->info("Successfully pruned {$deleted} raw visit records older than {$retentionDays} days.");
}
// 3. Prune old temporary logo files (older than 24 hours)
$disk = Storage::disk('public');
if ($disk->exists('short-urls/tmp')) {
$files = $disk->files('short-urls/tmp');
$now = time();
$prunedCount = 0;
foreach ($files as $file) {
$lastModified = $disk->lastModified($file);
if (($now - $lastModified) > 86400) { // 86400 seconds = 24 hours
$disk->delete($file);
$prunedCount++;
}
}
if ($prunedCount > 0) {
$this->info("Successfully pruned {$prunedCount} temporary logo files older than 24 hours.");
}
}
return 0;
}
}

View File

@@ -44,24 +44,85 @@ class SyncBufferedCountersCommand extends Command
$dirtyIds = array_unique(array_filter($dirtyIds));
$processed = 0;
$updatesToMake = [];
foreach ($dirtyIds as $id) {
$totalKey = "{$prefix}total:{$id}";
$uniqueKey = "{$prefix}unique:{$id}";
$qrKey = "{$prefix}qr:{$id}";
$totalDelta = (int) Cache::pull($totalKey, 0);
$uniqueDelta = (int) Cache::pull($uniqueKey, 0);
$qrDelta = (int) Cache::pull($qrKey, 0);
if ($totalDelta > 0 || $uniqueDelta > 0) {
// Perform a single atomic update query for this URL
ShortUrl::where('id', $id)->update([
'total_visits' => DB::raw("total_visits + {$totalDelta}"),
'unique_visits' => DB::raw("unique_visits + {$uniqueDelta}"),
]);
$processed++;
if ($totalDelta > 0 || $uniqueDelta > 0 || $qrDelta > 0) {
$updatesToMake[$id] = [
'total' => $totalDelta,
'unique' => $uniqueDelta,
'qr' => $qrDelta,
];
}
}
if (empty($updatesToMake)) {
$this->info('No buffered counters to synchronize.');
return 0;
}
try {
DB::transaction(function () use ($updatesToMake, &$processed) {
$shortUrls = ShortUrl::whereIn('id', array_keys($updatesToMake))->get(['id', 'url_key']);
foreach ($updatesToMake as $id => $deltas) {
ShortUrl::where('id', $id)->update([
'total_visits' => DB::raw("total_visits + {$deltas['total']}"),
'unique_visits' => DB::raw("unique_visits + {$deltas['unique']}"),
'qr_scans' => DB::raw("qr_scans + {$deltas['qr']}"),
]);
$processed++;
}
foreach ($shortUrls as $url) {
Cache::forget("filament-short-url:{$url->url_key}");
}
});
} catch (\Throwable $e) {
// Restore pulled values in cache so no clicks are lost
foreach ($updatesToMake as $id => $deltas) {
$totalKey = "{$prefix}total:{$id}";
$uniqueKey = "{$prefix}unique:{$id}";
$qrKey = "{$prefix}qr:{$id}";
if ($deltas['total'] > 0) {
Cache::increment($totalKey, $deltas['total']);
}
if ($deltas['unique'] > 0) {
Cache::increment($uniqueKey, $deltas['unique']);
}
if ($deltas['qr'] > 0) {
Cache::increment($qrKey, $deltas['qr']);
}
}
// Put the IDs back into the dirty list
if (Cache::getDefaultDriver() === 'redis' && class_exists(Redis::class)) {
Redis::sadd($dirtyKey, ...array_keys($updatesToMake));
} else {
$lock = Cache::lock("{$prefix}dirty_ids_lock", 2);
$lock->get(function () use ($prefix, $updatesToMake) {
$cachedDirty = Cache::get("{$prefix}dirty_ids", []);
if (! is_array($cachedDirty)) {
$cachedDirty = [];
}
$merged = array_unique(array_merge($cachedDirty, array_keys($updatesToMake)));
Cache::forever("{$prefix}dirty_ids", $merged);
});
}
throw $e;
}
$this->info("Successfully synchronized counters for {$processed} short URLs.");
return 0;

View File

@@ -40,34 +40,143 @@ class ListShortUrls extends ManageRecords
return $data;
})
->after(function (ShortUrl $record): void {
$this->mountAction('shareAfterCreate', [
'shortUrl' => $record->getShortUrl(),
'urlKey' => $record->url_key,
'destHost' => parse_url($record->destination_url, PHP_URL_HOST) ?? '',
]);
$id = json_encode($record->id);
$shortUrl = json_encode($record->getShortUrl());
$urlKey = json_encode($record->url_key);
$destHost = json_encode(parse_url($record->destination_url, PHP_URL_HOST) ?? '');
$this->js("
setTimeout(() => {
if (localStorage.getItem('fsu:hide-share-modal') !== '1') {
\$wire.mountAction('shareAfterCreate', {
id: {$id},
shortUrl: {$shortUrl},
urlKey: {$urlKey},
destHost: {$destHost}
});
}
}, 200);
");
}),
// Hidden action — opened programmatically after create
Action::make('shareAfterCreate')
->label('')
->hidden()
->extraAttributes(['class' => 'hidden'])
->modalHeading('')
->modalWidth('lg')
->modalSubmitAction(false)
->modalCancelAction(false)
->form(function (array $arguments): array {
$recordId = $arguments['id'] ?? null;
$shortUrl = $arguments['shortUrl'] ?? '';
$qrTargetUrl = $shortUrl ? ($shortUrl.'?source=qr') : '';
$destHost = $arguments['destHost'] ?? '';
$urlKey = $arguments['urlKey'] ?? 'qr-code';
$encoded = urlencode($shortUrl);
$eid = 'fsu_'.substr(md5($shortUrl), 0, 8);
$successTitle = __('filament-short-url::default.success_modal_title') ?? 'Your link & QR code are ready!';
$successSubtitle = __('filament-short-url::default.success_modal_subtitle') ?? 'Time to get some clicks 🎉';
$successHelper = __('filament-short-url::default.success_modal_helper') ?? 'Copy and share manually or choose a platform.';
$downloadSvgText = __('filament-short-url::default.qr_download_svg') ?? 'Download SVG';
$downloadPngText = __('filament-short-url::default.qr_download_png') ?? 'Download PNG';
$closeButtonText = __('filament-short-url::default.close_button') ?? 'Close';
$copyLinkText = __('filament-short-url::default.action_copy') ?? 'Copy link';
$qrCodeText = __('filament-short-url::default.action_qr') ?? 'QR Code';
$openLinkText = __('filament-short-url::default.open_link') ?? 'Open link';
$dontShowAgainText = __('filament-short-url::default.dont_show_again') ?? "Don't show sharing options after creating a link";
$record = $recordId ? ShortUrl::find($recordId) : null;
$qrDefaults = $record ? $record->getQrOptions() : config('filament-short-url.qr_defaults', []);
$isGrad = ($qrDefaults['gradient_enabled'] ?? false) || (($qrDefaults['color_mode'] ?? '') === 'gradient');
$dotStyle = $qrDefaults['dot_style'] ?? 'square';
$fgColor = $qrDefaults['foreground_color'] ?? '#000000';
$bgColor = ($qrDefaults['bg_transparent'] ?? false) ? 'rgba(0,0,0,0)' : ($qrDefaults['background_color'] ?? '#ffffff');
$dotsOptions = $isGrad ? [
'type' => $dotStyle,
'gradient' => [
'type' => $qrDefaults['gradient_type'] ?? 'linear',
'colorStops' => [
['offset' => 0, 'color' => $qrDefaults['gradient_from'] ?? '#4f46e5'],
['offset' => 1, 'color' => $qrDefaults['gradient_to'] ?? '#06b6d4'],
],
],
] : [
'type' => $dotStyle,
'color' => $fgColor,
];
$mainColor = $isGrad ? ($qrDefaults['gradient_from'] ?? '#4f46e5') : $fgColor;
$eyeConfigEnabled = $qrDefaults['eye_config_enabled'] ?? false;
$eyeSquareStyle = $qrDefaults['eye_square_style'] ?? ($dotStyle === 'dots' ? 'dot' : 'square');
$eyeDotStyle = $qrDefaults['eye_dot_style'] ?? ($dotStyle === 'dots' ? 'dot' : 'square');
$eyeColor = $qrDefaults['eye_color'] ?? $mainColor;
$cornersSquareOptions = $eyeConfigEnabled ? [
'type' => $eyeSquareStyle,
'color' => $eyeColor,
] : [
'type' => $dotStyle === 'dots' ? 'dot' : 'square',
'color' => $mainColor,
];
$cornersDotOptions = $eyeConfigEnabled ? [
'type' => $eyeDotStyle,
'color' => $eyeColor,
] : [
'type' => $dotStyle === 'dots' ? 'dot' : 'square',
'color' => $mainColor,
];
$logo = $qrDefaults['logo'] ?? null;
$logoSize = $qrDefaults['logo_size'] ?? 0.3;
$logoMargin = $qrDefaults['logo_margin'] ?? 9;
$logoHideBackground = $qrDefaults['logo_hide_background'] ?? true;
$logoShape = $qrDefaults['logo_shape'] ?? 'square';
$qrOptionsJson = json_encode([
'type' => 'svg',
'width' => 200,
'height' => 200,
'margin' => $qrDefaults['margin'] ?? 1,
'dotsOptions' => $dotsOptions,
'backgroundOptions' => ['color' => $bgColor],
'cornersSquareOptions' => $cornersSquareOptions,
'cornersDotOptions' => $cornersDotOptions,
'image' => $logo ?: null,
'imageOptions' => [
'crossOrigin' => 'anonymous',
'hideBackgroundDots' => $logoHideBackground,
'imageSize' => $logoSize,
'margin' => $logoMargin,
'logoShape' => $logoShape,
],
'qrOptions' => ['errorCorrectionLevel' => $logo ? 'H' : 'M'],
]);
$escapedQrOptions = e($qrOptionsJson);
$html = <<<HTML
<div x-data x-init="if(localStorage.getItem('fsu:hide-share-modal')==='1'){ \$nextTick(()=>\$wire.unmountAction()) }">
<!-- Close Button (x) -->
<button type="button" x-on:click="event.preventDefault(); event.stopPropagation(); const f=\$el.closest('.fi-modal-window'); const m=f?(f.getAttribute('x-on:keydown.window.escape')||'').match(/'(fi-[^']*)'/):null; if(m){\$dispatch('close-modal',{id:m[1]})}else{\$wire.unmountAction()}"
style="position:absolute;top:16px;right:16px;z-index:50;width:28px;height:28px;border-radius:50%;border:none;background:#f3f4f6;cursor:pointer;display:flex;align-items:center;justify-content:center;color:#6b7280;transition:color .15s,background-color .15s"
onmouseover="this.style.color='#374151';this.style.backgroundColor='#e5e7eb'"
onmouseout="this.style.color='#6b7280';this.style.backgroundColor='#f3f4f6'"
title="{$closeButtonText}">
<svg style="width:16px;height:16px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<div style="text-align:center;padding:4px 0 8px">
<p style="font-size:13px;color:#6b7280;margin:0 0 6px">Time to get some clicks 🎉</p>
<h2 style="font-size:23px;font-weight:700;color:#111827;margin:0 0 6px;line-height:1.25">Your link &amp; QR code are ready!</h2>
<p style="font-size:13px;color:#9ca3af;margin:0">Copy and share manually or choose a platform.</p>
<p style="font-size:13px;color:#6b7280;margin:0 0 6px">{$successSubtitle}</p>
<h2 style="font-size:23px;font-weight:700;color:#111827;margin:0 0 6px;line-height:1.25">{$successTitle}</h2>
<p style="font-size:13px;color:#9ca3af;margin:0">{$successHelper}</p>
</div>
<!-- URL pill -->
@@ -77,8 +186,10 @@ class ListShortUrls extends ManageRecords
</div>
<span style="flex:1;font-size:14px;font-weight:600;color:#1d4ed8;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{$shortUrl}</span>
<div style="display:flex;align-items:center;gap:4px;flex-shrink:0">
<button id="{$eid}_copy"
<button id="{$eid}_copy" type="button"
onclick="
event.preventDefault();
event.stopPropagation();
const u='{$shortUrl}';
if(navigator.clipboard&&window.isSecureContext){navigator.clipboard.writeText(u);}
else{const t=document.createElement('textarea');t.value=u;t.style.cssText='position:fixed;left:-9999px';document.body.appendChild(t);t.select();document.execCommand('copy');t.remove();}
@@ -88,17 +199,150 @@ class ListShortUrls extends ManageRecords
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="Copy link">
onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'" title="{$copyLinkText}">
<svg style="width:16px;height:16px;color:#6b7280" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path stroke-linecap="round" stroke-linejoin="round" d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</button>
<button id="{$eid}_qr_btn" type="button" data-qr-options="{$escapedQrOptions}"
onclick="
event.preventDefault();
event.stopPropagation();
const container = document.getElementById('{$eid}_qr_container');
const canvas = document.getElementById('{$eid}_qr_canvas');
if (container.style.display === 'none') {
container.style.display = 'block';
const loadScript = () => {
return new Promise((resolve) => {
if (window.QRCodeStyling) return resolve();
const s = document.createElement('script');
s.src = 'https://unpkg.com/qr-code-styling@1.6.0-rc.1/lib/qr-code-styling.js';
s.onload = () => resolve();
s.onerror = () => {
const s2 = document.createElement('script');
s2.src = 'https://cdn.jsdelivr.net/npm/qr-code-styling@1.6.0-rc.1/lib/qr-code-styling.js';
s2.onload = () => resolve();
document.head.appendChild(s2);
};
document.head.appendChild(s);
});
};
loadScript().then(() => {
if (!canvas.innerHTML) {
const opts = JSON.parse(this.getAttribute('data-qr-options'));
opts.data = '{$qrTargetUrl}';
const fixSvg = () => {
const svg = canvas.querySelector('svg');
if (svg) {
const w = svg.getAttribute('width') || opts.width || 200;
const h = svg.getAttribute('height') || opts.height || 200;
svg.setAttribute('viewBox', '0 0 ' + w + ' ' + h);
svg.style.width = '100%';
svg.style.height = '100%';
}
};
if (opts.image && opts.imageOptions) {
const img = new Image();
img.onload = () => {
const cv = document.createElement('canvas');
cv.width = 1200;
cv.height = 1200;
const cx = cv.getContext('2d');
cx.imageSmoothingEnabled = true;
cx.imageSmoothingQuality = 'high';
const isCircle = opts.imageOptions.logoShape === 'circle';
const targetDim = 1200 - (parseFloat(opts.imageOptions.margin || 0) * 20);
cx.save();
if (isCircle) {
cx.beginPath();
cx.arc(600, 600, targetDim / 2, 0, 2 * Math.PI);
cx.clip();
const scale = Math.max(targetDim / img.width, targetDim / img.height);
const w = img.width * scale;
const h = img.height * scale;
const x = (1200 - w) / 2;
const y = (1200 - h) / 2;
cx.drawImage(img, x, y, w, h);
} else {
cx.beginPath();
const offset = (1200 - targetDim) / 2;
const radius = 144 * (targetDim / 1200);
if (typeof cx.roundRect === 'function') {
cx.roundRect(offset, offset, targetDim, targetDim, radius);
} else {
cx.moveTo(offset + radius, offset);
cx.lineTo(offset + targetDim - radius, offset);
cx.quadraticCurveTo(offset + targetDim, offset, offset + targetDim, offset + radius);
cx.lineTo(offset + targetDim, offset + targetDim - radius);
cx.quadraticCurveTo(offset + targetDim, offset + targetDim, offset + targetDim - radius, offset + targetDim);
cx.lineTo(offset + radius, offset + targetDim);
cx.quadraticCurveTo(offset, offset + targetDim, offset, offset + targetDim - radius);
cx.lineTo(offset, offset + radius);
cx.quadraticCurveTo(offset, offset, offset + radius, offset);
cx.closePath();
}
cx.clip();
const scale = Math.max(targetDim / img.width, targetDim / img.height);
const w = img.width * scale;
const h = img.height * scale;
const x = (1200 - w) / 2;
const y = (1200 - h) / 2;
cx.drawImage(img, x, y, w, h);
}
cx.restore();
opts.image = cv.toDataURL('image/png');
opts.imageOptions.margin = 0;
window['qr_{$eid}'] = new window.QRCodeStyling(opts);
window['qr_{$eid}'].append(canvas);
fixSvg();
};
img.src = opts.image;
} else {
window['qr_{$eid}'] = new window.QRCodeStyling(opts);
window['qr_{$eid}'].append(canvas);
fixSvg();
}
}
});
} else {
container.style.display = 'none';
}
"
style="width:30px;height:30px;border-radius:7px;border:1px solid #e5e7eb;background:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .15s"
onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'" title="{$qrCodeText}">
<svg style="width:16px;height:16px;color:#6b7280" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0 1 3.75 9.375v-4.5ZM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 0 1-1.125-1.125v-4.5ZM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0 1 13.5 9.375v-4.5Z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12h.008v.008H15V12Zm0 3h.008v.008H15V15Zm0 3h.008v.008H15V18Zm3-3h.008v.008H18V15Zm0 3h.008v.008H18V18Zm3-3h.008v.008H21V15Zm0 3h.008v.008H21V18Zm0-6h.008v.008H21V12Zm-3 0h.008v.008H18V12Z" />
</svg>
</button>
<a href="{$shortUrl}" target="_blank" rel="noopener noreferrer"
style="width:30px;height:30px;border-radius:7px;border:1px solid #e5e7eb;background:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;text-decoration:none;transition:background .15s"
onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'" title="Open link">
onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'" title="{$openLinkText}">
<svg style="width:15px;height:15px;color:#6b7280" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
</a>
</div>
</div>
<!-- QR Code Container (toggled) -->
<div id="{$eid}_qr_container" style="display:none;margin:16px 0 0;text-align:center;background:#f9fafb;border:1px solid #e5e7eb;border-radius:12px;padding:16px">
<div style="display:flex;justify-content:center;margin-bottom:12px">
<div id="{$eid}_qr_canvas" style="background:#fff;padding:8px;border-radius:8px;box-shadow:0 1px 3px rgba(0,0,0,0.05)"></div>
</div>
<div style="display:flex;justify-content:center;gap:8px">
<button type="button" onclick="event.preventDefault();event.stopPropagation();window['qr_{$eid}']?.download({ name: '{$urlKey}-qr', extension: 'svg' })" style="font-size:12px;font-weight:600;color:#374151;border:1.5px solid #e5e7eb;background:#fff;padding:6px 12px;border-radius:8px;cursor:pointer;transition:background .15s" onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'">{$downloadSvgText}</button>
<button type="button" onclick="event.preventDefault();event.stopPropagation();window['qr_{$eid}']?.download({ name: '{$urlKey}-qr', extension: 'png' })" style="font-size:12px;font-weight:600;color:#374151;border:1.5px solid #e5e7eb;background:#fff;padding:6px 12px;border-radius:8px;cursor:pointer;transition:background .15s" onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'">{$downloadPngText}</button>
</div>
</div>
<!-- Social sharing -->
<div style="display:flex;align-items:center;justify-content:center;gap:8px;margin:18px 0 0;flex-wrap:wrap">
<a href="mailto:?body={$encoded}" target="_blank" rel="noopener" title="Email"
@@ -128,13 +372,14 @@ class ListShortUrls extends ManageRecords
</div>
<!-- Don't show again -->
<div style="display:flex;align-items:center;gap:8px;margin:20px 0 2px;justify-content:center">
<input type="checkbox" id="{$eid}_hide"
onchange="localStorage.setItem('fsu:hide-share-modal', this.checked ? '1' : '0')"
style="width:15px;height:15px;cursor:pointer;accent-color:#374151;flex-shrink:0">
<label for="{$eid}_hide" style="font-size:13px;color:#6b7280;cursor:pointer;user-select:none">
Don't show me the sharing options after creating a link
</label>
<div style="display:flex;justify-content:center;margin:20px 0 2px">
<button type="button"
x-on:click="localStorage.setItem('fsu:hide-share-modal', '1'); const f=\$el.closest('.fi-modal-window'); const m=f?(f.getAttribute('x-on:keydown.window.escape')||'').match(/'(fi-[^']*)'/):null; if(m){\$dispatch('close-modal',{id:m[1]})}else{\$wire.unmountAction()}"
style="font-size:12px;color:#9ca3af;background:none;border:none;cursor:pointer;text-decoration:underline;transition:color .15s"
onmouseover="this.style.color='#4b5563'"
onmouseout="this.style.color='#9ca3af'">
{$dontShowAgainText}
</button>
</div>
</div>
@@ -142,7 +387,7 @@ HTML;
return [
Forms\Components\Placeholder::make('share_modal_content')
->label('')
->hiddenLabel()
->content(new HtmlString($html)),
];
}),

View File

@@ -94,6 +94,7 @@ class ShortUrlSettingsPage extends Page implements HasForms
'tracking_fields_operating_system_version' => $mgr->get('tracking_fields_operating_system_version', true),
'tracking_fields_referer_url' => $mgr->get('tracking_fields_referer_url', true),
'tracking_fields_device_type' => $mgr->get('tracking_fields_device_type', true),
'tracking_fields_browser_language' => $mgr->get('tracking_fields_browser_language', true),
'qr_size' => $mgr->get('qr_size', 300),
'qr_margin' => $mgr->get('qr_margin', 1),
'qr_dot_style' => $mgr->get('qr_dot_style', 'square'),
@@ -653,6 +654,11 @@ class ShortUrlSettingsPage extends Page implements HasForms
->inline(false)
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
Toggle::make('tracking_fields_browser_language')
->label(__('filament-short-url::default.settings_track_browser_language_default'))
->inline(false)
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
Toggle::make('tracking_fields_referer_url')
->label(__('filament-short-url::default.settings_track_referer_default'))
->inline(false)

View File

@@ -2,6 +2,7 @@
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas;
use Bjanczak\FilamentShortUrl\Services\SafeBrowsingService;
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
use Filament\Actions\Action;
use Filament\Forms\Components\DateTimePicker;
@@ -11,6 +12,7 @@ use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Components\ViewField;
use Filament\Schemas\Components\Group;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs;
use Filament\Schemas\Components\Tabs\Tab;
@@ -47,7 +49,7 @@ class ShortUrlForm
->maxLength(2048)
->rules([
fn (): \Closure => function (string $attribute, $value, \Closure $fail) {
$safeBrowsing = app(\Bjanczak\FilamentShortUrl\Services\SafeBrowsingService::class);
$safeBrowsing = app(SafeBrowsingService::class);
if (! $safeBrowsing->isSafe($value)) {
$fail(__('filament-short-url::default.safe_browsing_error') ?? 'This URL has been flagged by Google Safe Browsing as unsafe.');
}
@@ -296,6 +298,12 @@ class ShortUrlForm
->default(fn () => config('filament-short-url.tracking.fields.referer_url', true))
->inline(false)
->disabled(fn (Get $get): bool => ! $get('track_visits')),
Toggle::make('track_browser_language')
->label(__('filament-short-url::default.track_browser_language'))
->default(fn () => config('filament-short-url.tracking.fields.browser_language', true))
->inline(false)
->disabled(fn (Get $get): bool => ! $get('track_visits')),
])
->columns(4)
->hidden(fn (Get $get): bool => ! $get('track_visits')),
@@ -360,18 +368,32 @@ class ShortUrlForm
return Tab::make(__('filament-short-url::default.tab_qr_design'))
->icon('heroicon-o-qr-code')
->schema([
TextInput::make('qr_options')
->extraAttributes([
'id' => 'qr-options-json-input',
'style' => 'position:absolute;width:1px;height:1px;opacity:0;pointer-events:none',
'aria-hidden' => 'true',
])
->dehydrateStateUsing(fn (?string $state): array => json_decode($state ?? '{}', true) ?: [])
->afterStateHydrated(function (TextInput $component, mixed $state): void {
$component->state(is_array($state) ? json_encode($state) : ($state ?? '{}'));
})
->columnSpanFull()
->label(''),
Group::make([
TextInput::make('qr_options')
->extraAttributes([
'style' => 'display: none !important;',
])
->extraInputAttributes([
'id' => 'qr-options-json-input',
])
->hiddenLabel()
->dehydrateStateUsing(fn (?string $state): array => json_decode($state ?? '{}', true) ?: [])
->afterStateHydrated(function (TextInput $component, mixed $state): void {
$component->state(is_array($state) ? json_encode($state) : ($state ?? '{}'));
}),
TextInput::make('qr_logo')
->extraAttributes([
'style' => 'display: none !important;',
])
->extraInputAttributes([
'id' => 'qr-logo-path-input',
])
->hiddenLabel()
->nullable(),
])->extraAttributes([
'style' => 'display: none !important;',
]),
ViewField::make('qr_designer')
->view('filament-short-url::qr-designer')

View File

@@ -121,7 +121,7 @@ HTML
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
'.$record->total_visits.' clicks
'.$record->total_visits.' '.__('filament-short-url::default.badge_clicks').'
</span>
<!-- Unique Clicks Badge -->
@@ -129,7 +129,16 @@ HTML
<svg class="w-3.5 h-3.5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
'.$record->unique_visits.' unique
'.$record->unique_visits.' '.__('filament-short-url::default.badge_unique').'
</span>
<!-- QR Scans Badge -->
<span class="inline-flex items-center gap-1 bg-[#f4f4f5] dark:bg-gray-800 px-2 py-1 rounded text-[11px] font-medium text-gray-600 dark:text-gray-300">
<svg class="w-3.5 h-3.5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0 1 3.75 9.375v-4.5ZM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 0 1-1.125-1.125v-4.5ZM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0 1 13.5 9.375v-4.5Z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12h.008v.008H15V12Zm0 3h.008v.008H15V15Zm0 3h.008v.008H15V18Zm3-3h.008v.008H18V15Zm0 3h.008v.008H18V18Zm3-3h.008v.008H21V15Zm0 3h.008v.008H21V18Zm0-6h.008v.008H21V12Zm-3 0h.008v.008H18V12Z" />
</svg>
'.$record->qr_scans.' '.__('filament-short-url::default.badge_qr_scans').'
</span>
<!-- Date Added Badge -->
@@ -137,7 +146,7 @@ HTML
<svg class="w-3.5 h-3.5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
'.$record->created_at->format('M d, Y').'
'.$record->created_at->translatedFormat('M d, Y').'
</span>
<!-- Expiry / Single Use Badge -->
@@ -146,12 +155,12 @@ HTML
<svg class="w-3.5 h-3.5 text-yellow-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Expires: '.$record->expires_at->format('M d, Y').'
'.__('filament-short-url::default.badge_expires', ['date' => $record->expires_at->translatedFormat('M d, Y')]).'
' : '
<svg class="w-3.5 h-3.5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
No expiry
'.__('filament-short-url::default.badge_no_expiry').'
').'
</span>
@@ -160,7 +169,7 @@ HTML
<svg class="w-3.5 h-3.5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
</svg>
'.$record->redirect_status_code.' redirect
'.__('filament-short-url::default.badge_redirect', ['code' => $record->redirect_status_code]).'
</span>
</div>
')),
@@ -294,6 +303,284 @@ HTML
')),
]),
Action::make('qrCode')
->label(__('filament-short-url::default.action_qr') ?? 'QR Code')
->icon('heroicon-o-qr-code')
->color('gray')
->iconButton()
->tooltip(__('filament-short-url::default.action_qr') ?? 'QR Code')
->modalWidth('md')
->modalSubmitAction(false)
->modalCancelAction(false)
->form(function (ShortUrl $record): array {
$shortUrl = $record->getShortUrl();
$qrTargetUrl = $shortUrl.'?source=qr';
$destHost = parse_url($record->destination_url, PHP_URL_HOST) ?? '';
$urlKey = $record->url_key;
$eid = 'fsu_'.substr(md5($shortUrl), 0, 8);
$qrHelperText = __('filament-short-url::default.qr_modal_helper') ?? 'Scan, copy, or download your custom QR code.';
$downloadSvgText = __('filament-short-url::default.qr_download_svg') ?? 'Download SVG';
$downloadPngText = __('filament-short-url::default.qr_download_png') ?? 'Download PNG';
$closeButtonText = __('filament-short-url::default.close_button') ?? 'Close';
$copyLinkText = __('filament-short-url::default.action_copy') ?? 'Copy link';
$openLinkText = __('filament-short-url::default.open_link') ?? 'Open link';
$qrDefaults = $record->getQrOptions();
$isGrad = ($qrDefaults['gradient_enabled'] ?? false) || (($qrDefaults['color_mode'] ?? '') === 'gradient');
$dotStyle = $qrDefaults['dot_style'] ?? 'square';
$fgColor = $qrDefaults['foreground_color'] ?? '#000000';
$bgColor = ($qrDefaults['bg_transparent'] ?? false) ? 'rgba(0,0,0,0)' : ($qrDefaults['background_color'] ?? '#ffffff');
$dotsOptions = $isGrad ? [
'type' => $dotStyle,
'gradient' => [
'type' => $qrDefaults['gradient_type'] ?? 'linear',
'colorStops' => [
['offset' => 0, 'color' => $qrDefaults['gradient_from'] ?? '#4f46e5'],
['offset' => 1, 'color' => $qrDefaults['gradient_to'] ?? '#06b6d4'],
],
],
] : [
'type' => $dotStyle,
'color' => $fgColor,
];
$mainColor = $isGrad ? ($qrDefaults['gradient_from'] ?? '#4f46e5') : $fgColor;
$eyeConfigEnabled = $qrDefaults['eye_config_enabled'] ?? false;
$eyeSquareStyle = $qrDefaults['eye_square_style'] ?? ($dotStyle === 'dots' ? 'dot' : 'square');
$eyeDotStyle = $qrDefaults['eye_dot_style'] ?? ($dotStyle === 'dots' ? 'dot' : 'square');
$eyeColor = $qrDefaults['eye_color'] ?? $mainColor;
$cornersSquareOptions = $eyeConfigEnabled ? [
'type' => $eyeSquareStyle,
'color' => $eyeColor,
] : [
'type' => $dotStyle === 'dots' ? 'dot' : 'square',
'color' => $mainColor,
];
$cornersDotOptions = $eyeConfigEnabled ? [
'type' => $eyeDotStyle,
'color' => $eyeColor,
] : [
'type' => $dotStyle === 'dots' ? 'dot' : 'square',
'color' => $mainColor,
];
$logo = $qrDefaults['logo'] ?? null;
$logoSize = $qrDefaults['logo_size'] ?? 0.3;
$logoMargin = $qrDefaults['logo_margin'] ?? 9;
$logoHideBackground = $qrDefaults['logo_hide_background'] ?? true;
$logoShape = $qrDefaults['logo_shape'] ?? 'square';
$qrOptionsJson = json_encode([
'type' => 'svg',
'width' => 200,
'height' => 200,
'margin' => $qrDefaults['margin'] ?? 1,
'dotsOptions' => $dotsOptions,
'backgroundOptions' => ['color' => $bgColor],
'cornersSquareOptions' => $cornersSquareOptions,
'cornersDotOptions' => $cornersDotOptions,
'image' => $logo ?: null,
'imageOptions' => [
'crossOrigin' => 'anonymous',
'hideBackgroundDots' => $logoHideBackground,
'imageSize' => $logoSize,
'margin' => $logoMargin,
'logoShape' => $logoShape,
],
'qrOptions' => ['errorCorrectionLevel' => $logo ? 'H' : 'M'],
]);
$escapedQrOptions = e($qrOptionsJson);
$html = <<<HTML
<div data-qr-options="{$escapedQrOptions}" x-data="{
shortUrl: '{$shortUrl}',
qrTargetUrl: '{$qrTargetUrl}',
urlKey: '{$urlKey}',
eid: '{$eid}',
init() {
this.\$nextTick(() => {
this.loadScript().then(() => {
const canvas = document.getElementById(this.eid + '_qr_canvas');
if (canvas && !canvas.innerHTML) {
const opts = JSON.parse(this.\$el.getAttribute('data-qr-options'));
opts.data = this.qrTargetUrl;
const fixSvg = () => {
const svg = canvas.querySelector('svg');
if (svg) {
const w = svg.getAttribute('width') || opts.width || 200;
const h = svg.getAttribute('height') || opts.height || 200;
svg.setAttribute('viewBox', '0 0 ' + w + ' ' + h);
svg.style.width = '100%';
svg.style.height = '100%';
}
};
if (opts.image && opts.imageOptions) {
const img = new Image();
img.onload = () => {
const cv = document.createElement('canvas');
cv.width = 1200;
cv.height = 1200;
const cx = cv.getContext('2d');
cx.imageSmoothingEnabled = true;
cx.imageSmoothingQuality = 'high';
const isCircle = opts.imageOptions.logoShape === 'circle';
const targetDim = 1200 - (parseFloat(opts.imageOptions.margin || 0) * 20);
cx.save();
if (isCircle) {
cx.beginPath();
cx.arc(600, 600, targetDim / 2, 0, 2 * Math.PI);
cx.clip();
const scale = Math.max(targetDim / img.width, targetDim / img.height);
const w = img.width * scale;
const h = img.height * scale;
const x = (1200 - w) / 2;
const y = (1200 - h) / 2;
cx.drawImage(img, x, y, w, h);
} else {
cx.beginPath();
const offset = (1200 - targetDim) / 2;
const radius = 144 * (targetDim / 1200);
if (typeof cx.roundRect === 'function') {
cx.roundRect(offset, offset, targetDim, targetDim, radius);
} else {
cx.moveTo(offset + radius, offset);
cx.lineTo(offset + targetDim - radius, offset);
cx.quadraticCurveTo(offset + targetDim, offset, offset + targetDim, offset + radius);
cx.lineTo(offset + targetDim, offset + targetDim - radius);
cx.quadraticCurveTo(offset + targetDim, offset + targetDim, offset + targetDim - radius, offset + targetDim);
cx.lineTo(offset + radius, offset + targetDim);
cx.quadraticCurveTo(offset, offset + targetDim, offset, offset + targetDim - radius);
cx.lineTo(offset, offset + radius);
cx.quadraticCurveTo(offset, offset, offset + radius, offset);
cx.closePath();
}
cx.clip();
const scale = Math.max(targetDim / img.width, targetDim / img.height);
const w = img.width * scale;
const h = img.height * scale;
const x = (1200 - w) / 2;
const y = (1200 - h) / 2;
cx.drawImage(img, x, y, w, h);
}
cx.restore();
opts.image = cv.toDataURL('image/png');
opts.imageOptions.margin = 0;
window['qr_' + this.eid] = new window.QRCodeStyling(opts);
window['qr_' + this.eid].append(canvas);
fixSvg();
};
img.src = opts.image;
} else {
window['qr_' + this.eid] = new window.QRCodeStyling(opts);
window['qr_' + this.eid].append(canvas);
fixSvg();
}
}
});
});
},
loadScript() {
return new Promise((resolve) => {
if (window.QRCodeStyling) return resolve();
const s = document.createElement('script');
s.src = 'https://unpkg.com/qr-code-styling@1.6.0-rc.1/lib/qr-code-styling.js';
s.onload = () => resolve();
s.onerror = () => {
const s2 = document.createElement('script');
s2.src = 'https://cdn.jsdelivr.net/npm/qr-code-styling@1.6.0-rc.1/lib/qr-code-styling.js';
s2.onload = () => resolve();
document.head.appendChild(s2);
};
document.head.appendChild(s);
});
}
}">
<!-- Close Button (x) -->
<button type="button" x-on:click="event.preventDefault(); event.stopPropagation(); const f=\$el.closest('.fi-modal-window'); const m=f?(f.getAttribute('x-on:keydown.window.escape')||'').match(/'(fi-[^']*)'/):null; if(m){\$dispatch('close-modal',{id:m[1]})}else{\$wire.unmountAction()}"
style="position:absolute;top:16px;right:16px;z-index:50;width:28px;height:28px;border-radius:50%;border:none;background:#f3f4f6;cursor:pointer;display:flex;align-items:center;justify-content:center;color:#6b7280;transition:color .15s,background-color .15s"
onmouseover="this.style.color='#374151';this.style.backgroundColor='#e5e7eb'"
onmouseout="this.style.color='#6b7280';this.style.backgroundColor='#f3f4f6'"
title="{$closeButtonText}">
<svg style="width:16px;height:16px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<div style="text-align:center;padding:4px 0 8px">
<p style="font-size:13px;color:#9ca3af;margin:0">{$qrHelperText}</p>
</div>
<!-- URL pill -->
<div style="display:flex;align-items:center;gap:10px;background:#EFF6FF;border-radius:999px;padding:10px 14px;margin:18px 0 0">
<div style="width:30px;height:30px;border-radius:50%;background:#fff;display:flex;align-items:center;justify-content:center;flex-shrink:0;border:1px solid #e5e7eb">
<img src="https://icons.duckduckgo.com/ip2/{$destHost}.ico" style="width:16px;height:16px;object-fit:contain" onerror="this.style.display='none'">
</div>
<span style="flex:1;font-size:14px;font-weight:600;color:#1d4ed8;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{$shortUrl}</span>
<div style="display:flex;align-items:center;gap:4px;flex-shrink:0">
<button id="{$eid}_copy" type="button"
onclick="
event.preventDefault();
event.stopPropagation();
const u='{$shortUrl}';
if(navigator.clipboard&&window.isSecureContext){navigator.clipboard.writeText(u);}
else{const t=document.createElement('textarea');t.value=u;t.style.cssText='position:fixed;left:-9999px';document.body.appendChild(t);t.select();document.execCommand('copy');t.remove();}
const b=document.getElementById('{$eid}_copy');
const prev=b.innerHTML;
b.innerHTML='<svg style=\'width:16px;height:16px;color:#16a34a\' fill=\'none\' viewBox=\'0 0 24 24\' stroke=\'currentColor\' stroke-width=\'2\'><path stroke-linecap=\'round\' stroke-linejoin=\'round\' d=\'M5 13l4 4L19 7\'/></svg>';
setTimeout(()=>b.innerHTML=prev,1800);
"
style="width:30px;height:30px;border-radius:7px;border:1px solid #e5e7eb;background:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .15s"
onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'" title="{$copyLinkText}">
<svg style="width:16px;height:16px;color:#6b7280" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path stroke-linecap="round" stroke-linejoin="round" d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</button>
<a href="{$shortUrl}" target="_blank" rel="noopener noreferrer"
style="width:30px;height:30px;border-radius:7px;border:1px solid #e5e7eb;background:#fff;display:flex;align-items:center;justify-content:center;text-decoration:none;transition:background .15s"
onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'" title="{$openLinkText}">
<svg style="width:15px;height:15px;color:#6b7280" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
</a>
</div>
</div>
<!-- QR Code Container (Visible immediately) -->
<div id="{$eid}_qr_container" style="display:block;margin:16px 0 0;text-align:center;background:#f9fafb;border:1px solid #e5e7eb;border-radius:12px;padding:16px">
<div style="display:flex;justify-content:center;margin-bottom:12px">
<div id="{$eid}_qr_canvas" style="background:#fff;padding:8px;border-radius:8px;box-shadow:0 1px 3px rgba(0,0,0,0.05)"></div>
</div>
<div style="display:flex;justify-content:center;gap:8px">
<button type="button" onclick="event.preventDefault();event.stopPropagation();window['qr_{$eid}']?.download({ name: '{$urlKey}-qr', extension: 'svg' })" style="font-size:12px;font-weight:600;color:#374151;border:1.5px solid #e5e7eb;background:#fff;padding:6px 12px;border-radius:8px;cursor:pointer;transition:background .15s" onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'">{$downloadSvgText}</button>
<button type="button" onclick="event.preventDefault();event.stopPropagation();window['qr_{$eid}']?.download({ name: '{$urlKey}-qr', extension: 'png' })" style="font-size:12px;font-weight:600;color:#374151;border:1.5px solid #e5e7eb;background:#fff;padding:6px 12px;border-radius:8px;cursor:pointer;transition:background .15s" onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'">{$downloadPngText}</button>
</div>
</div>
</div>
HTML;
return [
Forms\Components\Placeholder::make('qr_modal_content')
->hiddenLabel()
->content(new HtmlString($html)),
];
}),
Action::make('stats')
->label(__('filament-short-url::default.action_stats'))
->icon('heroicon-o-chart-bar')

View File

@@ -41,6 +41,10 @@ class ShortUrlStatsOverview extends BaseWidget
->icon('heroicon-o-user-group')
->color('info'),
Stat::make(__('filament-short-url::default.stats_card_today_clicks'), number_format($stats['visitsToday'] ?? 0))
->icon('heroicon-o-clock')
->color('success'),
Stat::make(__('filament-short-url::default.stats_card_top_source'), $topSource)
->icon('heroicon-o-megaphone')
->color('warning'),
@@ -48,6 +52,10 @@ class ShortUrlStatsOverview extends BaseWidget
Stat::make(__('filament-short-url::default.stats_card_top_country'), $topCountry)
->icon('heroicon-o-globe-alt')
->color('success'),
Stat::make(__('filament-short-url::default.stats_card_qr_scans'), number_format($stats['qrScans'] ?? 0))
->icon('heroicon-o-qr-code')
->color('info'),
];
}
}

View File

@@ -4,6 +4,7 @@ namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Filament\Widgets\ChartWidget;
use Illuminate\Support\Carbon;
class ShortUrlVisitsChart extends ChartWidget
{
@@ -15,9 +16,7 @@ class ShortUrlVisitsChart extends ChartWidget
protected ?string $maxHeight = '200px';
protected int|string|array $columnSpan = [
'lg' => 2,
];
protected int|string|array $columnSpan = 'full';
public function getHeading(): string
{
@@ -52,10 +51,11 @@ class ShortUrlVisitsChart extends ChartWidget
],
'labels' => array_map(function (string $date) {
try {
$carbon = \Illuminate\Support\Carbon::parse($date);
$carbon = Carbon::parse($date);
if (strlen($date) === 7) { // Y-m
return $carbon->format('m.Y');
}
return $carbon->format('d.m');
} catch (\Throwable $e) {
return $date;

View File

@@ -15,9 +15,7 @@ class ShortUrlVisitsRightBreakdown extends Widget
protected string $view = 'filament-short-url::widgets.visits-right-breakdown';
protected int|string|array $columnSpan = [
'lg' => 1,
];
protected int|string|array $columnSpan = 'full';
public function mount(?ShortUrl $record = null): void
{
@@ -29,9 +27,43 @@ class ShortUrlVisitsRightBreakdown extends Widget
*/
protected function getViewData(): array
{
$languageNames = [
'en' => 'English',
'pl' => 'Polish',
'de' => 'German',
'es' => 'Spanish',
'fr' => 'French',
'it' => 'Italian',
'ru' => 'Russian',
'zh' => 'Chinese',
'ja' => 'Japanese',
'pt' => 'Portuguese',
'nl' => 'Dutch',
'tr' => 'Turkish',
'uk' => 'Ukrainian',
'cs' => 'Czech',
'sv' => 'Swedish',
'no' => 'Norwegian',
'da' => 'Danish',
'fi' => 'Finnish',
'ro' => 'Romanian',
'hu' => 'Hungarian',
'sk' => 'Slovak',
'bg' => 'Bulgarian',
'hr' => 'Croatian',
'sr' => 'Serbian',
'sl' => 'Slovenian',
'et' => 'Estonian',
'lv' => 'Latvian',
'lt' => 'Lithuanian',
'el' => 'Greek',
];
if (! $this->record) {
return [
'visitsByCountry' => [],
'visitsByLanguage' => [],
'languageNames' => $languageNames,
'totalVisits' => 0,
];
}
@@ -40,7 +72,89 @@ class ShortUrlVisitsRightBreakdown extends Widget
return [
'visitsByCountry' => $stats['visitsByCountry'] ?? [],
'visitsByLanguage' => $stats['visitsByLanguage'] ?? [],
'languageNames' => $languageNames,
'totalVisits' => $stats['totalVisits'] ?? 0,
];
}
/**
* Dynamically translate an ISO language code to the current application locale.
*/
public static function getLanguageTranslation(string $langCode): string
{
$langCode = strtolower(trim($langCode));
if (class_exists(\Locale::class)) {
try {
$name = \Locale::getDisplayLanguage($langCode, app()->getLocale());
if ($name && $name !== $langCode) {
return mb_convert_case($name, MB_CASE_TITLE, 'UTF-8');
}
} catch (\Throwable) {
// Fallback
}
}
$fallback = [
'en' => 'English',
'pl' => 'Polish',
'de' => 'German',
'es' => 'Spanish',
'fr' => 'French',
'it' => 'Italian',
'ru' => 'Russian',
'zh' => 'Chinese',
'ja' => 'Japanese',
'pt' => 'Portuguese',
'nl' => 'Dutch',
'tr' => 'Turkish',
'uk' => 'Ukrainian',
'cs' => 'Czech',
'sv' => 'Swedish',
'no' => 'Norwegian',
'da' => 'Danish',
'fi' => 'Finnish',
'ro' => 'Romanian',
'hu' => 'Hungarian',
'sk' => 'Slovak',
'bg' => 'Bulgarian',
'hr' => 'Croatian',
'sr' => 'Serbian',
'sl' => 'Slovenian',
'et' => 'Estonian',
'lv' => 'Latvian',
'lt' => 'Lithuanian',
'el' => 'Greek',
];
return $fallback[$langCode] ?? strtoupper($langCode);
}
/**
* Dynamically translate an English country name to the current application locale.
*/
public static function getCountryTranslation(string $englishName): string
{
$englishName = trim($englishName);
try {
$enCountries = trans('filament-short-url::countries', [], 'en');
if (is_array($enCountries)) {
$flipped = array_change_key_case(array_flip($enCountries), CASE_LOWER);
$lookupKey = strtolower($englishName);
$code = $flipped[$lookupKey] ?? null;
if ($code) {
$translated = __('filament-short-url::countries.'.strtoupper($code));
if ($translated && $translated !== 'filament-short-url::countries.'.strtoupper($code)) {
return $translated;
}
}
}
} catch (\Throwable) {
// Fallback
}
return $englishName;
}
}

View File

@@ -90,7 +90,7 @@ class ShortUrlWorldMapWidget extends Widget
}
// Load and clean up raw SVG map on the server-side where paths are reliable
$svgPath = dirname(__FILE__, 6) . '/resources/views/widgets/world-map.svg';
$svgPath = dirname(__FILE__, 6).'/resources/views/widgets/world-map.svg';
$svgContent = '';
if (file_exists($svgPath)) {
$svgContent = file_get_contents($svgPath);

View File

@@ -4,6 +4,8 @@ namespace Bjanczak\FilamentShortUrl;
use Bjanczak\FilamentShortUrl\Console\Commands\SyncBufferedCountersCommand;
use Bjanczak\FilamentShortUrl\Services\GeoIpService;
use Bjanczak\FilamentShortUrl\Services\ProxyDetectionService;
use Bjanczak\FilamentShortUrl\Services\SafeBrowsingService;
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
use Bjanczak\FilamentShortUrl\Services\ShortUrlSettingsManager;
use Bjanczak\FilamentShortUrl\Services\ShortUrlTracker;
@@ -34,6 +36,9 @@ class FilamentShortUrlServiceProvider extends PackageServiceProvider
'2026_06_02_000006_add_max_visits_and_expiration_redirect_to_short_urls_table',
'2026_06_02_000007_add_retargeting_pixels_and_webhooks_to_short_urls_table',
'2026_06_02_000008_add_bot_and_proxy_to_short_url_visits_table',
'2026_06_02_224250_add_qr_logo_to_short_urls_table',
'2026_06_03_110000_add_qr_and_language_to_visits_and_daily_stats',
'2026_06_03_120000_add_track_browser_language_to_short_urls_table',
])
->hasCommands([
SyncBufferedCountersCommand::class,
@@ -52,8 +57,8 @@ class FilamentShortUrlServiceProvider extends PackageServiceProvider
$this->app->singleton(GeoIpService::class);
$this->app->singleton(ShortUrlService::class);
$this->app->singleton(ShortUrlTracker::class);
$this->app->singleton(\Bjanczak\FilamentShortUrl\Services\ProxyDetectionService::class);
$this->app->singleton(\Bjanczak\FilamentShortUrl\Services\SafeBrowsingService::class);
$this->app->singleton(ProxyDetectionService::class);
$this->app->singleton(SafeBrowsingService::class);
}
public function packageBooted(): void

View File

@@ -9,6 +9,10 @@ use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\StreamedResponse;
class ShortUrlApiController extends Controller
{
@@ -142,4 +146,223 @@ class ShortUrlApiController extends Controller
}
}
}
/**
* Upload QR logo file from admin panel.
*/
public function uploadLogo(Request $request): JsonResponse
{
Log::info('ShortUrlApiController::uploadLogo called', [
'auth_check' => auth()->check(),
'user_id' => auth()->id(),
'has_file' => $request->hasFile('logo'),
'all_files' => array_keys($request->allFiles()),
]);
if (! auth()->check()) {
return response()->json(['error' => 'Unauthorized'], 401);
}
$request->validate([
'logo' => 'required|image|max:10240',
]);
if ($request->hasFile('logo')) {
$file = $request->file('logo');
$tempPath = $file->getRealPath();
$filename = Str::random(40).'.webp';
$targetPath = 'short-urls/tmp/'.$filename;
$processed = $this->processLogo($tempPath, $targetPath);
if ($processed) {
$path = $targetPath;
} else {
// Fallback: store raw file if image processing fails
$path = $file->store('short-urls/tmp', 'public');
}
$url = route('short-url.logo', ['filename' => basename($path)]);
return response()->json([
'path' => $path,
'url' => $url,
]);
}
return response()->json(['error' => 'No file uploaded'], 400);
}
/**
* Process the uploaded logo: detect available driver and optimize/convert to WebP.
*/
private function processLogo(string $filePath, string $targetPath): bool
{
if (extension_loaded('imagick') && class_exists(\Imagick::class)) {
return $this->processLogoWithImagick($filePath, $targetPath);
}
if (extension_loaded('gd') && function_exists('gd_info')) {
return $this->processLogoWithGd($filePath, $targetPath);
}
return false;
}
/**
* Process the logo using Imagick: scale down to 800px max edge and convert to WebP.
*/
private function processLogoWithImagick(string $filePath, string $targetPath): bool
{
try {
$imagick = new \Imagick($filePath);
// Get original dimensions
$width = $imagick->getImageWidth();
$height = $imagick->getImageHeight();
// Calculate new dimensions
$maxDim = 800;
if ($width > $maxDim || $height > $maxDim) {
if ($width > $height) {
$newWidth = $maxDim;
$newHeight = (int) round(($height * $maxDim) / $width);
} else {
$newHeight = $maxDim;
$newWidth = (int) round(($width * $maxDim) / $height);
}
$imagick->scaleImage($newWidth, $newHeight);
}
// Convert to WebP format
$imagick->setImageFormat('webp');
$imagick->setImageCompressionQuality(85);
// Get image data as blob
$webpData = $imagick->getImageBlob();
// Clear resources
$imagick->clear();
$imagick->destroy();
if ($webpData) {
return Storage::disk('public')->put($targetPath, $webpData);
}
} catch (\Throwable $e) {
// Fall back to GD or raw store
}
return false;
}
/**
* Process the uploaded logo using GD: scale down to 800px on the longer side (preserving aspect ratio) and convert to WebP.
*/
private function processLogoWithGd(string $filePath, string $targetPath): bool
{
// 1. Get original dimensions and type
$info = @getimagesize($filePath);
if (! $info) {
return false;
}
[$width, $height, $type] = $info;
// 2. Load image based on type
switch ($type) {
case IMAGETYPE_JPEG:
$src = @imagecreatefromjpeg($filePath);
break;
case IMAGETYPE_PNG:
$src = @imagecreatefrompng($filePath);
break;
case IMAGETYPE_WEBP:
$src = @imagecreatefromwebp($filePath);
break;
case IMAGETYPE_GIF:
$src = @imagecreatefromgif($filePath);
break;
default:
return false;
}
if (! $src) {
return false;
}
// 3. Calculate new dimensions
$maxDim = 800;
$newWidth = $width;
$newHeight = $height;
if ($width > $maxDim || $height > $maxDim) {
if ($width > $height) {
$newWidth = $maxDim;
$newHeight = (int) round(($height * $maxDim) / $width);
} else {
$newHeight = $maxDim;
$newWidth = (int) round(($width * $maxDim) / $height);
}
}
// 4. Create new truecolor image
$dst = imagecreatetruecolor($newWidth, $newHeight);
if (! $dst) {
imagedestroy($src);
return false;
}
// Preserve transparency for PNG and WebP
imagealphablending($dst, false);
imagesavealpha($dst, true);
// Resize
imagecopyresampled($dst, $src, 0, 0, 0, 0, $newWidth, $newHeight, $width, $height);
// 5. Save as WebP
$disk = Storage::disk('public');
ob_start();
$saved = imagewebp($dst, null, 85);
$webpData = ob_get_clean();
// Free memory
imagedestroy($src);
imagedestroy($dst);
if ($saved && $webpData !== false) {
return $disk->put($targetPath, $webpData);
}
return false;
}
/**
* Serve the uploaded QR logo.
*/
public function serveLogo(string $filename): StreamedResponse|BinaryFileResponse
{
// Prevent directory traversal attacks
$filename = basename($filename);
if (! preg_match('/^[a-zA-Z0-9_\-]+\.[a-zA-Z0-9]+$/', $filename)) {
abort(400, 'Invalid filename');
}
$disk = Storage::disk('public');
$path = 'short-urls/logos/'.$filename;
if (! $disk->exists($path)) {
$path = 'short-urls/tmp/'.$filename;
if (! $disk->exists($path)) {
abort(404);
}
}
return $disk->response($path, null, [
'Access-Control-Allow-Origin' => '*',
'Access-Control-Allow-Methods' => 'GET, OPTIONS',
'Access-Control-Allow-Headers' => 'Content-Type, X-Requested-With',
]);
}
}

View File

@@ -5,6 +5,7 @@ namespace Bjanczak\FilamentShortUrl\Http\Controllers;
use Bjanczak\FilamentShortUrl\Jobs\TrackShortUrlVisitJob;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Bjanczak\FilamentShortUrl\Services\ClientIpExtractor;
use Bjanczak\FilamentShortUrl\Services\ProxyDetectionService;
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
@@ -40,7 +41,7 @@ class ShortUrlRedirectController extends Controller
// 1. VPN/Proxy & Bot Blocking Check
if (config('filament-short-url.vpn_detection.enabled', false) && config('filament-short-url.vpn_detection.block_action') === 'block_with_403') {
$ipAddress = ClientIpExtractor::getIp($request);
$proxyDetector = app(\Bjanczak\FilamentShortUrl\Services\ProxyDetectionService::class);
$proxyDetector = app(ProxyDetectionService::class);
$detection = $proxyDetector->detect($ipAddress);
if ($detection['is_proxy'] || $detection['is_bot']) {
abort(403, 'Access denied. VPN, Proxy, or automated scraping connection detected.');
@@ -117,6 +118,17 @@ class ShortUrlRedirectController extends Controller
$countryCode = ClientIpExtractor::getCountryCode($request);
$city = ClientIpExtractor::getCity($request);
$isQrScan = (bool) ($request->query('source') === 'qr' || $request->query('qr') === '1');
$languages = $request->getLanguages();
$browserLanguage = null;
if (! empty($languages)) {
$parts = explode('-', str_replace('_', '-', $languages[0]));
$browserLanguage = strtolower(trim($parts[0]));
if (strlen($browserLanguage) > 5) {
$browserLanguage = substr($browserLanguage, 0, 5);
}
}
$job = new TrackShortUrlVisitJob(
shortUrl: $shortUrl,
ipAddress: $ipAddress,
@@ -129,6 +141,8 @@ class ShortUrlRedirectController extends Controller
utmCampaign: $request->query('utm_campaign'),
utmTerm: $request->query('utm_term'),
utmContent: $request->query('utm_content'),
isQrScan: $isQrScan,
browserLanguage: $browserLanguage,
);
if ($connection) {

View File

@@ -20,6 +20,7 @@ class IncrementVisitJob implements ShouldQueue
public function __construct(
public readonly int $shortUrlId,
public readonly bool $isUnique = false,
public readonly bool $isQrScan = false,
) {
$this->onQueue(config('filament-short-url.queue_name', 'default'));
}
@@ -32,12 +33,20 @@ class IncrementVisitJob implements ShouldQueue
return;
}
$updates = [];
if ($this->isUnique) {
$updates['unique_visits'] = DB::raw('unique_visits + 1');
}
if ($this->isQrScan) {
$updates['qr_scans'] = DB::raw('qr_scans + 1');
}
$shortUrl->newQuery()
->where('id', $shortUrl->id)
->increment(
'total_visits',
1,
$this->isUnique ? ['unique_visits' => DB::raw('unique_visits + 1')] : []
$updates
);
}
}

View File

@@ -47,6 +47,8 @@ class TrackShortUrlVisitJob implements ShouldQueue
public readonly ?string $utmCampaign = null,
public readonly ?string $utmTerm = null,
public readonly ?string $utmContent = null,
public readonly bool $isQrScan = false,
public readonly ?string $browserLanguage = null,
) {
$this->onQueue(config('filament-short-url.queue_name', 'default'));
}
@@ -80,6 +82,8 @@ class TrackShortUrlVisitJob implements ShouldQueue
utmCampaign: $this->utmCampaign,
utmTerm: $this->utmTerm,
utmContent: $this->utmContent,
isQrScan: $this->isQrScan,
browserLanguage: $this->browserLanguage,
);
// Null means bot/crawler — nothing to dispatch or report
@@ -133,6 +137,8 @@ class TrackShortUrlVisitJob implements ShouldQueue
'utm_campaign' => $visit->utm_campaign,
'utm_term' => $visit->utm_term,
'utm_content' => $visit->utm_content,
'is_qr_scan' => (bool) $visit->is_qr_scan,
'browser_language' => $visit->browser_language,
],
];

View File

@@ -18,6 +18,7 @@ use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\Storage;
/**
* @property int $id
@@ -71,6 +72,7 @@ class ShortUrl extends Model
'track_operating_system_version',
'track_device_type',
'track_referer_url',
'track_browser_language',
'qr_options',
'ga_tracking_id',
'password',
@@ -84,6 +86,8 @@ class ShortUrl extends Model
'pixel_google_id',
'pixel_linkedin_id',
'webhook_url',
'qr_logo',
'qr_scans',
];
/** @var array<string, string> */
@@ -99,6 +103,7 @@ class ShortUrl extends Model
'track_operating_system_version' => 'boolean',
'track_device_type' => 'boolean',
'track_referer_url' => 'boolean',
'track_browser_language' => 'boolean',
'qr_options' => 'array',
'show_warning_page' => 'boolean',
'targeting_rules' => 'array',
@@ -106,6 +111,7 @@ class ShortUrl extends Model
'activated_at' => 'datetime',
'deactivated_at' => 'datetime',
'expires_at' => 'datetime',
'qr_scans' => 'integer',
];
// ─── Relations ───────────────────────────────────────────────────────────
@@ -204,6 +210,29 @@ class ShortUrl extends Model
if (empty($m->webhook_url)) {
$m->webhook_url = null;
}
if ($m->isDirty('qr_logo') && ! empty($m->qr_logo)) {
if (str_starts_with($m->qr_logo, 'short-urls/tmp/')) {
$tmpPath = $m->qr_logo;
$filename = basename($tmpPath);
$newPath = 'short-urls/logos/'.$filename;
$disk = Storage::disk('public');
if ($disk->exists($tmpPath)) {
$disk->move($tmpPath, $newPath);
$m->qr_logo = $newPath;
}
}
}
});
static::updating(function (self $m) {
if ($m->isDirty('qr_logo')) {
$oldLogo = $m->getOriginal('qr_logo');
if (! empty($oldLogo)) {
Storage::disk('public')->delete($oldLogo);
}
}
});
static::saved(function (self $m) {
@@ -216,11 +245,17 @@ class ShortUrl extends Model
}
}
});
static::deleted(fn (self $m) => cache()->forget("filament-short-url:{$m->url_key}"));
static::deleted(function (self $m) {
cache()->forget("filament-short-url:{$m->url_key}");
cache()->forget(ShortUrlGlobalOverview::LINKS_CACHE_KEY);
if (! empty($m->qr_logo)) {
Storage::disk('public')->delete($m->qr_logo);
}
});
// Bust the forever-cached link counts displayed in the global overview widget.
static::created(fn () => cache()->forget(ShortUrlGlobalOverview::LINKS_CACHE_KEY));
static::deleted(fn () => cache()->forget(ShortUrlGlobalOverview::LINKS_CACHE_KEY));
}
/** @return Collection<int, static> */
@@ -308,23 +343,34 @@ class ShortUrl extends Model
'operating_system_version' => $this->track_operating_system_version,
'device_type' => $this->track_device_type,
'referer_url' => $this->track_referer_url,
'browser_language' => $this->track_browser_language,
]));
}
/** @return array<string, mixed> */
public function getQrOptions(): array
{
return array_merge(
$opts = array_merge(
config('filament-short-url.qr_defaults', []),
$this->qr_options ?? []
);
if (! empty($this->qr_logo)) {
$opts['logo'] = route('short-url.logo', ['filename' => basename($this->qr_logo)]);
}
return $opts;
}
public function getShortUrl(): string
{
$prefix = config('filament-short-url.route_prefix', 's');
$prefix = config('filament-short-url.route_prefix');
return rtrim(config('app.url'), '/')."/{$prefix}/{$this->url_key}";
if (! empty($prefix)) {
return rtrim(config('app.url'), '/').'/'.trim($prefix, '/').'/'.$this->url_key;
}
return rtrim(config('app.url'), '/').'/'.$this->url_key;
}
/**
@@ -397,7 +443,7 @@ class ShortUrl extends Model
* Atomically increment visit counters — single query when unique,
* to avoid race conditions and two round-trips. Supports write-back caching.
*/
public function incrementVisits(bool $isUnique = false): void
public function incrementVisits(bool $isUnique = false, bool $isQrScan = false): void
{
if (config('filament-short-url.counter_buffering.enabled', false)) {
$prefix = config('filament-short-url.counter_buffering.cache_key_prefix', 'filament-short-url:buffer:');
@@ -409,6 +455,10 @@ class ShortUrl extends Model
cache()->increment("{$prefix}unique:{$this->id}");
}
if ($isQrScan) {
cache()->increment("{$prefix}qr:{$this->id}");
}
// Add to dirty IDs list atomically
if (cache()->getDefaultDriver() === 'redis' && class_exists(Redis::class)) {
Redis::sadd("{$prefix}dirty_ids", $this->id);
@@ -434,14 +484,22 @@ class ShortUrl extends Model
// Safe fallback: Dispatch async job so clicks are queued and not lost on cache clear
$connection = config('filament-short-url.queue_connection', 'sync');
dispatch(new IncrementVisitJob($this->id, $isUnique)->onConnection($connection ?: 'sync'));
dispatch((new IncrementVisitJob($this->id, $isUnique, $isQrScan))->onConnection($connection ?: 'sync'));
return;
}
$updates = [];
if ($isUnique) {
$updates['unique_visits'] = DB::raw('unique_visits + 1');
}
if ($isQrScan) {
$updates['qr_scans'] = DB::raw('qr_scans + 1');
}
$this->newQuery()
->where('id', $this->id)
->increment('total_visits', 1, $isUnique ? ['unique_visits' => DB::raw('unique_visits + 1')] : []);
->increment('total_visits', 1, $updates);
}
/**
@@ -499,6 +557,7 @@ class ShortUrl extends Model
$visitsToday = count($rawVisits);
$visitsThisWeek = 0;
$visitsThisMonth = 0;
$qrScans = 0;
$visitsByCountry = [];
$visitsByCity = [];
@@ -509,6 +568,7 @@ class ShortUrl extends Model
$utmSources = [];
$utmMediums = [];
$utmCampaigns = [];
$visitsByLanguage = [];
// Sum up daily stats
$startOfWeek = now()->startOfWeek()->toDateString();
@@ -517,6 +577,7 @@ class ShortUrl extends Model
foreach ($dailyStatsRows as $row) {
$totalVisits += $row->visits_count;
$uniqueVisitsCount += $row->unique_visits_count;
$qrScans += $row->qr_visits_count ?? 0;
$rowDate = $row->date->toDateString();
if ($rowDate >= $startOfWeek) {
@@ -535,6 +596,7 @@ class ShortUrl extends Model
$utmSources = $mergeStats($utmSources, $row->utm_source_stats);
$utmMediums = $mergeStats($utmMediums, $row->utm_medium_stats);
$utmCampaigns = $mergeStats($utmCampaigns, $row->utm_campaign_stats);
$visitsByLanguage = $mergeStats($visitsByLanguage, $row->language_stats);
}
// Combine today's raw visits
@@ -546,6 +608,12 @@ class ShortUrl extends Model
$visitsThisMonth += count($rawVisits);
foreach ($rawVisits as $visit) {
if ($visit->is_qr_scan) {
$qrScans++;
}
if ($visit->browser_language) {
$visitsByLanguage[$visit->browser_language] = ($visitsByLanguage[$visit->browser_language] ?? 0) + 1;
}
if ($visit->country) {
$visitsByCountry[$visit->country] = ($visitsByCountry[$visit->country] ?? 0) + 1;
}
@@ -622,6 +690,7 @@ class ShortUrl extends Model
arsort($utmSources);
arsort($utmMediums);
arsort($utmCampaigns);
arsort($visitsByLanguage);
return [
'totalVisits' => $totalVisits,
@@ -639,6 +708,8 @@ class ShortUrl extends Model
'utmSources' => array_slice($utmSources, 0, 8, true),
'utmMediums' => array_slice($utmMediums, 0, 8, true),
'utmCampaigns' => array_slice($utmCampaigns, 0, 8, true),
'qrScans' => $qrScans,
'visitsByLanguage' => array_slice($visitsByLanguage, 0, 10, true),
];
});
}
@@ -650,6 +721,8 @@ class ShortUrl extends Model
protected static ?array $bufferedUniqueVisits = null;
protected static ?array $bufferedQrScans = null;
/**
* Preload all buffered clicks in a single batch query for the entire request.
* Prevents N+1 database queries even if database cache driver is used.
@@ -662,6 +735,7 @@ class ShortUrl extends Model
static::$bufferedTotalVisits = [];
static::$bufferedUniqueVisits = [];
static::$bufferedQrScans = [];
if (! config('filament-short-url.counter_buffering.enabled', false)) {
return;
@@ -691,15 +765,18 @@ class ShortUrl extends Model
// 2. Build array of keys to fetch in a single cache store read
$totalKeys = [];
$uniqueKeys = [];
$qrKeys = [];
foreach ($dirtyIds as $id) {
$totalKeys[$id] = "{$prefix}total:{$id}";
$uniqueKeys[$id] = "{$prefix}unique:{$id}";
$qrKeys[$id] = "{$prefix}qr:{$id}";
}
try {
// Cache::many() is highly optimized (e.g. 1 database query for database store, or 1 MGET for Redis)
$totals = cache()->many(array_values($totalKeys));
$uniques = cache()->many(array_values($uniqueKeys));
$qrs = cache()->many(array_values($qrKeys));
foreach ($totalKeys as $id => $key) {
static::$bufferedTotalVisits[$id] = (int) ($totals[$key] ?? 0);
@@ -707,6 +784,9 @@ class ShortUrl extends Model
foreach ($uniqueKeys as $id => $key) {
static::$bufferedUniqueVisits[$id] = (int) ($uniques[$key] ?? 0);
}
foreach ($qrKeys as $id => $key) {
static::$bufferedQrScans[$id] = (int) ($qrs[$key] ?? 0);
}
} catch (\Throwable) {
// Fallback
}
@@ -749,4 +829,23 @@ class ShortUrl extends Model
return $dbValue + $buffered;
}
/**
* Get the QR scans count, merging the database value with any buffered clicks in cache.
* Prevents database N+1 queries.
*/
public function getQrScansAttribute(): int
{
$dbValue = $this->attributes['qr_scans'] ?? 0;
if (! config('filament-short-url.counter_buffering.enabled', false)) {
return $dbValue;
}
static::loadAllBufferedVisits();
$buffered = static::$bufferedQrScans[$this->id] ?? 0;
return $dbValue + $buffered;
}
}

View File

@@ -42,6 +42,8 @@ class ShortUrlDailyStats extends Model
'utm_source_stats',
'utm_medium_stats',
'utm_campaign_stats',
'qr_visits_count',
'language_stats',
];
/** @var array<string, string> */
@@ -58,6 +60,8 @@ class ShortUrlDailyStats extends Model
'utm_source_stats' => 'array',
'utm_medium_stats' => 'array',
'utm_campaign_stats' => 'array',
'qr_visits_count' => 'integer',
'language_stats' => 'array',
];
public function shortUrl(): BelongsTo

View File

@@ -49,6 +49,8 @@ class ShortUrlVisit extends Model
'visited_at',
'is_bot',
'is_proxy',
'is_qr_scan',
'browser_language',
];
/** @var array<string, string> */
@@ -56,6 +58,7 @@ class ShortUrlVisit extends Model
'visited_at' => 'datetime',
'is_bot' => 'boolean',
'is_proxy' => 'boolean',
'is_qr_scan' => 'boolean',
];
public function shortUrl(): BelongsTo

View File

@@ -39,7 +39,7 @@ class ProxyDetectionService
// Default to ip-api
return $this->queryIpApi($ip, $timeout);
} catch (\Throwable $e) {
Log::warning("Proxy detection failed for IP: {$ip}. Error: " . $e->getMessage());
Log::warning("Proxy detection failed for IP: {$ip}. Error: ".$e->getMessage());
return ['is_proxy' => false, 'is_bot' => false];
}

View File

@@ -61,7 +61,8 @@ class SafeBrowsingService
->post($endpoint, $payload);
if ($response->failed()) {
Log::warning("Google Safe Browsing API request failed with status: " . $response->status());
Log::warning('Google Safe Browsing API request failed with status: '.$response->status());
return true; // Default to safe if API is down
}
@@ -70,7 +71,8 @@ class SafeBrowsingService
// If there are matches, the URL is flagged as unsafe
return empty($matches);
} catch (\Throwable $e) {
Log::warning("Google Safe Browsing check failed: " . $e->getMessage());
Log::warning('Google Safe Browsing check failed: '.$e->getMessage());
return true; // Default to safe on exception
}
}

View File

@@ -25,16 +25,22 @@ class ShortUrlSettingsManager
return $this->cache;
}
$path = $this->getSettingsPath();
$stored = [];
if (File::exists($path)) {
try {
$stored = json_decode(File::get($path), true) ?: [];
} catch (\Throwable) {
// Fallback to empty if json is corrupt
$readFromFile = function () {
$path = $this->getSettingsPath();
if (File::exists($path)) {
try {
return json_decode(File::get($path), true) ?: [];
} catch (\Throwable) {
// Fallback to empty if json is corrupt
}
}
}
return [];
};
$stored = app()->bound('cache')
? cache()->remember('filament-short-url:settings', 86400, $readFromFile)
: $readFromFile();
// Merge stored settings with default values from config()
$this->cache = array_merge([
@@ -67,6 +73,7 @@ class ShortUrlSettingsManager
'tracking_fields_operating_system_version' => config('filament-short-url.tracking.fields.operating_system_version', true),
'tracking_fields_referer_url' => config('filament-short-url.tracking.fields.referer_url', true),
'tracking_fields_device_type' => config('filament-short-url.tracking.fields.device_type', true),
'tracking_fields_browser_language' => config('filament-short-url.tracking.fields.browser_language', true),
'qr_size' => config('filament-short-url.qr_defaults.size', 300),
'qr_margin' => config('filament-short-url.qr_defaults.margin', 1),
'qr_dot_style' => config('filament-short-url.qr_defaults.dot_style', 'square'),
@@ -146,6 +153,7 @@ class ShortUrlSettingsManager
'tracking_fields_operating_system_version',
'tracking_fields_referer_url',
'tracking_fields_device_type',
'tracking_fields_browser_language',
'qr_size',
'qr_margin',
'qr_dot_style',
@@ -241,6 +249,9 @@ class ShortUrlSettingsManager
if (isset($filtered['tracking_fields_device_type'])) {
$filtered['tracking_fields_device_type'] = (bool) $filtered['tracking_fields_device_type'];
}
if (isset($filtered['tracking_fields_browser_language'])) {
$filtered['tracking_fields_browser_language'] = (bool) $filtered['tracking_fields_browser_language'];
}
// QR defaults
if (isset($filtered['qr_size'])) {
@@ -266,6 +277,7 @@ class ShortUrlSettingsManager
}
File::put($path, json_encode($filtered, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
cache()->forget('filament-short-url:settings');
$this->cache = null;
// Apply immediately to current request config
@@ -321,6 +333,7 @@ class ShortUrlSettingsManager
'filament-short-url.tracking.fields.operating_system_version' => $settings['tracking_fields_operating_system_version'],
'filament-short-url.tracking.fields.referer_url' => $settings['tracking_fields_referer_url'],
'filament-short-url.tracking.fields.device_type' => $settings['tracking_fields_device_type'],
'filament-short-url.tracking.fields.browser_language' => $settings['tracking_fields_browser_language'],
'filament-short-url.qr_defaults.size' => $settings['qr_size'],
'filament-short-url.qr_defaults.margin' => $settings['qr_margin'],
'filament-short-url.qr_defaults.dot_style' => $settings['qr_dot_style'],

View File

@@ -34,6 +34,8 @@ class ShortUrlTracker
?string $utmCampaign = null,
?string $utmTerm = null,
?string $utmContent = null,
bool $isQrScan = false,
?string $browserLanguage = null,
): ?ShortUrlVisit {
$ip = ClientIpExtractor::getIp($request);
$ipHash = hash('sha256', $ip);
@@ -56,9 +58,19 @@ class ShortUrlTracker
// Determine uniqueness: first time this IP hash visits this URL.
// We check BEFORE insert to avoid a self-referential race.
$isUnique = ! ShortUrlVisit::where('short_url_id', $shortUrl->id)
->where('ip_hash', $ipHash)
->exists();
$isUnique = false;
if (config('filament-short-url.counter_buffering.enabled', false)) {
$uniqueCacheKey = "filament-short-url:unique-visit:{$shortUrl->id}:{$ipHash}";
if (cache()->add($uniqueCacheKey, true, 86400)) {
$isUnique = ! ShortUrlVisit::where('short_url_id', $shortUrl->id)
->where('ip_hash', $ipHash)
->exists();
}
} else {
$isUnique = ! ShortUrlVisit::where('short_url_id', $shortUrl->id)
->where('ip_hash', $ipHash)
->exists();
}
$visit = new ShortUrlVisit;
$visit->short_url_id = $shortUrl->id;
@@ -76,6 +88,8 @@ class ShortUrlTracker
$visit->city = $geo['city'] ?? null;
$visit->is_bot = $isBot;
$visit->is_proxy = $isProxy;
$visit->is_qr_scan = $isQrScan;
$visit->browser_language = $shortUrl->track_browser_language ? $browserLanguage : null;
$visit->utm_source = $utmSource;
$visit->utm_medium = $utmMedium;
@@ -114,7 +128,7 @@ class ShortUrlTracker
// Increment stats only if it's NOT a bot or proxy/VPN to keep analytics clean
if (! $isBot && ! $isProxy) {
$shortUrl->incrementVisits($isUnique);
$shortUrl->incrementVisits($isUnique, $isQrScan);
}
return $visit;