-
+ {{-- Section 4: Logo & Overlay --}}
+
+
+
+
+
+ {{-- Upload Area --}}
+
+
+
+
+
+
+
+
+
+
+
{{ __('filament-short-url::default.qr_label_drag_drop_upload') }}
+
{{ __('filament-short-url::default.qr_label_upload_supports') }}
+
+
+
+
+
+
+
+
{{ __('filament-short-url::default.qr_label_drag_drop_replace') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{-- Logo Details & Sliders (rendered if logo exists) --}}
+
+
+
+ {{ __('filament-short-url::default.qr_label_remove_logo') }}
+
+
+
+
+
{{ __('filament-short-url::default.qr_label_logo_shape') }}
+
+
+ {{ __('filament-short-url::default.qr_option_square') }}
+
+
+ {{ __('filament-short-url::default.qr_option_circle') }}
+
+
+
+
+
+
+ {{ __('filament-short-url::default.qr_label_logo_size') }}
+
+
+
+
+
+
+
+
+
+ {{ __('filament-short-url::default.qr_label_logo_margin') }}
+
+
+
+
+
+
+
+
+ {{ __('filament-short-url::default.qr_label_clear_dots') }}
+
+
+
-
@@ -432,59 +1231,51 @@
- {{-- ══ RIGHT: Preview ══ --}}
-
-
- {{-- Top bar --}}
-
-
{{ __('filament-short-url::default.qr_label_preview') }}
-
-
-
-
-
- PNG
-
-
-
-
-
- SVG
-
+ {{-- ══ RIGHT: Sticky Preview Card ══ --}}
+
+
+
+
{{ __('filament-short-url::default.qr_label_live_preview') }}
+
+
+ {{ __('filament-short-url::default.qr_label_png') }}
+
+
+ {{ __('filament-short-url::default.qr_label_svg') }}
+
+
-
- {{--
- Canvas box — ALL styles in :style (object) so Alpine does NOT overwrite
- the static style attribute (Alpine :style REPLACES, not merges, with static style).
- --}}
-
- {{-- Spinner --}}
-
+
+
+ {{-- Spinner --}}
+
+
+ {{-- QR canvas --}}
+
+
- {{-- QR canvas --}}
-
-
-
- {{ $shortUrl }}
-
+
+ {{ $shortUrl }}
+
+
-
diff --git a/resources/views/stats.blade.php b/resources/views/stats.blade.php
index 4288138..9976930 100644
--- a/resources/views/stats.blade.php
+++ b/resources/views/stats.blade.php
@@ -37,22 +37,17 @@
'dateTo' => $dateTo,
], key('stats-overview-' . $dateFrom . '-' . $dateTo))
-
-
- @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\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,
diff --git a/resources/views/widgets/visits-right-breakdown.blade.php b/resources/views/widgets/visits-right-breakdown.blade.php
index 23f0e4c..30593eb 100644
--- a/resources/views/widgets/visits-right-breakdown.blade.php
+++ b/resources/views/widgets/visits-right-breakdown.blade.php
@@ -1,5 +1,5 @@
-
+
{{-- Countries --}}
@@ -11,10 +11,13 @@
@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
- {{ $country }}
+ {{ $translatedCountry }}
{{ number_format($count) }} ({{ $pct }}%)
@@ -27,5 +30,34 @@
+ {{-- Languages --}}
+
+
+
+
+
+
{{ __('filament-short-url::default.stats_breakdown_languages') }}
+
+
+ @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
+
+
+ {{ $langName }} ({{ strtoupper($langCode) }})
+ {{ number_format($count) }} ({{ $pct }}%)
+
+
+
+ @empty
+
{{ __('filament-short-url::default.stats_no_language_data') }}
+ @endforelse
+
+
+
diff --git a/routes/web.php b/routes/web.php
index f2308a0..bf6b1b3 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -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');
diff --git a/src/Console/Commands/AggregateAndPruneVisitsCommand.php b/src/Console/Commands/AggregateAndPruneVisitsCommand.php
index 333802c..c68bfe3 100644
--- a/src/Console/Commands/AggregateAndPruneVisitsCommand.php
+++ b/src/Console/Commands/AggregateAndPruneVisitsCommand.php
@@ -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;
}
}
diff --git a/src/Console/Commands/SyncBufferedCountersCommand.php b/src/Console/Commands/SyncBufferedCountersCommand.php
index 9260444..04bf777 100644
--- a/src/Console/Commands/SyncBufferedCountersCommand.php
+++ b/src/Console/Commands/SyncBufferedCountersCommand.php
@@ -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;
diff --git a/src/Filament/Resources/ShortUrlResource/Pages/ListShortUrls.php b/src/Filament/Resources/ShortUrlResource/Pages/ListShortUrls.php
index 4f59a78..2c9ac56 100644
--- a/src/Filament/Resources/ShortUrlResource/Pages/ListShortUrls.php
+++ b/src/Filament/Resources/ShortUrlResource/Pages/ListShortUrls.php
@@ -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 = <<
+
+
+
+
+
+
+
-
Time to get some clicks 🎉
-
Your link & QR code are ready!
-
Copy and share manually or choose a platform.
+
{$successSubtitle}
+
{$successTitle}
+
{$successHelper}
@@ -77,8 +186,10 @@ class ListShortUrls extends ManageRecords
{$shortUrl}
+
+
+
+
+ {$downloadSvgText}
+ {$downloadPngText}
+
+
+
-
-
-
- Don't show me the sharing options after creating a link
-
+
+
+ {$dontShowAgainText}
+
@@ -142,7 +387,7 @@ HTML;
return [
Forms\Components\Placeholder::make('share_modal_content')
- ->label('')
+ ->hiddenLabel()
->content(new HtmlString($html)),
];
}),
diff --git a/src/Filament/Resources/ShortUrlResource/Pages/ShortUrlSettingsPage.php b/src/Filament/Resources/ShortUrlResource/Pages/ShortUrlSettingsPage.php
index 5b517a8..4f70ce9 100644
--- a/src/Filament/Resources/ShortUrlResource/Pages/ShortUrlSettingsPage.php
+++ b/src/Filament/Resources/ShortUrlResource/Pages/ShortUrlSettingsPage.php
@@ -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)
diff --git a/src/Filament/Resources/ShortUrlResource/Schemas/ShortUrlForm.php b/src/Filament/Resources/ShortUrlResource/Schemas/ShortUrlForm.php
index 4cf01d9..78fa557 100644
--- a/src/Filament/Resources/ShortUrlResource/Schemas/ShortUrlForm.php
+++ b/src/Filament/Resources/ShortUrlResource/Schemas/ShortUrlForm.php
@@ -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')
diff --git a/src/Filament/Resources/ShortUrlResource/Tables/ShortUrlsTable.php b/src/Filament/Resources/ShortUrlResource/Tables/ShortUrlsTable.php
index 08c08fc..f61f629 100644
--- a/src/Filament/Resources/ShortUrlResource/Tables/ShortUrlsTable.php
+++ b/src/Filament/Resources/ShortUrlResource/Tables/ShortUrlsTable.php
@@ -121,7 +121,7 @@ HTML
- '.$record->total_visits.' clicks
+ '.$record->total_visits.' '.__('filament-short-url::default.badge_clicks').'
@@ -129,7 +129,16 @@ HTML
- '.$record->unique_visits.' unique
+ '.$record->unique_visits.' '.__('filament-short-url::default.badge_unique').'
+
+
+
+
+
+
+
+
+ '.$record->qr_scans.' '.__('filament-short-url::default.badge_qr_scans').'
@@ -137,7 +146,7 @@ HTML
- '.$record->created_at->format('M d, Y').'
+ '.$record->created_at->translatedFormat('M d, Y').'
@@ -146,12 +155,12 @@ HTML
- Expires: '.$record->expires_at->format('M d, Y').'
+ '.__('filament-short-url::default.badge_expires', ['date' => $record->expires_at->translatedFormat('M d, Y')]).'
' : '
- No expiry
+ '.__('filament-short-url::default.badge_no_expiry').'
').'
@@ -160,7 +169,7 @@ HTML
- '.$record->redirect_status_code.' redirect
+ '.__('filament-short-url::default.badge_redirect', ['code' => $record->redirect_status_code]).'
')),
@@ -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 = <<
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{$shortUrl}
+
+
+
+
+
+
+
+ {$downloadSvgText}
+ {$downloadPngText}
+
+
+
+
+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')
diff --git a/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlStatsOverview.php b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlStatsOverview.php
index c56469d..3b93d78 100644
--- a/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlStatsOverview.php
+++ b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlStatsOverview.php
@@ -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'),
];
}
}
diff --git a/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsChart.php b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsChart.php
index 434924a..343896f 100644
--- a/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsChart.php
+++ b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsChart.php
@@ -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;
diff --git a/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsRightBreakdown.php b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsRightBreakdown.php
index bf2e961..d775a26 100644
--- a/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsRightBreakdown.php
+++ b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsRightBreakdown.php
@@ -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;
+ }
}
diff --git a/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlWorldMapWidget.php b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlWorldMapWidget.php
index 9347acf..90c1bd9 100644
--- a/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlWorldMapWidget.php
+++ b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlWorldMapWidget.php
@@ -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);
diff --git a/src/FilamentShortUrlServiceProvider.php b/src/FilamentShortUrlServiceProvider.php
index 122ce9f..c24c511 100644
--- a/src/FilamentShortUrlServiceProvider.php
+++ b/src/FilamentShortUrlServiceProvider.php
@@ -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
diff --git a/src/Http/Controllers/ShortUrlApiController.php b/src/Http/Controllers/ShortUrlApiController.php
index e661fd1..20e7358 100644
--- a/src/Http/Controllers/ShortUrlApiController.php
+++ b/src/Http/Controllers/ShortUrlApiController.php
@@ -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',
+ ]);
+ }
}
diff --git a/src/Http/Controllers/ShortUrlRedirectController.php b/src/Http/Controllers/ShortUrlRedirectController.php
index e966ac9..84e5fea 100644
--- a/src/Http/Controllers/ShortUrlRedirectController.php
+++ b/src/Http/Controllers/ShortUrlRedirectController.php
@@ -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) {
diff --git a/src/Jobs/IncrementVisitJob.php b/src/Jobs/IncrementVisitJob.php
index 67c5cbb..6efe735 100644
--- a/src/Jobs/IncrementVisitJob.php
+++ b/src/Jobs/IncrementVisitJob.php
@@ -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
);
}
}
diff --git a/src/Jobs/TrackShortUrlVisitJob.php b/src/Jobs/TrackShortUrlVisitJob.php
index 594b486..63e5331 100644
--- a/src/Jobs/TrackShortUrlVisitJob.php
+++ b/src/Jobs/TrackShortUrlVisitJob.php
@@ -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,
],
];
diff --git a/src/Models/ShortUrl.php b/src/Models/ShortUrl.php
index c5657a9..91da7b2 100644
--- a/src/Models/ShortUrl.php
+++ b/src/Models/ShortUrl.php
@@ -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
*/
@@ -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 */
@@ -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 */
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;
+ }
}
diff --git a/src/Models/ShortUrlDailyStats.php b/src/Models/ShortUrlDailyStats.php
index 0b3d25d..72a8a59 100644
--- a/src/Models/ShortUrlDailyStats.php
+++ b/src/Models/ShortUrlDailyStats.php
@@ -42,6 +42,8 @@ class ShortUrlDailyStats extends Model
'utm_source_stats',
'utm_medium_stats',
'utm_campaign_stats',
+ 'qr_visits_count',
+ 'language_stats',
];
/** @var array */
@@ -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
diff --git a/src/Models/ShortUrlVisit.php b/src/Models/ShortUrlVisit.php
index 5e645a5..6f201d3 100644
--- a/src/Models/ShortUrlVisit.php
+++ b/src/Models/ShortUrlVisit.php
@@ -49,6 +49,8 @@ class ShortUrlVisit extends Model
'visited_at',
'is_bot',
'is_proxy',
+ 'is_qr_scan',
+ 'browser_language',
];
/** @var array */
@@ -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
diff --git a/src/Services/ProxyDetectionService.php b/src/Services/ProxyDetectionService.php
index 6cfcf39..f8bdec3 100644
--- a/src/Services/ProxyDetectionService.php
+++ b/src/Services/ProxyDetectionService.php
@@ -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];
}
diff --git a/src/Services/SafeBrowsingService.php b/src/Services/SafeBrowsingService.php
index 8f64908..27173f9 100644
--- a/src/Services/SafeBrowsingService.php
+++ b/src/Services/SafeBrowsingService.php
@@ -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
}
}
diff --git a/src/Services/ShortUrlSettingsManager.php b/src/Services/ShortUrlSettingsManager.php
index b61a445..a15a379 100644
--- a/src/Services/ShortUrlSettingsManager.php
+++ b/src/Services/ShortUrlSettingsManager.php
@@ -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'],
diff --git a/src/Services/ShortUrlTracker.php b/src/Services/ShortUrlTracker.php
index e960953..c0abd74 100644
--- a/src/Services/ShortUrlTracker.php
+++ b/src/Services/ShortUrlTracker.php
@@ -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;