diff --git a/README.md b/README.md index 5386682..6a3c12f 100644 --- a/README.md +++ b/README.md @@ -20,15 +20,15 @@ A professional, high-performance **Short URL Manager** plugin for [Filament v5](

- - + + - - + + - +
Dashboard StatsShort URL Creation FormDashboard StatsShort URL Creation Form
Targeting and RulesInteractive Settings PanelTargeting and RulesInteractive Settings Panel
Visit Logs TableVisit Logs Table

@@ -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. diff --git a/art/1.png b/art/1.png deleted file mode 100644 index 50ed1ca..0000000 Binary files a/art/1.png and /dev/null differ diff --git a/art/2.png b/art/2.png deleted file mode 100644 index 7ab3e38..0000000 Binary files a/art/2.png and /dev/null differ diff --git a/art/3.png b/art/3.png deleted file mode 100644 index ccebce5..0000000 Binary files a/art/3.png and /dev/null differ diff --git a/art/4.png b/art/4.png deleted file mode 100644 index 3f16675..0000000 Binary files a/art/4.png and /dev/null differ diff --git a/art/v2_1.png b/art/v2_1.png new file mode 100644 index 0000000..2aab23b Binary files /dev/null and b/art/v2_1.png differ diff --git a/art/v2_2.png b/art/v2_2.png new file mode 100644 index 0000000..c81b988 Binary files /dev/null and b/art/v2_2.png differ diff --git a/art/v2_3.png b/art/v2_3.png new file mode 100644 index 0000000..300da5d Binary files /dev/null and b/art/v2_3.png differ diff --git a/art/v2_4.png b/art/v2_4.png new file mode 100644 index 0000000..baa78c7 Binary files /dev/null and b/art/v2_4.png differ diff --git a/art/5.png b/art/v2_5.png similarity index 100% rename from art/5.png rename to art/v2_5.png diff --git a/config/filament-short-url.php b/config/filament-short-url.php index 3997e85..3611238 100644 --- a/config/filament-short-url.php +++ b/config/filament-short-url.php @@ -78,6 +78,7 @@ return [ 'operating_system_version' => true, 'referer_url' => true, 'device_type' => true, + 'browser_language' => true, ], ], diff --git a/database/migrations/2026_06_02_224250_add_qr_logo_to_short_urls_table.php b/database/migrations/2026_06_02_224250_add_qr_logo_to_short_urls_table.php new file mode 100644 index 0000000..acc2724 --- /dev/null +++ b/database/migrations/2026_06_02_224250_add_qr_logo_to_short_urls_table.php @@ -0,0 +1,28 @@ +string('qr_logo')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('short_urls', function (Blueprint $table) { + $table->dropColumn('qr_logo'); + }); + } +}; diff --git a/database/migrations/2026_06_03_110000_add_qr_and_language_to_visits_and_daily_stats.php b/database/migrations/2026_06_03_110000_add_qr_and_language_to_visits_and_daily_stats.php new file mode 100644 index 0000000..4297c51 --- /dev/null +++ b/database/migrations/2026_06_03_110000_add_qr_and_language_to_visits_and_daily_stats.php @@ -0,0 +1,43 @@ +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']); + }); + } +}; diff --git a/database/migrations/2026_06_03_120000_add_track_browser_language_to_short_urls_table.php b/database/migrations/2026_06_03_120000_add_track_browser_language_to_short_urls_table.php new file mode 100644 index 0000000..fb4b783 --- /dev/null +++ b/database/migrations/2026_06_03_120000_add_track_browser_language_to_short_urls_table.php @@ -0,0 +1,22 @@ +boolean('track_browser_language')->default(true); + }); + } + + public function down(): void + { + Schema::table('short_urls', function (Blueprint $table): void { + $table->dropColumn('track_browser_language'); + }); + } +}; diff --git a/resources/lang/en/default.php b/resources/lang/en/default.php index 1c60b69..157d666 100644 --- a/resources/lang/en/default.php +++ b/resources/lang/en/default.php @@ -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', ]; diff --git a/resources/lang/pl/default.php b/resources/lang/pl/default.php index a4a1a95..f831035 100644 --- a/resources/lang/pl/default.php +++ b/resources/lang/pl/default.php @@ -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', ]; diff --git a/resources/views/qr-designer.blade.php b/resources/views/qr-designer.blade.php index 7b43f53..5420029 100644 --- a/resources/views/qr-designer.blade.php +++ b/resources/views/qr-designer.blade.php @@ -1,8 +1,24 @@ @php /** @var \Bjanczak\FilamentShortUrl\Models\ShortUrl|null $record */ - $record = $this->record ?? null; - $shortUrl = $record ? $record->getShortUrl() : (config('app.url').'/s/preview'); + $record = $record ?? (isset($getRecord) && is_callable($getRecord) ? $getRecord() : (isset($component) ? $component->getRecord() : null)); + $shortUrl = $record ? ($record->getShortUrl() . '?source=qr') : (config('app.url').'/s/preview?source=qr'); $opts = $record ? $record->getQrOptions() : config('filament-short-url.qr_defaults', []); + + $qrOptionsStatePath = 'qr_options'; + $qrLogoStatePath = 'qr_logo'; + + if (isset($component) && method_exists($component, 'getStatePath')) { + $statePath = $component->getStatePath(); + \Illuminate\Support\Facades\Log::info('QR DESIGNER STATE PATH: ' . $statePath); + if ($statePath) { + $parts = explode('.', $statePath); + if (end($parts) === 'qr_designer') { + array_pop($parts); + $qrOptionsStatePath = implode('.', array_merge($parts, ['qr_options'])); + $qrLogoStatePath = implode('.', array_merge($parts, ['qr_logo'])); + } + } + } @endphp @@ -151,25 +521,45 @@ eyeSquareStyle: @js($opts['eye_square_style'] ?? 'square'), eyeDotStyle: @js($opts['eye_dot_style'] ?? 'square'), eyeColor: @js($opts['eye_color'] ?? '#000000'), + logo: @js($opts['logo'] ?? ''), + logoSize: {{ $opts['logo_size'] ?? 0.3 }}, + logoMargin: {{ $opts['logo_margin'] ?? 9 }}, + logoHideBackground: {{ ($opts['logo_hide_background'] ?? true) ? 'true' : 'false' }}, + logoShape: @js($opts['logo_shape'] ?? 'square'), + logoPath: @js($record ? $record->qr_logo : ''), qrInstance: null, + processedLogo: '', + activeSection: 'basic', + dragOver: false, + uploading: false, + uploadProgress: 0, init() { this.$nextTick(() => { this.loadScript().then(() => { - this.render(); - ['size','margin','dotStyle','colorMode','fgColor','gradientFrom', - 'gradientTo','gradientType','bgTransparent','bgColor', - 'eyeConfigEnabled','eyeSquareStyle','eyeDotStyle','eyeColor' - ].forEach(k => this.$watch(k, () => { this.syncDom(); this.render(); })); + this.updateProcessedLogo().then(() => { + this.render(); + ['size','margin','dotStyle','colorMode','fgColor','gradientFrom', + 'gradientTo','gradientType','bgTransparent','bgColor', + 'eyeConfigEnabled','eyeSquareStyle','eyeDotStyle','eyeColor', + 'logoSize' + ].forEach(k => this.$watch(k, () => { this.syncDom(); this.render(); })); - // Re-render when the QR tab becomes visible (Filament uses x-show on tab panels) - const canvas = this.$refs.qrCanvas; - if (canvas) { - const obs = new IntersectionObserver(entries => { - if (entries[0].isIntersecting) { this.render(); } - }); - obs.observe(canvas); - } + // Watchers for logo/background/shape/margin to update processed image dynamically + this.$watch('logo', () => { this.updateProcessedLogo().then(() => { this.syncDom(); this.render(); }); }); + this.$watch('logoHideBackground', () => { this.updateProcessedLogo().then(() => { this.syncDom(); this.render(); }); }); + this.$watch('logoShape', () => { this.updateProcessedLogo().then(() => { this.syncDom(); this.render(); }); }); + this.$watch('logoMargin', () => { this.updateProcessedLogo().then(() => { this.syncDom(); this.render(); }); }); + + // Re-render when the QR tab becomes visible (Filament uses x-show on tab panels) + const canvas = this.$refs.qrCanvas; + if (canvas) { + const obs = new IntersectionObserver(entries => { + if (entries[0].isIntersecting) { this.render(); } + }); + obs.observe(canvas); + } + }); }); }); }, @@ -192,6 +582,118 @@ }); }, + updateProcessedLogo() { + if (!this.logo) { + this.processedLogo = ''; + return Promise.resolve(); + } + + const processImage = (img, hasCrossOrigin) => { + try { + const canvas = document.createElement('canvas'); + canvas.width = 1200; + canvas.height = 1200; + const ctx = canvas.getContext('2d'); + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + + const isCircle = this.logoShape === 'circle'; + const targetDim = 1200 - (this.logoMargin * 20); // Dynamic dimension based on slider + + // Draw the image + ctx.save(); + if (isCircle) { + // Clip drawing to the circle + ctx.beginPath(); + ctx.arc(600, 600, targetDim / 2, 0, 2 * Math.PI); + ctx.clip(); + + // object-fit: cover scaling logic for circular logo + 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; + + ctx.drawImage(img, x, y, w, h); + } else { + // object-fit: cover scaling logic for square logo + ctx.beginPath(); + const offset = (1200 - targetDim) / 2; + const radius = 144 * (targetDim / 1200); // Scale the radius dynamically + + if (typeof ctx.roundRect === 'function') { + ctx.roundRect(offset, offset, targetDim, targetDim, radius); + } else { + ctx.moveTo(offset + radius, offset); + ctx.lineTo(offset + targetDim - radius, offset); + ctx.quadraticCurveTo(offset + targetDim, offset, offset + targetDim, offset + radius); + ctx.lineTo(offset + targetDim, offset + targetDim - radius); + ctx.quadraticCurveTo(offset + targetDim, offset + targetDim, offset + targetDim - radius, offset + targetDim); + ctx.lineTo(offset + radius, offset + targetDim); + ctx.quadraticCurveTo(offset, offset + targetDim, offset, offset + targetDim - radius); + ctx.lineTo(offset, offset + radius); + ctx.quadraticCurveTo(offset, offset, offset + radius, offset); + ctx.closePath(); + } + ctx.clip(); + + // object-fit: cover scaling + 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; + + ctx.drawImage(img, x, y, w, h); + } + ctx.restore(); + + return canvas.toDataURL('image/png'); + } catch (e) { + if (hasCrossOrigin) { + throw e; // rethrow to trigger loadWithoutCORS + } + console.warn('Canvas processing failed (possibly CORS), falling back to raw logo:', e); + return this.logo; + } + }; + + return new Promise((resolve) => { + const img = new Image(); + img.crossOrigin = 'anonymous'; + + img.onload = () => { + try { + this.processedLogo = processImage(img, true); + resolve(); + } catch (e) { + loadWithoutCORS(); + } + }; + + const loadWithoutCORS = () => { + const imgRetry = new Image(); + imgRetry.onload = () => { + this.processedLogo = processImage(imgRetry, false); + resolve(); + }; + imgRetry.onerror = () => { + console.error('Failed to load logo image entirely:', this.logo); + this.processedLogo = ''; + resolve(); + }; + imgRetry.src = this.logo; + }; + + img.onerror = () => { + loadWithoutCORS(); + }; + + img.src = this.logo; + }); + }, + buildOptions() { const isGrad = this.colorMode === 'gradient'; const dotsOptions = isGrad @@ -207,29 +709,83 @@ ? { type: this.eyeDotStyle, color: this.eyeColor } : { type: this.dotStyle === 'dots' ? 'dot' : 'square', color: mainColor }; - return { + const options = { + type: 'svg', width: +this.size || 300, height: +this.size || 300, data: this.url, margin: +this.margin || 1, dotsOptions, backgroundOptions: this.bgTransparent ? { color: 'rgba(0,0,0,0)' } : { color: this.bgColor }, cornersSquareOptions: eyeSq, cornersDotOptions: eyeDt, - qrOptions: { errorCorrectionLevel: 'M' }, + qrOptions: { errorCorrectionLevel: this.logo ? 'H' : 'M' }, }; + + if (this.processedLogo) { + options.image = this.processedLogo; + options.imageOptions = { + crossOrigin: 'anonymous', + hideBackgroundDots: this.logoHideBackground, + imageSize: parseFloat(this.logoSize) || 0.3, + margin: 0, + logoShape: this.logoShape + }; + } + + return options; }, render() { - const el = this.$refs.qrCanvas; - if (!el || !window.QRCodeStyling) return; - el.innerHTML = ''; - this.qrInstance = new window.QRCodeStyling(this.buildOptions()); - this.qrInstance.append(el); + try { + const el = this.$refs.qrCanvas; + if (!el || !window.QRCodeStyling) return; + el.innerHTML = ''; + const opts = this.buildOptions(); + + fetch('/admin/short-url/log-debug', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': @js(csrf_token()) + }, + body: JSON.stringify({ + event: 'render_start', + logo: this.logo, + processedLogo_length: this.processedLogo ? this.processedLogo.length : 0, + options: opts + }) + }); + + this.qrInstance = new window.QRCodeStyling(opts); + this.qrInstance.append(el); + + const svg = el.querySelector('svg'); + if (svg) { + const w = svg.getAttribute('width') || this.size || 300; + const h = svg.getAttribute('height') || this.size || 300; + svg.setAttribute('viewBox', '0 0 ' + w + ' ' + h); + svg.style.width = '100%'; + svg.style.height = '100%'; + svg.style.maxWidth = '100%'; + svg.style.maxHeight = '100%'; + } + } catch (err) { + fetch('/admin/short-url/log-debug', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': @js(csrf_token()) + }, + body: JSON.stringify({ + event: 'render_error', + error: err.message, + stack: err.stack + }) + }); + } }, syncDom() { - const el = document.getElementById('qr-options-json-input'); - if (!el) return; - el.value = JSON.stringify({ + const optionsVal = { size: +this.size, margin: +this.margin, dot_style: this.dotStyle, color_mode: this.colorMode, foreground_color: this.fgColor, gradient_from: this.gradientFrom, gradient_to: this.gradientTo, @@ -237,194 +793,437 @@ background_color: this.bgColor, eye_config_enabled: this.eyeConfigEnabled, eye_square_style: this.eyeSquareStyle, eye_dot_style: this.eyeDotStyle, eye_color: this.eyeColor, + logo: this.logo, logo_size: parseFloat(this.logoSize), + logo_margin: parseFloat(this.logoMargin), logo_hide_background: this.logoHideBackground, + logo_shape: this.logoShape, + }; + + const optionsJson = JSON.stringify(optionsVal); + + const findFormInput = (nameSuffix, fallbackId) => { + const parent = this.$el.closest('form') || this.$el.closest('.fi-modal-window') || this.$el.closest('.fi-fo-component-container'); + if (parent) { + const el = parent.querySelector('input[name*=' + nameSuffix + ']'); + if (el) return el; + } + return document.getElementById(fallbackId); + }; + + const optionsEl = findFormInput('qr_options', 'qr-options-json-input'); + const logoEl = findFormInput('qr_logo', 'qr-logo-path-input'); + + if (optionsEl) { + optionsEl.value = optionsJson; + } + if (logoEl) { + logoEl.value = this.logoPath; + } + + const resolveStatePath = (el, fallback) => { + if (!el) return fallback; + for (let i = 0; i < el.attributes.length; i++) { + const attr = el.attributes[i]; + if (attr.name.startsWith('wire:model')) { + return attr.value; + } + } + const name = el.getAttribute('name'); + if (name) { + return name.replace(/\[/g, '.').replace(/\]/g, ''); + } + return fallback; + }; + + const optionsPath = resolveStatePath(optionsEl, @js($qrOptionsStatePath)); + const logoPath = resolveStatePath(logoEl, @js($qrLogoStatePath)); + + if (optionsPath && optionsPath.includes('.')) { + this.$wire.set(optionsPath, optionsJson); + } + if (logoPath && logoPath.includes('.')) { + this.$wire.set(logoPath, this.logoPath); + } + + fetch('/admin/short-url/log-debug', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': @js(csrf_token()) + }, + body: JSON.stringify({ + optionsEl_exists: !!optionsEl, + logoEl_exists: !!logoEl, + optionsEl_name: optionsEl ? optionsEl.getAttribute('name') : null, + logoEl_name: logoEl ? logoEl.getAttribute('name') : null, + optionsPath: optionsPath, + logoPath: logoPath, + logoPathValue: this.logoPath, + }) }); - el.dispatchEvent(new Event('input', { bubbles: true })); }, download(ext) { this.qrInstance?.download({ name: 'qr-code', extension: ext }); }, setHex(field, val) { if (/^#[0-9A-Fa-f]{6}$/.test(val)) this[field] = val; }, + handleLogoUpload(file) { + if (!file) return; + + this.uploading = true; + this.uploadProgress = 0; + + const formData = new FormData(); + formData.append('logo', file); + + const xhr = new XMLHttpRequest(); + xhr.open('POST', '/admin/short-url/upload-logo'); + xhr.setRequestHeader('X-CSRF-TOKEN', @js(csrf_token())); + + xhr.upload.onprogress = (e) => { + if (e.lengthComputable) { + this.uploadProgress = Math.round((e.loaded / e.total) * 100); + } + }; + + xhr.onload = () => { + this.uploading = false; + if (xhr.status >= 200 && xhr.status < 300) { + try { + const data = JSON.parse(xhr.responseText); + this.logoPath = data.path; + this.logo = data.url; + this.logoMargin = 9; + + this.updateProcessedLogo().then(() => { + this.syncDom(); + this.render(); + }); + } catch (err) { + console.error('Parsing response failed:', err); + alert('{{ addslashes(__('filament-short-url::default.qr_logo_upload_parse_error')) }}'); + } + } else { + console.error('Logo upload failed:', xhr.status, xhr.responseText); + alert('{{ addslashes(__('filament-short-url::default.qr_logo_upload_error')) }}'); + } + }; + + xhr.onerror = () => { + this.uploading = false; + alert('{{ addslashes(__('filament-short-url::default.qr_logo_upload_connection_error')) }}'); + }; + + xhr.send(formData); + }, }" class="w-full" > -{{-- CSS Grid: left=fixed 280px, right=fills remaining space, both columns same height --}} -
+
+ {{-- ══ LEFT: settings panel (Accordions) ══ --}} +
- {{-- ══ LEFT: settings panel ══ --}} -
- - {{-- Size & Margin --}} -
-
-
- {{ __('filament-short-url::default.qr_label_size') }} - -
-
- {{ __('filament-short-url::default.qr_label_margin') }} - -
-
-
- - {{-- Dot Style --}} -
- {{ __('filament-short-url::default.qr_label_style') }} - -
- - {{-- Foreground Color --}} -
- {{ __('filament-short-url::default.qr_label_foreground_color') }} - - {{-- Mode radio --}} -
- - -
- - {{-- Single color picker --}} -
- {{ __('filament-short-url::default.qr_label_color') }} -
-
- -
- -
-
- - {{-- Gradient pickers --}} -