Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8d6b1b22f | ||
|
|
0d4eab2016 | ||
|
|
0349825890 | ||
|
|
e1e6ded015 | ||
|
|
c5147ac170 | ||
|
|
0cdc4ad0db | ||
|
|
5e2b266793 | ||
|
|
26247fd94a | ||
|
|
f3623df340 | ||
|
|
48ecb8ed28 | ||
|
|
d67bd56f68 | ||
|
|
d075ee83b0 | ||
|
|
67ce293685 | ||
|
|
ce8062036a | ||
|
|
e8e9f26c9b | ||
|
|
da810db511 | ||
|
|
8de83e3973 | ||
|
|
a5421a2d30 | ||
|
|
d1eb322cb6 | ||
|
|
3d62dbcbb7 | ||
|
|
c650c1f205 |
120
README.md
120
README.md
@@ -20,12 +20,15 @@ A professional, high-performance **Short URL Manager** plugin for [Filament v5](
|
||||
<p align="center">
|
||||
<table align="center" style="border-collapse: collapse; border: none;">
|
||||
<tr style="border: none;">
|
||||
<td width="50%" style="border: none; padding: 5px;"><img src="art/1.png" alt="Dashboard Stats" style="border-radius: 8px;"></td>
|
||||
<td width="50%" style="border: none; padding: 5px;"><img src="art/2.png" alt="Short URL Creation Form" style="border-radius: 8px;"></td>
|
||||
<td width="50%" style="border: none; padding: 5px;"><img src="art/v2_1.png" alt="Dashboard Stats" style="border-radius: 8px;"></td>
|
||||
<td width="50%" style="border: none; padding: 5px;"><img src="art/v2_2.png" alt="Short URL Creation Form" style="border-radius: 8px;"></td>
|
||||
</tr>
|
||||
<tr style="border: none;">
|
||||
<td width="50%" style="border: none; padding: 5px;"><img src="art/3.png" alt="Targeting and Rules" style="border-radius: 8px;"></td>
|
||||
<td width="50%" style="border: none; padding: 5px;"><img src="art/4.png" alt="Interactive Settings Panel" style="border-radius: 8px;"></td>
|
||||
<td width="50%" style="border: none; padding: 5px;"><img src="art/v2_3.png" alt="Targeting and Rules" style="border-radius: 8px;"></td>
|
||||
<td width="50%" style="border: none; padding: 5px;"><img src="art/v2_4.png" alt="Interactive Settings Panel" style="border-radius: 8px;"></td>
|
||||
</tr>
|
||||
<tr style="border: none;">
|
||||
<td colspan="2" style="border: none; padding: 5px;"><img src="art/v2_5.png" alt="Visit Logs Table" style="border-radius: 8px; width: 100%;"></td>
|
||||
</tr>
|
||||
</table>
|
||||
</p>
|
||||
@@ -40,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).
|
||||
@@ -178,6 +183,54 @@ All fluent methods on the plugin are optional. If not called, the plugin falls b
|
||||
| `navigationLabel(string)` | `'Short URLs'` | Overrides the menu item display name. |
|
||||
| `navigationIcon(string)` | `heroicon-o-link` | Overrides the Heroicon used in the sidebar. |
|
||||
| `navigationSort(int)` | `50` | Controls the sort order within the navigation list. |
|
||||
| `authorizeSettingsUsing(Closure)` | `null` | Restricts access to the Settings page using a custom callback. See [Restricting Settings Access](#restricting-settings-access). |
|
||||
|
||||
---
|
||||
|
||||
## Restricting Settings Access
|
||||
|
||||
By default, any user who can view Short URLs can also access the Settings page. You can restrict this to specific roles or permissions using the `authorizeSettingsUsing()` method:
|
||||
|
||||
```php
|
||||
FilamentShortUrlPlugin::make()
|
||||
->authorizeSettingsUsing(fn () => auth()->user()->hasRole('admin'))
|
||||
```
|
||||
|
||||
The callback can be any closure that returns a `bool`. When it returns `false`, the Settings page returns a 403 and the **Settings** button in the table header is automatically hidden.
|
||||
|
||||
### With `spatie/laravel-permission`
|
||||
|
||||
```php
|
||||
FilamentShortUrlPlugin::make()
|
||||
->authorizeSettingsUsing(fn () => auth()->user()->hasRole('admin'))
|
||||
// or permission-based:
|
||||
->authorizeSettingsUsing(fn () => auth()->user()->can('manage short-url settings'))
|
||||
```
|
||||
|
||||
### Via a Laravel Policy
|
||||
|
||||
Alternatively, define a `manageSettings` method in a Policy for the `ShortUrl` model — the plugin detects it automatically without any plugin configuration:
|
||||
|
||||
```php
|
||||
// app/Policies/ShortUrlPolicy.php
|
||||
public function manageSettings(User $user): bool
|
||||
{
|
||||
return $user->is_admin;
|
||||
}
|
||||
```
|
||||
|
||||
Then register it in `AuthServiceProvider`:
|
||||
|
||||
```php
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use App\Policies\ShortUrlPolicy;
|
||||
|
||||
protected $policies = [
|
||||
ShortUrl::class => ShortUrlPolicy::class,
|
||||
];
|
||||
```
|
||||
|
||||
> **Priority order:** `authorizeSettingsUsing()` callback → `ShortUrlPolicy@manageSettings` → default `canViewAny()` fallback.
|
||||
|
||||
---
|
||||
|
||||
@@ -190,7 +243,7 @@ Settings are stored dynamically in `storage/app/filament-short-url-settings.json
|
||||
The settings panel allows you to configure:
|
||||
|
||||
### 1. General Routing & Queueing
|
||||
* **Route Prefix**: The slug prepended to short URLs (e.g. `s` for `/s/{key}`).
|
||||
* **Route Prefix**: The slug prepended to short URLs (e.g. `s` for `/s/{key}`). Can be left empty to serve links directly from the root domain (e.g. `domain.com/{key}`).
|
||||
* **Default Redirect Status**: Choose `302 (Found / Temporary)` or `301 (Moved Permanently)`.
|
||||
* *Note: `302` is highly recommended for analytics accuracy because browsers cache `301` redirects, skipping subsequent logs.*
|
||||
* **Key Length**: Default character count (base62) for auto-generated keys (default: `6`).
|
||||
@@ -210,27 +263,17 @@ Sends server-side `short_url_visit` hits using the **GA4 Measurement Protocol AP
|
||||
### 4. Counter Buffering (Write-back Caching)
|
||||
For extremely high-traffic applications, direct database writes for click counts can cause row-locking bottlenecks.
|
||||
* **Buffer Click Counts**: Toggling this option buffers total and unique visit count increments in the application cache.
|
||||
* **Cron Synchronization**: When enabled, you must schedule the synchronization command to run periodically (e.g., every minute) to flush counts to the database:
|
||||
```bash
|
||||
php artisan short-url:sync-counters
|
||||
```
|
||||
In your scheduler (`routes/console.php` or `app/Console/Kernel.php`):
|
||||
```php
|
||||
$schedule->command('short-url:sync-counters')->everyMinute();
|
||||
```
|
||||
* **Cron Synchronization**: When enabled, the synchronization command flushes counts to the database. The package automatically registers this in the Laravel Scheduler to run every minute when counter buffering is active, so you only need to ensure the standard Laravel schedule runner (`* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1`) is running on your server.
|
||||
* Command: `php artisan short-url:sync-counters`
|
||||
|
||||
### 5. Performance & Security Tab (new in v1.2.0)
|
||||
|
||||
#### High-Traffic Log Management (Aggregation & Pruning)
|
||||
At scale, the `short_url_visits` table can grow to tens of gigabytes. The aggregation system solves this:
|
||||
* **Enable Daily Aggregation**: When enabled, the nightly `short-url:aggregate-and-prune` command summarizes the previous day's raw visit records into the compact `short_url_daily_stats` table.
|
||||
* **Enable Daily Aggregation**: When enabled, the stats are summarized. The package automatically registers the aggregation command in the Laravel Scheduler to run daily at 02:00, so you only need to ensure the standard Laravel schedule runner is running on your server.
|
||||
* Command: `php artisan short-url:aggregate-and-prune`
|
||||
* **Prune Raw Logs After (days)**: Raw visit records older than this threshold are permanently deleted after aggregation. Set to `0` to disable pruning. Default: `90` days.
|
||||
|
||||
Schedule the command in your scheduler:
|
||||
```php
|
||||
$schedule->command('short-url:aggregate-and-prune')->dailyAt('02:00');
|
||||
```
|
||||
|
||||
#### Rate Limiting / Bot Protection
|
||||
Prevent redirect abuse and bot traffic flooding:
|
||||
* **Enable Rate Limiting**: Activates per-IP rate limiting on all redirect routes.
|
||||
@@ -320,7 +363,23 @@ $shortUrl->update([
|
||||
]);
|
||||
```
|
||||
|
||||
#### 3. A/B Split Rotation
|
||||
#### 3. Browser Language-Based Redirects (new in v2.0.5)
|
||||
Route visitors to language-specific URLs based on their browser's language preferences (detected from the `Accept-Language` header). Fully supports matching exact regional locales (e.g., `en-US`, `en-GB`) with automatic fallback to base language codes (e.g., `en`, `pl`).
|
||||
|
||||
```php
|
||||
$shortUrl->update([
|
||||
'targeting_rules' => [
|
||||
'type' => 'language',
|
||||
'language' => [
|
||||
['language_code' => 'pl', 'url' => 'https://pl.example.com'],
|
||||
['language_code' => 'en-US', 'url' => 'https://us.example.com'],
|
||||
['language_code' => 'de', 'url' => 'https://de.example.com'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
```
|
||||
|
||||
#### 4. A/B Split Rotation
|
||||
Distribute traffic across multiple URLs using weighted random selection. Weights are proportional — they do not need to sum to 100.
|
||||
|
||||
```php
|
||||
@@ -359,6 +418,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.
|
||||
|
||||
@@ -531,6 +592,11 @@ curl -X POST https://yourdomain.com/api/short-url/links \
|
||||
| `pixel_google_id` | string | ❌ | Google Tag / GA4 ID |
|
||||
| `pixel_linkedin_id` | string | ❌ | LinkedIn Partner ID |
|
||||
| `webhook_url` | string (URL) | ❌ | Per-link webhook endpoint |
|
||||
| `targeting_rules` | array | ❌ | JSON targeting rules (device, geo, rotation, language) |
|
||||
| `password` | string | ❌ | Access protection password |
|
||||
| `show_warning_page` | boolean | ❌ | Show safety warning page before redirect |
|
||||
| `track_visits` | boolean | ❌ | Track visitor clicks and logs |
|
||||
| `track_browser_language` | boolean | ❌ | Track visitor browser language locale |
|
||||
|
||||
**Response:** `201 Created` with the created link object.
|
||||
|
||||
@@ -802,6 +868,18 @@ 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.
|
||||
|
||||
### v1.6.0
|
||||
- **Google Safe Browsing Integration** — Automatic safety checks against Google's API during link creation or modification. Includes bypass settings, asynchronous checking option, and alert badges.
|
||||
- **VPN / Proxy / Bot Filtering** — Detect and filter out VPN/proxy traffic and Tor nodes using external proxy detection APIs to keep traffic analytics clean.
|
||||
|
||||
BIN
art/v2_1.png
Normal file
BIN
art/v2_1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 345 KiB |
BIN
art/v2_2.png
Normal file
BIN
art/v2_2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 292 KiB |
BIN
art/v2_3.png
Normal file
BIN
art/v2_3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 451 KiB |
BIN
art/v2_4.png
Normal file
BIN
art/v2_4.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 247 KiB |
BIN
art/v2_5.png
Normal file
BIN
art/v2_5.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 484 KiB |
@@ -78,6 +78,7 @@ return [
|
||||
'operating_system_version' => true,
|
||||
'referer_url' => true,
|
||||
'device_type' => true,
|
||||
'browser_language' => true,
|
||||
],
|
||||
],
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('short_urls', function (Blueprint $table) {
|
||||
$table->string('qr_logo')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('short_urls', function (Blueprint $table) {
|
||||
$table->dropColumn('qr_logo');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('short_urls', function (Blueprint $table): void {
|
||||
$table->integer('qr_scans')->default(0);
|
||||
});
|
||||
|
||||
Schema::table('short_url_visits', function (Blueprint $table): void {
|
||||
$table->boolean('is_qr_scan')->default(false)->index();
|
||||
$table->string('browser_language', 10)->nullable()->index();
|
||||
});
|
||||
|
||||
Schema::table('short_url_daily_stats', function (Blueprint $table): void {
|
||||
$table->integer('qr_visits_count')->default(0);
|
||||
$table->json('language_stats')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('short_urls', function (Blueprint $table): void {
|
||||
$table->dropColumn('qr_scans');
|
||||
});
|
||||
|
||||
Schema::table('short_url_visits', function (Blueprint $table): void {
|
||||
$table->dropIndex(['is_qr_scan']);
|
||||
$table->dropColumn('is_qr_scan');
|
||||
$table->dropIndex(['browser_language']);
|
||||
$table->dropColumn('browser_language');
|
||||
});
|
||||
|
||||
Schema::table('short_url_daily_stats', function (Blueprint $table): void {
|
||||
$table->dropColumn(['qr_visits_count', 'language_stats']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('short_urls', function (Blueprint $table): void {
|
||||
$table->boolean('track_browser_language')->default(true);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('short_urls', function (Blueprint $table): void {
|
||||
$table->dropColumn('track_browser_language');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// 1. Create short_url_pixels table
|
||||
Schema::create('short_url_pixels', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name', 150);
|
||||
$table->string('type', 50); // meta, google, linkedin, tiktok, pinterest
|
||||
$table->string('pixel_id', 100);
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['type', 'pixel_id']);
|
||||
});
|
||||
|
||||
// 2. Create short_url_pixel pivot table
|
||||
Schema::create('short_url_pixel', function (Blueprint $table) {
|
||||
$table->foreignId('short_url_id')->constrained('short_urls')->cascadeOnDelete();
|
||||
$table->foreignId('pixel_id')->constrained('short_url_pixels')->cascadeOnDelete();
|
||||
$table->primary(['short_url_id', 'pixel_id']);
|
||||
});
|
||||
|
||||
// 3. Data migration: Migrate existing pixel IDs from short_urls to short_url_pixels and pivot
|
||||
if (Schema::hasColumn('short_urls', 'pixel_meta_id')) {
|
||||
$oldUrls = DB::table('short_urls')
|
||||
->whereNotNull('pixel_meta_id')
|
||||
->orWhereNotNull('pixel_google_id')
|
||||
->orWhereNotNull('pixel_linkedin_id')
|
||||
->get();
|
||||
|
||||
foreach ($oldUrls as $url) {
|
||||
// Migrate Meta Pixel
|
||||
if (! empty($url->pixel_meta_id)) {
|
||||
$pixelId = DB::table('short_url_pixels')
|
||||
->where('type', 'meta')
|
||||
->where('pixel_id', $url->pixel_meta_id)
|
||||
->value('id');
|
||||
|
||||
if (! $pixelId) {
|
||||
$pixelId = DB::table('short_url_pixels')->insertGetId([
|
||||
'name' => 'Meta Pixel ('.$url->pixel_meta_id.')',
|
||||
'type' => 'meta',
|
||||
'pixel_id' => $url->pixel_meta_id,
|
||||
'is_active' => true,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
DB::table('short_url_pixel')->insertOrIgnore([
|
||||
'short_url_id' => $url->id,
|
||||
'pixel_id' => $pixelId,
|
||||
]);
|
||||
}
|
||||
|
||||
// Migrate Google Tag / GA4
|
||||
if (! empty($url->pixel_google_id)) {
|
||||
$pixelId = DB::table('short_url_pixels')
|
||||
->where('type', 'google')
|
||||
->where('pixel_id', $url->pixel_google_id)
|
||||
->value('id');
|
||||
|
||||
if (! $pixelId) {
|
||||
$pixelId = DB::table('short_url_pixels')->insertGetId([
|
||||
'name' => 'Google Tag ('.$url->pixel_google_id.')',
|
||||
'type' => 'google',
|
||||
'pixel_id' => $url->pixel_google_id,
|
||||
'is_active' => true,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
DB::table('short_url_pixel')->insertOrIgnore([
|
||||
'short_url_id' => $url->id,
|
||||
'pixel_id' => $pixelId,
|
||||
]);
|
||||
}
|
||||
|
||||
// Migrate LinkedIn Insight
|
||||
if (! empty($url->pixel_linkedin_id)) {
|
||||
$pixelId = DB::table('short_url_pixels')
|
||||
->where('type', 'linkedin')
|
||||
->where('pixel_id', $url->pixel_linkedin_id)
|
||||
->value('id');
|
||||
|
||||
if (! $pixelId) {
|
||||
$pixelId = DB::table('short_url_pixels')->insertGetId([
|
||||
'name' => 'LinkedIn Insight ('.$url->pixel_linkedin_id.')',
|
||||
'type' => 'linkedin',
|
||||
'pixel_id' => $url->pixel_linkedin_id,
|
||||
'is_active' => true,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
DB::table('short_url_pixel')->insertOrIgnore([
|
||||
'short_url_id' => $url->id,
|
||||
'pixel_id' => $pixelId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Drop the old columns
|
||||
Schema::table('short_urls', function (Blueprint $table) {
|
||||
$table->dropColumn(['pixel_meta_id', 'pixel_google_id', 'pixel_linkedin_id']);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
// 1. Re-add the columns
|
||||
Schema::table('short_urls', function (Blueprint $table) {
|
||||
$table->string('pixel_meta_id', 100)->nullable();
|
||||
$table->string('pixel_google_id', 100)->nullable();
|
||||
$table->string('pixel_linkedin_id', 100)->nullable();
|
||||
});
|
||||
|
||||
// 2. Re-populate the old columns from pivot data
|
||||
$associations = DB::table('short_url_pixel')
|
||||
->join('short_url_pixels', 'short_url_pixel.pixel_id', '=', 'short_url_pixels.id')
|
||||
->select('short_url_pixel.short_url_id', 'short_url_pixels.type', 'short_url_pixels.pixel_id')
|
||||
->get();
|
||||
|
||||
foreach ($associations as $assoc) {
|
||||
if ($assoc->type === 'meta') {
|
||||
DB::table('short_urls')->where('id', $assoc->short_url_id)->update(['pixel_meta_id' => $assoc->pixel_id]);
|
||||
} elseif ($assoc->type === 'google') {
|
||||
DB::table('short_urls')->where('id', $assoc->short_url_id)->update(['pixel_google_id' => $assoc->pixel_id]);
|
||||
} elseif ($assoc->type === 'linkedin') {
|
||||
DB::table('short_urls')->where('id', $assoc->short_url_id)->update(['pixel_linkedin_id' => $assoc->pixel_id]);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Drop tables
|
||||
Schema::dropIfExists('short_url_pixel');
|
||||
Schema::dropIfExists('short_url_pixels');
|
||||
}
|
||||
};
|
||||
@@ -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',
|
||||
@@ -170,7 +199,8 @@ return [
|
||||
'settings_site_name' => 'Site Name Override',
|
||||
'settings_site_name_helper' => 'Custom brand/site name displayed on redirect prompt screens. If empty, falls back to config("app.name").',
|
||||
'settings_route_prefix' => 'Route Prefix',
|
||||
'settings_route_prefix_helper' => 'URL segment before the short key, e.g. "/s/abc123". Change requires a config:clear.',
|
||||
'settings_route_prefix_helper' => 'URL segment before the short key, e.g. "/s/abc123". Leave empty to serve links directly from the root domain (e.g. "domain.com/abc123"). Change requires a config:clear.',
|
||||
'settings_redirect_code_helper' => 'Temporary redirect (302) forces browsers to request the short URL every time, ensuring accurate tracking of all visits. Permanent redirect (301) is cached by browsers and search engines (better for SEO), but subsequent visits from the same device might not be logged in statistics.',
|
||||
'settings_key_length' => 'Auto-generated Key Length',
|
||||
'settings_key_length_helper' => 'Number of characters for auto-generated short keys (base62). 6 chars = ~56 billion unique keys.',
|
||||
'settings_cache_ttl' => 'Redirect Cache TTL',
|
||||
@@ -186,11 +216,12 @@ return [
|
||||
'settings_geoip_driver_maxmind' => 'MaxMind Local DB (offline, zero latency)',
|
||||
'settings_geoip_driver_ipapi' => 'ip-api.com (free API, 45 req/min limit)',
|
||||
'settings_geoip_cache_ttl' => 'Geo-IP Result Cache TTL',
|
||||
'settings_geoip_cache_ttl_helper' => 'Seconds to cache the resolved country per hashed IP. 86400 = 24 hours.',
|
||||
'settings_geoip_cache_ttl_helper' => 'Time (in seconds) to cache the resolved location per visitor IP. Prevents querying APIs/databases repeatedly on subsequent clicks from the same user. Default: 86400 (24 hours). Max: 31536000 (1 year).',
|
||||
'settings_geoip_timeout' => 'API Timeout',
|
||||
'settings_geoip_timeout_helper' => 'Maximum seconds to wait for an external Geo-IP API response before giving up.',
|
||||
'settings_maxmind_path' => 'MaxMind Database Path',
|
||||
'settings_maxmind_path_helper' => 'Absolute path to your GeoLite2-Country.mmdb or GeoIP2-Country.mmdb file. Required when driver is set to MaxMind.',
|
||||
'settings_maxmind_info_callout' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M8 0C3.58125 0 0 3.58125 0 8C0 12.4187 3.58125 16 8 16C12.4187 16 16 12.4187 16 8C16 3.58125 12.4187 0 8 0ZM8 14.5C4.41563 14.5 1.5 11.5841 1.5 8C1.5 4.41594 4.41563 1.5 8 1.5C11.5844 1.5 14.5 4.41594 14.5 8C14.5 11.5841 11.5844 14.5 8 14.5ZM9.25 10.5H8.75V7.75C8.75 7.3375 8.41563 7 8 7H7C6.5875 7 6.25 7.3375 6.25 7.75C6.25 8.1625 6.5875 8.5 7 8.5H7.25V10.5H6.75C6.3375 10.5 6 10.8375 6 11.25C6 11.6625 6.3375 12 6.75 12H9.25C9.66406 12 10 11.6641 10 11.25C10 10.8359 9.66563 10.5 9.25 10.5ZM8 6C8.55219 6 9 5.55219 9 5C9 4.44781 8.55219 4 8 4C7.44781 4 7 4.44687 7 5C7 5.55313 7.44687 6 8 6Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>How to configure MaxMind (Offline Geo-IP):</strong><br>1. Register for a free account at <a href="https://www.maxmind.com" target="_blank" class="underline text-primary-600 dark:text-primary-400">maxmind.com</a>.<br>2. Download the free <strong>GeoLite2 Country</strong> or <strong>GeoLite2 City</strong> database in binary format (file extension <code class="px-1 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">.mmdb</code>).<br>3. Upload the file to your server, e.g. under the path <code class="px-1 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">storage/geoip/GeoLite2-Country.mmdb</code>.<br>4. Paste the absolute path below and click the <strong>Verify file</strong> button on the right side of the field.</span></div></div>',
|
||||
'settings_maxmind_verify' => 'Verify file',
|
||||
'settings_maxmind_verify_ok' => '✅ File found & readable',
|
||||
'settings_maxmind_verify_fail' => '❌ File not found or not readable',
|
||||
@@ -209,10 +240,13 @@ return [
|
||||
'settings_section_buffering' => 'Visit Counters Buffering',
|
||||
'settings_buffering_enabled' => 'Buffer Visit Counts in Cache',
|
||||
'settings_buffering_helper' => 'When enabled, visit count increments are temporarily buffered in the application cache and must be flushed periodically to the database via "php artisan short-url:sync-counters". This prevents row-locking issues and performance degradation under high-traffic spikes.',
|
||||
'settings_buffering_worker_info' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M8 0C3.58125 0 0 3.58125 0 8C0 12.4187 3.58125 16 8 16C12.4187 16 16 12.4187 16 8C16 3.58125 12.4187 0 8 0ZM8 14.5C4.41563 14.5 1.5 11.5841 1.5 8C1.5 4.41594 4.41563 1.5 8 1.5C11.5844 1.5 14.5 4.41594 14.5 8C14.5 11.5841 11.5844 14.5 8 14.5ZM9.25 10.5H8.75V7.75C8.75 7.3375 8.41563 7 8 7H7C6.5875 7 6.25 7.3375 6.25 7.75C6.25 8.1625 6.5875 8.5 7 8.5H7.25V10.5H6.75C6.3375 10.5 6 10.8375 6 11.25C6 11.6625 6.3375 12 6.75 12H9.25C9.66406 12 10 11.6641 10 11.25C10 10.8359 9.66563 10.5 9.25 10.5ZM8 6C8.55219 6 9 5.55219 9 5C9 4.44781 8.55219 4 8 4C7.44781 4 7 4.44687 7 5C7 5.55313 7.44687 6 8 6Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full [&_kbd]:bg-background-light dark:[&_kbd]:bg-background-dark [_code]:!text-current [_kbd]:!text-current [_a]:!text-current [_a]:border-current [_strong]:!text-current text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>Required Cron Task (Scheduler):</strong> You have enabled click counter buffering. You must add the following task to your server\'s crontab:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1</code><br>Command run automatically in the background by the scheduler:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">php artisan short-url:sync-counters</code></span></div></div>',
|
||||
|
||||
// CDN Trust Settings
|
||||
'settings_trust_cdn_headers' => 'Trust CDN & Proxy Headers',
|
||||
'settings_trust_cdn_headers_helper' => 'Enable this if your app sits behind a CDN (like Cloudflare, AWS CloudFront) or a reverse proxy. This allows extracting the real client IP and country code from CDN headers. Warning: only enable this if you are actually behind a proxy, otherwise client IP addresses can be spoofed!',
|
||||
'settings_trust_cdn_headers_info_callout' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-amber-200 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/20" data-callout-type="warning"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-amber-800 dark:text-amber-300" aria-label="Warning"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" /></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full text-amber-900 dark:text-amber-200" data-component-part="callout-content"><span><strong>Warning (IP Address Security):</strong> You have enabled trust for proxy headers. Enable this option <u>only</u> if your website is behind:<br>• <strong>Cloudflare</strong> (reads from CF-Connecting-IP)<br>• <strong>AWS CloudFront</strong> or another CDN system<br>• <strong>Nginx/Apache reverse proxy</strong> forwarding X-Forwarded-For.<br><br><strong>Turn this option OFF</strong> if your server connects directly to users. If left ON without a proxy, malicious users can easily spoof their IP address by sending a custom header.</span></div></div>',
|
||||
'settings_geoip_headers_warning' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-amber-200 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/20" data-callout-type="warning"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-amber-800 dark:text-amber-300" aria-label="Warning"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" /></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full text-amber-900 dark:text-amber-200" data-component-part="callout-content"><span><strong>Proxy Header Trust Disabled:</strong> You selected <em>CDN Headers</em> as the Geo-IP driver, but the option <strong>"Trust CDN & Proxy Headers"</strong> in the <em>General</em> tab is disabled. Location detection will not function until you enable it.</span></div></div>',
|
||||
|
||||
// Stats View & Export Localization
|
||||
'stats_filter_visited_from' => 'Visited From',
|
||||
@@ -265,13 +299,16 @@ return [
|
||||
'targeting_type_device' => 'Device-Based Redirects',
|
||||
'targeting_type_country' => 'Country-Based (Geo-IP) Redirects',
|
||||
'targeting_type_rotation' => 'A/B Split Rotation',
|
||||
'targeting_type_language' => 'Browser Language-Based Redirects',
|
||||
'device_targeting_rules' => 'Device Rules',
|
||||
'country_targeting_rules' => 'Country Rules',
|
||||
'language_targeting_rules' => 'Language Rules',
|
||||
'rotation_targeting_rules' => 'Rotation Targets',
|
||||
'device_mobile' => 'Mobile Destination URL',
|
||||
'device_tablet' => 'Tablet Destination URL',
|
||||
'device_desktop' => 'Desktop Destination URL',
|
||||
'country_code' => 'Country Code',
|
||||
'language_code' => 'Language Code',
|
||||
'rotation_url' => 'Destination URL',
|
||||
'rotation_weight' => 'Traffic Weight',
|
||||
'rotation_weight_helper' => 'Percentage of traffic to route to this URL (e.g. 50 for 50%). Weights are balanced proportionally.',
|
||||
@@ -300,8 +337,9 @@ return [
|
||||
// Queue Settings Additions
|
||||
'settings_queue_name' => 'Queue Name',
|
||||
'settings_queue_name_helper' => 'The target queue to which tracking and buffering sync jobs are dispatched. Default: "default".',
|
||||
'settings_queue_worker_info' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M8 0C3.58125 0 0 3.58125 0 8C0 12.4187 3.58125 16 8 16C12.4187 16 16 12.4187 16 8C16 3.58125 12.4187 0 8 0ZM8 14.5C4.41563 14.5 1.5 11.5841 1.5 8C1.5 4.41594 4.41563 1.5 8 1.5C11.5844 1.5 14.5 4.41594 14.5 8C14.5 11.5841 11.5844 14.5 8 14.5ZM9.25 10.5H8.75V7.75C8.75 7.3375 8.41563 7 8 7H7C6.5875 7 6.25 7.3375 6.25 7.75C6.25 8.1625 6.5875 8.5 7 8.5H7.25V10.5H6.75C6.3375 10.5 6 10.8375 6 11.25C6 11.6625 6.3375 12 6.75 12H9.25C9.66406 12 10 11.6641 10 11.25C10 10.8359 9.66563 10.5 9.25 10.5ZM8 6C8.55219 6 9 5.55219 9 5C9 4.44781 8.55219 4 8 4C7.44781 4 7 4.44687 7 5C7 5.55313 7.44687 6 8 6Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full [&_kbd]:bg-background-light dark:[&_kbd]:bg-background-dark [_code]:!text-current [_kbd]:!text-current [_a]:!text-current [_a]:border-current [_strong]:!text-current text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>Queue Worker Required:</strong> The selected connection runs asynchronously. You must run a background worker to process these jobs:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">php artisan queue:work --queue=:queue</code></span></div></div>',
|
||||
'settings_geoip_stats_cache_ttl' => 'Stats Dashboard Cache TTL',
|
||||
'settings_geoip_stats_cache_ttl_helper' => 'Number of seconds to cache query metrics on the dashboard statistics page (prevents database load). Default: 300 (5 minutes).',
|
||||
'settings_geoip_stats_cache_ttl_helper' => 'Time (in seconds) to cache stats calculation on the dashboard. When viewing charts and logs, the system loads calculated numbers from cache instead of querying millions of rows from SQL on every refresh. Default: 300 (5 minutes). Max: 86400 (24 hours).',
|
||||
|
||||
// Default Tracking Tab
|
||||
'settings_tab_tracking_defaults' => 'Default Tracking',
|
||||
@@ -315,6 +353,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',
|
||||
@@ -360,6 +399,8 @@ return [
|
||||
'settings_api_enabled' => 'Enable Developer REST API',
|
||||
'settings_api_enabled_helper' => 'Allow external systems to create, list, and delete short URLs via the REST API. Disable to block all /api/short-url/* endpoints with a 503 response.',
|
||||
'settings_section_global_webhook' => 'Global Webhook Configuration',
|
||||
'settings_global_webhook_enabled' => 'Enable Global Webhook',
|
||||
'settings_global_webhook_enabled_helper' => 'When enabled, the plugin will dispatch HTTP POST notifications for all short link events.',
|
||||
'settings_global_webhook_url' => 'Global Webhook URL',
|
||||
'settings_global_webhook_url_helper' => 'Destination URL to dispatch event payloads asynchronously in the background for all links.',
|
||||
'settings_webhook_events' => 'Monitored Webhook Events',
|
||||
@@ -375,13 +416,54 @@ return [
|
||||
'api_key' => 'API Key',
|
||||
'active' => 'Active',
|
||||
|
||||
// Security v2.0
|
||||
'settings_section_security_v2' => 'Security & Anti-Fraud v2.0',
|
||||
'settings_section_security_v2_desc' => 'Configure VPN/proxy detection and Google Safe Browsing URL validation.',
|
||||
'settings_vpn_detection_enabled' => 'Enable VPN & Proxy Detection',
|
||||
'settings_vpn_detection_enabled_helper' => 'When enabled, incoming visits will be checked for VPN, proxy, or Tor usage.',
|
||||
'settings_vpn_driver' => 'Detection Driver',
|
||||
'settings_vpn_driver_helper' => 'Choose between the free IP-API service or VPNAPI.io.',
|
||||
'settings_vpn_driver_ipapi' => 'IP-API (Free)',
|
||||
'settings_vpn_driver_vpnapi' => 'VPNAPI.io (Premium / Free)',
|
||||
'settings_vpnapi_key' => 'VPNAPI.io Key',
|
||||
'settings_vpnapi_key_helper' => 'Your API key from vpnapi.io (required for vpnapi driver).',
|
||||
'settings_vpn_block_action' => 'VPN Block Action',
|
||||
'settings_vpn_block_action_helper' => 'Choose whether to only flag VPN traffic in statistics or actively block them with a 403 Forbidden page.',
|
||||
'settings_vpn_block_flag_only' => 'Flag in stats only (allow redirect)',
|
||||
'settings_vpn_block_block_403' => 'Block traffic (serve 403 Forbidden)',
|
||||
'settings_safe_browsing_enabled' => 'Enable Google Safe Browsing URL Verification',
|
||||
'settings_safe_browsing_enabled_helper' => 'Verify target URLs against Google\'s Threat List to block phishing, malware, and social engineering links.',
|
||||
'settings_safe_browsing_api_key' => 'Google Safe Browsing API Key',
|
||||
'settings_safe_browsing_api_key_helper' => 'Your Google Developer Console Web API key.',
|
||||
'settings_safe_browsing_test' => 'Test API Connection',
|
||||
'settings_safe_browsing_test_empty' => 'Please enter an API key first.',
|
||||
'settings_safe_browsing_test_ok' => 'Google Safe Browsing connection OK (API is active).',
|
||||
'settings_safe_browsing_test_fail' => 'Google Safe Browsing key validation failed.',
|
||||
'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',
|
||||
'pixels_navigation_label' => 'Retargeting Pixels',
|
||||
'pixel_resource_title' => 'Retargeting Pixel',
|
||||
'pixel_name' => 'Pixel Name',
|
||||
'pixel_type' => 'Provider',
|
||||
'pixel_id_label' => 'Pixel ID / Tag ID',
|
||||
'pixel_status_active' => 'Active',
|
||||
];
|
||||
|
||||
44
resources/lang/en/languages.php
Normal file
44
resources/lang/en/languages.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'pl' => 'Polish',
|
||||
'en' => 'English',
|
||||
'de' => 'German',
|
||||
'fr' => 'French',
|
||||
'es' => 'Spanish',
|
||||
'it' => 'Italian',
|
||||
'pt' => 'Portuguese',
|
||||
'ru' => 'Russian',
|
||||
'zh' => 'Chinese',
|
||||
'ja' => 'Japanese',
|
||||
'ar' => 'Arabic',
|
||||
'nl' => 'Dutch',
|
||||
'cs' => 'Czech',
|
||||
'sk' => 'Slovak',
|
||||
'hu' => 'Hungarian',
|
||||
'ro' => 'Romanian',
|
||||
'bg' => 'Bulgarian',
|
||||
'hr' => 'Croatian',
|
||||
'sr' => 'Serbian',
|
||||
'sl' => 'Slovenian',
|
||||
'uk' => 'Ukrainian',
|
||||
'tr' => 'Turkish',
|
||||
'da' => 'Danish',
|
||||
'sv' => 'Swedish',
|
||||
'no' => 'Norwegian',
|
||||
'fi' => 'Finnish',
|
||||
'el' => 'Greek',
|
||||
'he' => 'Hebrew',
|
||||
'hi' => 'Hindi',
|
||||
'th' => 'Thai',
|
||||
'vi' => 'Vietnamese',
|
||||
'ko' => 'Korean',
|
||||
'id' => 'Indonesian',
|
||||
'ms' => 'Malay',
|
||||
'et' => 'Estonian',
|
||||
'lv' => 'Latvian',
|
||||
'lt' => 'Lithuanian',
|
||||
'is' => 'Icelandic',
|
||||
'ga' => 'Irish',
|
||||
'fa' => 'Persian',
|
||||
];
|
||||
@@ -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',
|
||||
@@ -171,7 +200,8 @@ return [
|
||||
'settings_site_name' => 'Niestandardowa nazwa witryny',
|
||||
'settings_site_name_helper' => 'Własna nazwa witryny/marki wyświetlana na ekranach przekierowań (monit o hasło, ostrzeżenia, piksele). W przypadku braku wartości, zostanie użyta nazwa z konfiguracji config("app.name").',
|
||||
'settings_route_prefix' => 'Prefiks trasy',
|
||||
'settings_route_prefix_helper' => 'Segment URL przed kluczem, np. "/s/abc123". Zmiana wymaga php artisan config:clear.',
|
||||
'settings_route_prefix_helper' => 'Segment URL przed kluczem, np. "/s/abc123". Pozostaw puste, aby serwować linki bezpośrednio w głównej domenie (np. "domena.com/abc123"). Zmiana wymaga php artisan config:clear.',
|
||||
'settings_redirect_code_helper' => 'Przekierowanie tymczasowe (302) zmusza przeglądarki do każdorazowego odpytywania serwera, co pozwala na dokładne zliczanie wszystkich wizyt. Przekierowanie stałe (301) jest zapisywane w pamięci podręcznej przeglądarek i wyszukiwarek (lepsze pod SEO), przez co kolejne przejścia tego samego użytkownika mogą nie być rejestrowane w statystykach.',
|
||||
'settings_key_length' => 'Długość auto-generowanego klucza',
|
||||
'settings_key_length_helper' => 'Liczba znaków klucza (base62). 6 znaków = ~56 miliardów unikalnych kluczy.',
|
||||
'settings_cache_ttl' => 'TTL cache przekierowania',
|
||||
@@ -186,12 +216,13 @@ return [
|
||||
'settings_geoip_driver_headers' => 'Nagłówki CDN (najszybsze — Cloudflare, CloudFront)',
|
||||
'settings_geoip_driver_maxmind' => 'MaxMind lokalna baza (offline, zerowe opóźnienie)',
|
||||
'settings_geoip_driver_ipapi' => 'ip-api.com (darmowe API, limit 45 req/min)',
|
||||
'settings_geoip_cache_ttl' => 'TTL cache wyniku Geo-IP',
|
||||
'settings_geoip_cache_ttl_helper' => 'Sekundy cachowania kraju dla zahashowanego IP. 86400 = 24 godziny.',
|
||||
'settings_geoip_cache_ttl' => 'Czas życia (TTL) pamięci podręcznej IP',
|
||||
'settings_geoip_cache_ttl_helper' => 'Czas (w sekundach) przechowywania wyniku wykrywania lokalizacji dla danego IP. Zapobiega to wielokrotnemu odpytywaniu API lub baz danych przy kolejnych kliknięciach tej samej osoby. Domyślnie: 86400 (24 godziny). Maksymalnie: 31536000 (1 rok).',
|
||||
'settings_geoip_timeout' => 'Timeout API',
|
||||
'settings_geoip_timeout_helper' => 'Maksymalna liczba sekund oczekiwania na odpowiedź zewnętrznego API Geo-IP.',
|
||||
'settings_maxmind_path' => 'Ścieżka bazy MaxMind',
|
||||
'settings_maxmind_path_helper' => 'Bezwzględna ścieżka do pliku GeoLite2-Country.mmdb lub GeoIP2-Country.mmdb. Wymagana gdy sterownik to MaxMind.',
|
||||
'settings_maxmind_info_callout' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M8 0C3.58125 0 0 3.58125 0 8C0 12.4187 3.58125 16 8 16C12.4187 16 16 12.4187 16 8C16 3.58125 12.4187 0 8 0ZM8 14.5C4.41563 14.5 1.5 11.5841 1.5 8C1.5 4.41594 4.41563 1.5 8 1.5C11.5844 1.5 14.5 4.41594 14.5 8C14.5 11.5841 11.5844 14.5 8 14.5ZM9.25 10.5H8.75V7.75C8.75 7.3375 8.41563 7 8 7H7C6.5875 7 6.25 7.3375 6.25 7.75C6.25 8.1625 6.5875 8.5 7 8.5H7.25V10.5H6.75C6.3375 10.5 6 10.8375 6 11.25C6 11.6625 6.3375 12 6.75 12H9.25C9.66406 12 10 11.6641 10 11.25C10 10.8359 9.66563 10.5 9.25 10.5ZM8 6C8.55219 6 9 5.55219 9 5C9 4.44781 8.55219 4 8 4C7.44781 4 7 4.44687 7 5C7 5.55313 7.44687 6 8 6Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>Jak skonfigurować MaxMind (Offline Geo-IP):</strong><br>1. Zarejestruj darmowe konto na <a href="https://www.maxmind.com" target="_blank" class="underline text-primary-600 dark:text-primary-400">maxmind.com</a>.<br>2. Pobierz bezpłatną bazę danych <strong>GeoLite2 Country</strong> lub <strong>GeoLite2 City</strong> w formacie binarnym (plik z rozszerzeniem <code class="px-1 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">.mmdb</code>).<br>3. Prześlij plik na swój serwer, np. do katalogu <code class="px-1 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">storage/geoip/GeoLite2-Country.mmdb</code>.<br>4. Wpisz pełną ścieżkę bezwzględną poniżej i kliknij przycisk <strong>Zweryfikuj plik</strong> z prawej strony pola.</span></div></div>',
|
||||
'settings_maxmind_verify' => 'Weryfikuj plik',
|
||||
'settings_maxmind_verify_ok' => '✅ Plik znaleziony i dostępny',
|
||||
'settings_maxmind_verify_fail' => '❌ Plik nie istnieje lub jest niedostępny',
|
||||
@@ -210,10 +241,13 @@ return [
|
||||
'settings_section_buffering' => 'Buforowanie liczników wizyt',
|
||||
'settings_buffering_enabled' => 'Buforuj kliknięcia w pamięci podręcznej (Cache)',
|
||||
'settings_buffering_helper' => 'Po włączeniu, zliczenia kliknięć będą buforowane w pamięci podręcznej aplikacji i muszą być okresowo synchronizowane z bazą danych za pomocą komendy "php artisan short-url:sync-counters". Zapobiega to blokowaniu wierszy bazy danych i spadkom wydajności przy masowym ruchu.',
|
||||
'settings_buffering_worker_info' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M8 0C3.58125 0 0 3.58125 0 8C0 12.4187 3.58125 16 8 16C12.4187 16 16 12.4187 16 8C16 3.58125 12.4187 0 8 0ZM8 14.5C4.41563 14.5 1.5 11.5841 1.5 8C1.5 4.41594 4.41563 1.5 8 1.5C11.5844 1.5 14.5 4.41594 14.5 8C14.5 11.5841 11.5844 14.5 8 14.5ZM9.25 10.5H8.75V7.75C8.75 7.3375 8.41563 7 8 7H7C6.5875 7 6.25 7.3375 6.25 7.75C6.25 8.1625 6.5875 8.5 7 8.5H7.25V10.5H6.75C6.3375 10.5 6 10.8375 6 11.25C6 11.6625 6.3375 12 6.75 12H9.25C9.66406 12 10 11.6641 10 11.25C10 10.8359 9.66563 10.5 9.25 10.5ZM8 6C8.55219 6 9 5.55219 9 5C9 4.44781 8.55219 4 8 4C7.44781 4 7 4.44687 7 5C7 5.55313 7.44687 6 8 6Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full [&_kbd]:bg-background-light dark:[&_kbd]:bg-background-dark [_code]:!text-current [_kbd]:!text-current [_a]:!text-current [_a]:border-current [_strong]:!text-current text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>Wymagane zadanie Cron (Scheduler):</strong> Włączyłeś buforowanie kliknięć. Musisz dodać do harmonogramu zadań (cron) komendę synchronizującą liczniki z bazą danych:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">* * * * * cd /sciezka-do-twojego-projektu && php artisan schedule:run >> /dev/null 2>&1</code><br>Komenda uruchamiana automatycznie w tle przez harmonogram:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">php artisan short-url:sync-counters</code></span></div></div>',
|
||||
|
||||
// CDN Trust Settings
|
||||
'settings_trust_cdn_headers' => 'Ufaj nagłówkom CDN i proxy',
|
||||
'settings_trust_cdn_headers_helper' => 'Włącz tę opcję, jeśli Twoja aplikacja działa za CDN (np. Cloudflare, AWS CloudFront) lub reverse proxy. Pozwala to na pobieranie prawdziwego adresu IP klienta i kodu kraju z nagłówków CDN. Uwaga: włączaj tylko wtedy, gdy faktycznie korzystasz z proxy, w przeciwnym razie adresy IP mogą być sfałszowane!',
|
||||
'settings_trust_cdn_headers_info_callout' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-amber-200 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/20" data-callout-type="warning"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-amber-800 dark:text-amber-300" aria-label="Warning"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" /></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full text-amber-900 dark:text-amber-200" data-component-part="callout-content"><span><strong>Uwaga (Bezpieczeństwo adresów IP):</strong> Włączyłeś ufanie nagłówkom proxy. Włączaj tę opcję <u>wyłącznie</u> wtedy, gdy Twoja strona jest podpięta pod:<br>• <strong>Cloudflare</strong> (odczyt z nagłówka CF-Connecting-IP)<br>• <strong>AWS CloudFront</strong> lub inny system CDN<br>• <strong>Nginx/Apache reverse proxy</strong> przekazujący nagłówki X-Forwarded-For.<br><br><strong>Wyłącz tę opcję</strong>, jeśli Twój serwer łączy się z użytkownikami bezpośrednio. Jeśli pozostawisz ją włączoną bez CDN, złośliwi użytkownicy mogą łatwo sfałszować swój adres IP, wysyłając własny nagłówek.</span></div></div>',
|
||||
'settings_geoip_headers_warning' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-amber-200 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/20" data-callout-type="warning"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-amber-800 dark:text-amber-300" aria-label="Warning"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" /></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full text-amber-900 dark:text-amber-200" data-component-part="callout-content"><span><strong>Brak zaufania do nagłówków proxy:</strong> Wybrałeś <em>Nagłówki CDN</em> jako sterownik wykrywania lokalizacji, ale opcja <strong>„Ufaj nagłówkom CDN i proxy”</strong> w zakładce <em>Ogólne</em> jest wyłączona. Wykrywanie kraju nie będzie działać, dopóki jej nie włączysz.</span></div></div>',
|
||||
|
||||
// Stats View & Export Localization
|
||||
'stats_filter_visited_from' => 'Odwiedzono od',
|
||||
@@ -266,13 +300,16 @@ return [
|
||||
'targeting_type_device' => 'Przekierowania zależne od urządzenia',
|
||||
'targeting_type_country' => 'Przekierowania zależne od kraju (Geo-IP)',
|
||||
'targeting_type_rotation' => 'Podział ruchu A/B (Rotacja)',
|
||||
'targeting_type_language' => 'Przekierowania zależne od języka przeglądarki',
|
||||
'device_targeting_rules' => 'Reguły urządzeń',
|
||||
'country_targeting_rules' => 'Reguły krajów',
|
||||
'language_targeting_rules' => 'Reguły języków',
|
||||
'rotation_targeting_rules' => 'Cele rotacji',
|
||||
'device_mobile' => 'Docelowy URL dla urządzeń mobilnych',
|
||||
'device_tablet' => 'Docelowy URL dla tabletów',
|
||||
'device_desktop' => 'Docelowy URL dla komputerów stacjonarnych',
|
||||
'country_code' => 'Kod kraju',
|
||||
'language_code' => 'Kod języka',
|
||||
'rotation_url' => 'Docelowy URL',
|
||||
'rotation_weight' => 'Waga ruchu',
|
||||
'rotation_weight_helper' => 'Procent ruchu kierowany na ten URL (np. 50 dla 50%). Wagi są bilansowane proporcjonalnie.',
|
||||
@@ -301,8 +338,9 @@ return [
|
||||
// Queue Settings Additions
|
||||
'settings_queue_name' => 'Nazwa kolejki',
|
||||
'settings_queue_name_helper' => 'Docelowa nazwa kolejki, do której wysyłane są zadania śledzenia i synchronizacji liczników. Domyślnie: "default".',
|
||||
'settings_geoip_stats_cache_ttl' => 'TTL cache statystyk panelu',
|
||||
'settings_geoip_stats_cache_ttl_helper' => 'Liczba sekund buforowania wyników zapytań na stronie statystyk (zapobiega obciążeniu bazy danych). Domyślnie: 300 (5 minut).',
|
||||
'settings_queue_worker_info' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M8 0C3.58125 0 0 3.58125 0 8C0 12.4187 3.58125 16 8 16C12.4187 16 16 12.4187 16 8C16 3.58125 12.4187 0 8 0ZM8 14.5C4.41563 14.5 1.5 11.5841 1.5 8C1.5 4.41594 4.41563 1.5 8 1.5C11.5844 1.5 14.5 4.41594 14.5 8C14.5 11.5841 11.5844 14.5 8 14.5ZM9.25 10.5H8.75V7.75C8.75 7.3375 8.41563 7 8 7H7C6.5875 7 6.25 7.3375 6.25 7.75C6.25 8.1625 6.5875 8.5 7 8.5H7.25V10.5H6.75C6.3375 10.5 6 10.8375 6 11.25C6 11.6625 6.3375 12 6.75 12H9.25C9.66406 12 10 11.6641 10 11.25C10 10.8359 9.66563 10.5 9.25 10.5ZM8 6C8.55219 6 9 5.55219 9 5C9 4.44781 8.55219 4 8 4C7.44781 4 7 4.44687 7 5C7 5.55313 7.44687 6 8 6Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full [&_kbd]:bg-background-light dark:[&_kbd]:bg-background-dark [_code]:!text-current [_kbd]:!text-current [_a]:!text-current [_a]:border-current [_strong]:!text-current text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>Wymagany proces w tle (Queue Worker):</strong> Wybrane połączenie działa asynchronicznie. Musisz uruchomić proces w tle (workera), aby przetwarzać te zadania:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">php artisan queue:work --queue=:queue</code></span></div></div>',
|
||||
'settings_geoip_stats_cache_ttl' => 'Czas życia (TTL) pamięci podręcznej statystyk',
|
||||
'settings_geoip_stats_cache_ttl_helper' => 'Czas (w sekundach) buforowania obliczeń statystyk linków na pulpicie. Kiedy przeglądasz statystyki, system pobiera dane z cache zamiast każdorazowo przeliczać miliony rekordów w bazie danych. Domyślnie: 300 (5 minut). Maksymalnie: 86400 (24 godziny).',
|
||||
|
||||
// Default Tracking Tab
|
||||
'settings_tab_tracking_defaults' => 'Domyślne śledzenie',
|
||||
@@ -316,6 +354,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',
|
||||
@@ -361,6 +400,8 @@ return [
|
||||
'settings_api_enabled' => 'Włącz Developer REST API',
|
||||
'settings_api_enabled_helper' => 'Zezwól zewnętrznym systemom na tworzenie, listowanie i usuwanie krótkich linków przez REST API. Wyłącz, aby zablokować wszystkie endpointy /api/short-url/* odpowiedzią 503.',
|
||||
'settings_section_global_webhook' => 'Konfiguracja globalnego Webhooka',
|
||||
'settings_global_webhook_enabled' => 'Włącz globalny webhook',
|
||||
'settings_global_webhook_enabled_helper' => 'Gdy włączone, wtyczka będzie wysyłać powiadomienia HTTP POST w tle przy zdarzeniach wszystkich linków.',
|
||||
'settings_global_webhook_url' => 'Globalny Webhook URL',
|
||||
'settings_global_webhook_url_helper' => 'Adres URL, pod który wysyłane będą w tle powiadomienia o zdarzeniach ze wszystkich linków.',
|
||||
'settings_webhook_events' => 'Monitorowane zdarzenia Webhooka',
|
||||
@@ -375,4 +416,55 @@ return [
|
||||
'api_key_name' => 'Nazwa klucza (Opis)',
|
||||
'api_key' => 'Klucz API',
|
||||
'active' => 'Aktywny',
|
||||
|
||||
// Ochrona v2.0
|
||||
'settings_section_security_v2' => 'Bezpieczeństwo i ochrona przed nadużyciami v2.0',
|
||||
'settings_section_security_v2_desc' => 'Skonfiguruj wykrywanie sieci VPN/Proxy oraz walidację adresów przez Google Safe Browsing.',
|
||||
'settings_vpn_detection_enabled' => 'Włącz wykrywanie VPN i Proxy',
|
||||
'settings_vpn_detection_enabled_helper' => 'Po włączeniu każda wizyta będzie sprawdzana pod kątem korzystania z połączeń VPN, proxy lub sieci Tor.',
|
||||
'settings_vpn_driver' => 'Sterownik wykrywania',
|
||||
'settings_vpn_driver_helper' => 'Wybierz darmową usługę IP-API lub platformę VPNAPI.io.',
|
||||
'settings_vpn_driver_ipapi' => 'IP-API (Darmowy)',
|
||||
'settings_vpn_driver_vpnapi' => 'VPNAPI.io (Premium / Darmowy)',
|
||||
'settings_vpnapi_key' => 'Klucz VPNAPI.io',
|
||||
'settings_vpnapi_key_helper' => 'Twój klucz API pobrany z vpnapi.io (wymagany dla sterownika vpnapi).',
|
||||
'settings_vpn_block_action' => 'Akcja blokowania VPN',
|
||||
'settings_vpn_block_action_helper' => 'Wybierz, czy ruch z sieci VPN ma być jedynie flagowany w statystykach, czy aktywnie blokowany stroną 403 Forbidden.',
|
||||
'settings_vpn_block_flag_only' => 'Tylko flaguj w statystykach (zezwól na przekierowanie)',
|
||||
'settings_vpn_block_block_403' => 'Blokuj ruch (wyświetl błąd 403 Forbidden)',
|
||||
'settings_safe_browsing_enabled' => 'Włącz weryfikację przez Google Safe Browsing',
|
||||
'settings_safe_browsing_enabled_helper' => 'Automatycznie sprawdzaj docelowe adresy URL pod kątem phishingu, złośliwego oprogramowania i oszustw.',
|
||||
'settings_safe_browsing_api_key' => 'Klucz API Google Safe Browsing',
|
||||
'settings_safe_browsing_api_key_helper' => 'Klucz API Web Developer pobrany z Google Cloud Console.',
|
||||
'settings_safe_browsing_test' => 'Testuj połączenie API',
|
||||
'settings_safe_browsing_test_empty' => 'Najpierw wprowadź klucz API.',
|
||||
'settings_safe_browsing_test_ok' => 'Połączenie z Google Safe Browsing powiodło się (API jest aktywne).',
|
||||
'settings_safe_browsing_test_fail' => 'Weryfikacja klucza Google Safe Browsing nie powiodła się.',
|
||||
'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_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.',
|
||||
'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',
|
||||
'pixels_navigation_label' => 'Piksele retargetingowe',
|
||||
'pixel_resource_title' => 'Piksel retargetingowy',
|
||||
'pixel_name' => 'Nazwa piksela',
|
||||
'pixel_type' => 'Dostawca',
|
||||
'pixel_id_label' => 'ID Piksela / Tagu',
|
||||
'pixel_status_active' => 'Aktywny',
|
||||
];
|
||||
|
||||
44
resources/lang/pl/languages.php
Normal file
44
resources/lang/pl/languages.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'pl' => 'Polski',
|
||||
'en' => 'Angielski',
|
||||
'de' => 'Niemiecki',
|
||||
'fr' => 'Francuski',
|
||||
'es' => 'Hiszpański',
|
||||
'it' => 'Włoski',
|
||||
'pt' => 'Portugalski',
|
||||
'ru' => 'Rosyjski',
|
||||
'zh' => 'Chiński',
|
||||
'ja' => 'Japoński',
|
||||
'ar' => 'Arabski',
|
||||
'nl' => 'Holenderski',
|
||||
'cs' => 'Czeski',
|
||||
'sk' => 'Słowacki',
|
||||
'hu' => 'Węgierski',
|
||||
'ro' => 'Rumuński',
|
||||
'bg' => 'Bułgarski',
|
||||
'hr' => 'Chorwacki',
|
||||
'sr' => 'Serbski',
|
||||
'sl' => 'Słoweński',
|
||||
'uk' => 'Ukraiński',
|
||||
'tr' => 'Turecki',
|
||||
'da' => 'Duński',
|
||||
'sv' => 'Szwedzki',
|
||||
'no' => 'Norweski',
|
||||
'fi' => 'Fiński',
|
||||
'el' => 'Grecki',
|
||||
'he' => 'Hebrajski',
|
||||
'hi' => 'Hindi',
|
||||
'th' => 'Tajski',
|
||||
'vi' => 'Wietnamski',
|
||||
'ko' => 'Koreański',
|
||||
'id' => 'Indonezyjski',
|
||||
'ms' => 'Malajski',
|
||||
'et' => 'Estoński',
|
||||
'lv' => 'Łotewski',
|
||||
'lt' => 'Litewski',
|
||||
'is' => 'Islandzki',
|
||||
'ga' => 'Irlandzki',
|
||||
'fa' => 'Perski',
|
||||
];
|
||||
@@ -33,8 +33,16 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
@php
|
||||
$pixelMetaIds = $pixels->where('type', 'meta')->pluck('pixel_id');
|
||||
$pixelGoogleIds = $pixels->where('type', 'google')->pluck('pixel_id');
|
||||
$pixelLinkedinIds = $pixels->where('type', 'linkedin')->pluck('pixel_id');
|
||||
$pixelTiktokIds = $pixels->where('type', 'tiktok')->pluck('pixel_id');
|
||||
$pixelPinterestIds = $pixels->where('type', 'pinterest')->pluck('pixel_id');
|
||||
@endphp
|
||||
|
||||
<!-- Meta / Facebook Pixel -->
|
||||
@if(!empty($pixelMetaId))
|
||||
@if($pixelMetaIds->isNotEmpty())
|
||||
<script>
|
||||
!function(f,b,e,v,n,t,s)
|
||||
{if(f.fbq)return;n=f.fbq=function(){n.callMethod?
|
||||
@@ -44,29 +52,37 @@
|
||||
t.src=v;s=b.getElementsByTagName(e)[0];
|
||||
s.parentNode.insertBefore(t,s)}(window, document,'script',
|
||||
'https://connect.facebook.net/en_US/fbevents.js');
|
||||
fbq('init', '{{ $pixelMetaId }}');
|
||||
@foreach($pixelMetaIds as $id)
|
||||
fbq('init', '{{ $id }}');
|
||||
fbq('track', 'PageView');
|
||||
@endforeach
|
||||
</script>
|
||||
<noscript><img height="1" width="1" style="display:none" src="https://www.facebook.com/tr?id={{ $pixelMetaId }}&ev=PageView&noscript=1" /></noscript>
|
||||
@foreach($pixelMetaIds as $id)
|
||||
<noscript><img height="1" width="1" style="display:none" src="https://www.facebook.com/tr?id={{ $id }}&ev=PageView&noscript=1" /></noscript>
|
||||
@endforeach
|
||||
@endif
|
||||
|
||||
<!-- Google Analytics / GTM -->
|
||||
@if(!empty($pixelGoogleId))
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id={{ $pixelGoogleId }}"></script>
|
||||
@if($pixelGoogleIds->isNotEmpty())
|
||||
@php $firstGoogleId = $pixelGoogleIds->first(); @endphp
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id={{ $firstGoogleId }}"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', '{{ $pixelGoogleId }}');
|
||||
@foreach($pixelGoogleIds as $id)
|
||||
gtag('config', '{{ $id }}');
|
||||
@endforeach
|
||||
</script>
|
||||
@endif
|
||||
|
||||
<!-- LinkedIn Insight -->
|
||||
@if(!empty($pixelLinkedinId))
|
||||
@if($pixelLinkedinIds->isNotEmpty())
|
||||
<script type="text/javascript">
|
||||
_linkedin_data_partner_id = "{{ $pixelLinkedinId }}";
|
||||
window._linkedin_data_partner_ids = window._linkedin_data_partner_ids || [];
|
||||
window._linkedin_data_partner_ids.push(_linkedin_data_partner_id);
|
||||
@foreach($pixelLinkedinIds as $id)
|
||||
window._linkedin_data_partner_ids.push("{{ $id }}");
|
||||
@endforeach
|
||||
(function(l) {
|
||||
if (!l){window.lintrk = function(a,b){window.lintrk.q.push([a,b])};
|
||||
window.lintrk.q=[]}
|
||||
@@ -76,7 +92,36 @@
|
||||
b.src = "https://snap.licdn.com/li.lms-analytics/insight.min.js";
|
||||
s.parentNode.insertBefore(b, s);})(window.lintrk);
|
||||
</script>
|
||||
<noscript><img height="1" width="1" style="display:none;" alt="" src="https://px.ads.linkedin.com/collect/?pid={{ $pixelLinkedinId }}&fmt=gif" /></noscript>
|
||||
@foreach($pixelLinkedinIds as $id)
|
||||
<noscript><img height="1" width="1" style="display:none;" alt="" src="https://px.ads.linkedin.com/collect/?pid={{ $id }}&fmt=gif" /></noscript>
|
||||
@endforeach
|
||||
@endif
|
||||
|
||||
<!-- TikTok Pixel -->
|
||||
@if($pixelTiktokIds->isNotEmpty())
|
||||
<script>
|
||||
!function (w, d, t) {
|
||||
w.TiktokAnalyticsObject=t;var ttq=w[t]=w[t]||[];ttq.methods=["page","track","identify","instances","debug","on","off","once","ready","alias","group","enableCookie","disableCookie","holdConsent","revokeConsent","grantConsent"],ttq.setAndDefer=function(t,e){t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}};for(var i=0;i<ttq.methods.length;i++)ttq.setAndDefer(ttq,ttq.methods[i]);ttq.instance=function(t){for(var e=ttq._i[t]||[],n=0;n<ttq.methods.length;n++)ttq.setAndDefer(e,ttq.methods[n]);return e},ttq.load=function(e,n){var r="https://analytics.tiktok.com/i18n/pixel/events.js",o=n&&n.mixpanel;ttq._i=ttq._i||{},ttq._i[e]=[],ttq._i[e]._u=r,ttq._t=ttq._t||{},ttq._t[e]=+new Date,ttq._o=ttq._o||{},ttq._o[e]=o||{};var a=d.createElement("script");a.type="text/javascript",a.async=!0,a.src=r+"?sdkid="+e+"&lib="+t;var c=d.getElementsByTagName("script")[0];c.parentNode.insertBefore(a,c)};
|
||||
@foreach($pixelTiktokIds as $id)
|
||||
ttq.load('{{ $id }}');
|
||||
ttq.page();
|
||||
@endforeach
|
||||
}(window, document, 'ttq');
|
||||
</script>
|
||||
@endif
|
||||
|
||||
<!-- Pinterest Tag -->
|
||||
@if($pixelPinterestIds->isNotEmpty())
|
||||
<script>
|
||||
!function(e,n,t,r,a,s,o){e[r]||(e[r]=function(){(e[r].q=e[r].q||[]).push(arguments)},e[r].q=e[r].q||[],s=n.createElement(t),s.async=!0,s.src="https://s.pntrac.com/tag.js",o=n.getElementsByTagName(t)[0],o.parentNode.insertBefore(s,o))}(window,document,"script","pintrk");
|
||||
@foreach($pixelPinterestIds as $id)
|
||||
pintrk('load', '{{ $id }}');
|
||||
pintrk('page');
|
||||
@endforeach
|
||||
</script>
|
||||
@foreach($pixelPinterestIds as $id)
|
||||
<noscript><img height="1" width="1" style="display:none;" alt="" src="https://ct.pinterest.com/v3/?event=init&tid={{ $id }}&noscript=1" /></noscript>
|
||||
@endforeach
|
||||
@endif
|
||||
</head>
|
||||
<body class="bg-[#FCFCFC] dark:bg-[#0C0C0C] min-h-screen flex flex-col justify-between items-center py-10 px-6 font-sans antialiased">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -37,22 +37,17 @@
|
||||
'dateTo' => $dateTo,
|
||||
], key('stats-overview-' . $dateFrom . '-' . $dateTo))
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<div class="lg:col-span-2">
|
||||
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsChart::class, [
|
||||
'record' => $record,
|
||||
'dateFrom' => $dateFrom,
|
||||
'dateTo' => $dateTo,
|
||||
], key('stats-chart-' . $dateFrom . '-' . $dateTo))
|
||||
</div>
|
||||
<div>
|
||||
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsRightBreakdown::class, [
|
||||
'record' => $record,
|
||||
'dateFrom' => $dateFrom,
|
||||
'dateTo' => $dateTo,
|
||||
], key('stats-right-breakdown-' . $dateFrom . '-' . $dateTo))
|
||||
</div>
|
||||
</div>
|
||||
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsChart::class, [
|
||||
'record' => $record,
|
||||
'dateFrom' => $dateFrom,
|
||||
'dateTo' => $dateTo,
|
||||
], key('stats-chart-' . $dateFrom . '-' . $dateTo))
|
||||
|
||||
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsRightBreakdown::class, [
|
||||
'record' => $record,
|
||||
'dateFrom' => $dateFrom,
|
||||
'dateTo' => $dateTo,
|
||||
], key('stats-right-breakdown-' . $dateFrom . '-' . $dateTo))
|
||||
|
||||
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlWorldMapWidget::class, [
|
||||
'record' => $record,
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
$browserIcons = [
|
||||
'Chrome' => 'heroicon-m-globe-alt',
|
||||
'Firefox' => 'heroicon-m-globe-americas',
|
||||
'Safari' => 'heroicon-m-compass',
|
||||
'Safari' => 'heroicon-m-globe-europe-africa',
|
||||
'Edge' => 'heroicon-m-globe-asia-australia',
|
||||
'Opera' => 'heroicon-m-bolt',
|
||||
'Internet Explorer' => 'heroicon-m-wrench',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<x-filament-widgets::widget>
|
||||
<div class="space-y-6">
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
|
||||
{{-- Countries --}}
|
||||
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
|
||||
@@ -11,10 +11,13 @@
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
@forelse ($visitsByCountry as $country => $count)
|
||||
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
|
||||
@php
|
||||
$pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0;
|
||||
$translatedCountry = \Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsRightBreakdown::getCountryTranslation($country);
|
||||
@endphp
|
||||
<div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ $country }}</span>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ $translatedCountry }}</span>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span></span>
|
||||
</div>
|
||||
<div class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
|
||||
@@ -27,5 +30,34 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Languages --}}
|
||||
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
|
||||
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-indigo-50 text-indigo-500 dark:bg-indigo-950/50 dark:text-indigo-400">
|
||||
<x-filament::icon icon="heroicon-o-language" class="h-5 w-5" />
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_languages') }}</h3>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
@forelse ($visitsByLanguage as $langCode => $count)
|
||||
@php
|
||||
$pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0;
|
||||
$langName = \Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsRightBreakdown::getLanguageTranslation($langCode);
|
||||
@endphp
|
||||
<div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ $langName }} ({{ strtoupper($langCode) }})</span>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span></span>
|
||||
</div>
|
||||
<div class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
|
||||
<div class="h-full rounded-full bg-indigo-500 transition-all duration-500" style="width: {{ $pct }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_language_data') }}</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</x-filament-widgets::widget>
|
||||
|
||||
@@ -1,183 +1,5 @@
|
||||
@php
|
||||
/**
|
||||
* World-map heat-map widget.
|
||||
*
|
||||
* Uses Natural Earth 110m simplified country paths (public-domain).
|
||||
* Countries are coloured using their ISO-3166-1 alpha-2 code which is stored
|
||||
* in the short_url_visits.country_code column.
|
||||
*
|
||||
* $countryData – array<ISO2, int> raw click counts
|
||||
* $normalized – array<ISO2, 0-100> intensity percentage
|
||||
* $maxCount – int
|
||||
* $totalClicks – int
|
||||
*/
|
||||
|
||||
/**
|
||||
* Very compact "mercator" paths for ~180 countries sourced from public-domain
|
||||
* Natural Earth data (110m resolution) encoded into a 950×500 viewport.
|
||||
*
|
||||
* Only the most visited countries are listed for performance; the rest render
|
||||
* in a neutral colour. Full-resolution SVG world maps can be bundled as an
|
||||
* asset if needed.
|
||||
*/
|
||||
$countryPaths = [
|
||||
'AF' => 'M 613 175 L 617 170 L 626 172 L 630 168 L 637 173 L 638 180 L 633 186 L 624 189 L 616 184 Z',
|
||||
'AL' => 'M 508 142 L 511 138 L 514 142 L 512 147 L 508 145 Z',
|
||||
'DZ' => 'M 470 148 L 500 148 L 500 185 L 470 185 L 455 175 L 455 158 Z',
|
||||
'AO' => 'M 500 240 L 520 240 L 520 270 L 500 270 L 490 255 Z',
|
||||
'AR' => 'M 265 330 L 280 320 L 285 360 L 275 400 L 260 410 L 250 390 L 255 350 Z',
|
||||
'AU' => 'M 730 310 L 790 295 L 820 310 L 830 345 L 810 370 L 760 375 L 725 355 L 720 330 Z',
|
||||
'AT' => 'M 500 120 L 514 118 L 516 124 L 502 126 Z',
|
||||
'AZ' => 'M 568 138 L 575 134 L 580 140 L 574 145 L 568 142 Z',
|
||||
'BD' => 'M 655 180 L 663 178 L 666 185 L 660 190 L 654 187 Z',
|
||||
'BE' => 'M 480 112 L 487 110 L 489 116 L 482 118 Z',
|
||||
'BF' => 'M 460 198 L 475 195 L 478 205 L 462 208 Z',
|
||||
'BY' => 'M 530 105 L 545 103 L 548 112 L 532 115 Z',
|
||||
'BJ' => 'M 475 205 L 480 202 L 482 215 L 477 218 Z',
|
||||
'BO' => 'M 265 295 L 280 288 L 285 310 L 268 315 Z',
|
||||
'BA' => 'M 507 130 L 514 128 L 515 136 L 508 137 Z',
|
||||
'BW' => 'M 518 278 L 530 275 L 532 290 L 520 292 Z',
|
||||
'BR' => 'M 255 220 L 320 210 L 340 240 L 330 300 L 290 320 L 255 310 L 235 280 L 240 250 Z',
|
||||
'BN' => 'M 724 218 L 728 215 L 730 220 L 726 223 Z',
|
||||
'BG' => 'M 525 130 L 536 128 L 537 136 L 526 137 Z',
|
||||
'KH' => 'M 705 210 L 715 207 L 717 218 L 707 220 Z',
|
||||
'CM' => 'M 490 210 L 503 207 L 505 230 L 491 232 Z',
|
||||
'CA' => 'M 55 65 L 200 55 L 220 85 L 180 100 L 100 108 L 55 95 Z',
|
||||
'CF' => 'M 502 220 L 520 218 L 522 235 L 504 237 Z',
|
||||
'TD' => 'M 498 188 L 515 185 L 517 220 L 499 222 Z',
|
||||
'CL' => 'M 250 295 L 258 290 L 260 380 L 252 385 L 248 350 Z',
|
||||
'CN' => 'M 640 130 L 740 125 L 755 160 L 745 200 L 700 215 L 660 205 L 635 185 L 630 160 Z',
|
||||
'CO' => 'M 225 220 L 255 215 L 260 250 L 240 258 L 220 245 Z',
|
||||
'CD' => 'M 503 238 L 535 230 L 540 270 L 520 278 L 500 270 Z',
|
||||
'CG' => 'M 496 238 L 505 236 L 507 255 L 497 257 Z',
|
||||
'CR' => 'M 205 215 L 212 212 L 213 220 L 206 222 Z',
|
||||
'HR' => 'M 505 126 L 514 124 L 514 132 L 506 133 Z',
|
||||
'CU' => 'M 198 183 L 218 180 L 220 188 L 200 191 Z',
|
||||
'CY' => 'M 540 152 L 548 150 L 549 155 L 541 156 Z',
|
||||
'CZ' => 'M 505 115 L 518 113 L 519 120 L 506 121 Z',
|
||||
'DK' => 'M 494 98 L 500 95 L 502 105 L 495 107 Z',
|
||||
'DO' => 'M 225 188 L 233 186 L 234 193 L 226 194 Z',
|
||||
'EC' => 'M 220 248 L 233 244 L 235 260 L 221 263 Z',
|
||||
'EG' => 'M 537 158 L 558 155 L 560 175 L 538 178 Z',
|
||||
'SV' => 'M 196 210 L 203 208 L 204 214 L 197 215 Z',
|
||||
'GQ' => 'M 487 228 L 493 226 L 494 233 L 488 234 Z',
|
||||
'ER' => 'M 553 192 L 563 188 L 565 198 L 554 200 Z',
|
||||
'EE' => 'M 525 95 L 535 93 L 536 99 L 526 101 Z',
|
||||
'ET' => 'M 545 205 L 570 198 L 574 220 L 548 228 Z',
|
||||
'FJ' => 'M 865 285 L 873 282 L 875 290 L 867 292 Z',
|
||||
'FI' => 'M 520 75 L 540 68 L 545 92 L 522 95 Z',
|
||||
'FR' => 'M 467 115 L 492 112 L 494 133 L 469 136 Z',
|
||||
'GA' => 'M 490 235 L 500 233 L 501 248 L 491 250 Z',
|
||||
'GM' => 'M 430 200 L 442 198 L 443 204 L 431 205 Z',
|
||||
'GE' => 'M 558 130 L 572 128 L 573 136 L 559 137 Z',
|
||||
'DE' => 'M 491 105 L 512 103 L 514 125 L 492 127 Z',
|
||||
'GH' => 'M 458 212 L 470 210 L 471 228 L 459 230 Z',
|
||||
'GR' => 'M 519 138 L 533 136 L 535 150 L 520 152 Z',
|
||||
'GT' => 'M 187 207 L 197 205 L 198 215 L 188 217 Z',
|
||||
'GN' => 'M 432 210 L 452 207 L 454 222 L 433 225 Z',
|
||||
'GW' => 'M 428 205 L 438 203 L 439 210 L 429 211 Z',
|
||||
'GY' => 'M 265 230 L 275 227 L 277 242 L 267 244 Z',
|
||||
'HT' => 'M 218 188 L 226 186 L 227 194 L 219 195 Z',
|
||||
'HN' => 'M 197 208 L 212 205 L 213 215 L 198 217 Z',
|
||||
'HU' => 'M 510 120 L 525 118 L 526 127 L 511 128 Z',
|
||||
'IN' => 'M 617 160 L 660 155 L 665 195 L 655 215 L 635 220 L 615 205 L 608 185 Z',
|
||||
'ID' => 'M 710 235 L 790 225 L 800 255 L 780 265 L 720 260 L 705 250 Z',
|
||||
'IR' => 'M 573 148 L 615 142 L 620 172 L 608 185 L 578 180 L 568 165 Z',
|
||||
'IQ' => 'M 557 148 L 580 145 L 582 170 L 558 173 Z',
|
||||
'IE' => 'M 453 105 L 463 103 L 464 115 L 454 117 Z',
|
||||
'IL' => 'M 545 155 L 550 153 L 551 163 L 546 165 Z',
|
||||
'IT' => 'M 492 128 L 512 125 L 518 148 L 508 158 L 494 150 Z',
|
||||
'CI' => 'M 445 215 L 462 212 L 463 228 L 446 231 Z',
|
||||
'JP' => 'M 770 135 L 795 128 L 800 155 L 775 162 L 765 150 Z',
|
||||
'JO' => 'M 548 155 L 557 153 L 558 162 L 549 163 Z',
|
||||
'KZ' => 'M 580 108 L 645 103 L 650 140 L 640 145 L 582 142 Z',
|
||||
'KE' => 'M 543 232 L 563 227 L 566 252 L 544 255 Z',
|
||||
'KP' => 'M 751 140 L 763 137 L 765 150 L 752 152 Z',
|
||||
'KR' => 'M 758 148 L 770 145 L 772 158 L 759 160 Z',
|
||||
'XK' => 'M 513 133 L 519 131 L 520 137 L 514 138 Z',
|
||||
'KW' => 'M 570 160 L 576 158 L 577 164 L 571 165 Z',
|
||||
'KG' => 'M 630 130 L 648 127 L 650 136 L 631 138 Z',
|
||||
'LA' => 'M 700 190 L 713 185 L 716 207 L 702 210 Z',
|
||||
'LV' => 'M 522 100 L 535 98 L 536 105 L 523 106 Z',
|
||||
'LB' => 'M 548 150 L 553 148 L 554 155 L 549 156 Z',
|
||||
'LS' => 'M 522 295 L 528 293 L 529 300 L 523 301 Z',
|
||||
'LR' => 'M 435 218 L 447 215 L 448 226 L 436 228 Z',
|
||||
'LY' => 'M 498 155 L 535 150 L 537 178 L 500 182 Z',
|
||||
'LT' => 'M 520 105 L 535 103 L 536 112 L 521 113 Z',
|
||||
'MK' => 'M 516 135 L 524 133 L 525 140 L 517 141 Z',
|
||||
'MG' => 'M 565 278 L 578 272 L 582 298 L 568 302 Z',
|
||||
'MW' => 'M 535 260 L 541 257 L 543 273 L 536 275 Z',
|
||||
'MY' => 'M 700 218 L 735 212 L 738 228 L 702 232 Z',
|
||||
'ML' => 'M 440 185 L 475 180 L 478 210 L 442 215 Z',
|
||||
'MR' => 'M 428 175 L 455 172 L 457 200 L 430 203 Z',
|
||||
'MX' => 'M 115 155 L 200 148 L 210 190 L 195 205 L 150 200 L 115 180 Z',
|
||||
'MD' => 'M 530 118 L 540 116 L 541 124 L 531 125 Z',
|
||||
'MN' => 'M 652 115 L 730 108 L 733 140 L 655 145 Z',
|
||||
'ME' => 'M 511 132 L 517 130 L 518 136 L 512 137 Z',
|
||||
'MA' => 'M 440 148 L 460 145 L 462 168 L 441 171 Z',
|
||||
'MZ' => 'M 533 265 L 552 260 L 555 295 L 535 298 Z',
|
||||
'MM' => 'M 675 178 L 695 172 L 698 208 L 677 212 Z',
|
||||
'NA' => 'M 495 275 L 520 270 L 522 295 L 497 298 Z',
|
||||
'NP' => 'M 635 163 L 658 160 L 659 170 L 636 172 Z',
|
||||
'NL' => 'M 483 107 L 494 105 L 495 114 L 484 115 Z',
|
||||
'NZ' => 'M 845 365 L 855 358 L 858 378 L 848 382 Z',
|
||||
'NI' => 'M 200 212 L 215 209 L 216 220 L 201 222 Z',
|
||||
'NE' => 'M 462 183 L 500 178 L 502 208 L 464 212 Z',
|
||||
'NG' => 'M 468 208 L 503 203 L 505 235 L 470 238 Z',
|
||||
'NO' => 'M 490 72 L 520 62 L 535 85 L 510 92 L 490 88 Z',
|
||||
'OM' => 'M 592 170 L 616 163 L 618 188 L 595 192 Z',
|
||||
'PK' => 'M 606 150 L 640 145 L 643 175 L 620 182 L 607 172 Z',
|
||||
'PS' => 'M 545 155 L 549 153 L 550 160 L 546 161 Z',
|
||||
'PA' => 'M 213 222 L 228 218 L 229 228 L 214 230 Z',
|
||||
'PG' => 'M 780 255 L 815 248 L 818 268 L 782 272 Z',
|
||||
'PY' => 'M 265 310 L 285 305 L 288 328 L 267 331 Z',
|
||||
'PE' => 'M 225 262 L 265 255 L 268 300 L 228 308 Z',
|
||||
'PH' => 'M 745 195 L 773 188 L 776 220 L 748 224 Z',
|
||||
'PL' => 'M 508 105 L 530 102 L 532 120 L 510 122 Z',
|
||||
'PT' => 'M 450 125 L 462 122 L 463 140 L 451 143 Z',
|
||||
'PR' => 'M 234 188 L 240 186 L 241 192 L 235 193 Z',
|
||||
'RO' => 'M 520 118 L 540 115 L 542 130 L 522 132 Z',
|
||||
'RU' => 'M 540 60 L 820 50 L 830 120 L 770 135 L 680 118 L 590 100 L 548 88 Z',
|
||||
'RW' => 'M 530 245 L 537 242 L 538 250 L 531 251 Z',
|
||||
'SA' => 'M 553 163 L 607 155 L 610 195 L 580 210 L 553 205 Z',
|
||||
'SN' => 'M 425 198 L 448 195 L 450 208 L 427 211 Z',
|
||||
'RS' => 'M 512 126 L 524 124 L 525 135 L 513 136 Z',
|
||||
'SL' => 'M 430 218 L 442 215 L 443 225 L 431 227 Z',
|
||||
'SO' => 'M 555 210 L 578 200 L 582 228 L 560 238 L 555 225 Z',
|
||||
'ZA' => 'M 500 288 L 536 280 L 540 310 L 510 318 L 495 308 Z',
|
||||
'SS' => 'M 525 215 L 547 210 L 549 232 L 527 235 Z',
|
||||
'ES' => 'M 452 130 L 485 125 L 487 148 L 453 152 Z',
|
||||
'LK' => 'M 641 205 L 647 202 L 649 213 L 643 215 Z',
|
||||
'SD' => 'M 520 183 L 552 177 L 555 215 L 522 220 Z',
|
||||
'SR' => 'M 268 228 L 280 225 L 281 238 L 269 240 Z',
|
||||
'SZ' => 'M 526 292 L 532 290 L 533 297 L 527 298 Z',
|
||||
'SE' => 'M 503 72 L 520 68 L 525 100 L 505 103 Z',
|
||||
'CH' => 'M 487 118 L 502 116 L 503 124 L 488 125 Z',
|
||||
'SY' => 'M 545 143 L 563 140 L 565 155 L 546 157 Z',
|
||||
'TW' => 'M 753 172 L 759 169 L 761 178 L 755 180 Z',
|
||||
'TJ' => 'M 625 135 L 642 132 L 644 142 L 627 144 Z',
|
||||
'TZ' => 'M 530 250 L 555 244 L 557 272 L 532 275 Z',
|
||||
'TH' => 'M 692 193 L 712 188 L 714 218 L 694 222 Z',
|
||||
'TL' => 'M 762 258 L 775 255 L 776 263 L 763 265 Z',
|
||||
'TG' => 'M 470 210 L 476 208 L 477 225 L 471 226 Z',
|
||||
'TN' => 'M 490 143 L 502 141 L 503 158 L 491 160 Z',
|
||||
'TR' => 'M 530 132 L 575 127 L 577 148 L 532 152 Z',
|
||||
'TM' => 'M 590 130 L 623 126 L 625 145 L 592 148 Z',
|
||||
'UG' => 'M 530 232 L 548 228 L 550 248 L 532 251 Z',
|
||||
'UA' => 'M 522 108 L 560 104 L 562 128 L 524 132 Z',
|
||||
'AE' => 'M 590 170 L 605 167 L 606 178 L 591 180 Z',
|
||||
'GB' => 'M 460 100 L 480 95 L 482 118 L 462 122 Z',
|
||||
'US' => 'M 55 105 L 215 98 L 225 148 L 215 178 L 150 185 L 60 168 Z',
|
||||
'UY' => 'M 275 330 L 292 325 L 294 342 L 277 345 Z',
|
||||
'UZ' => 'M 600 118 L 635 113 L 637 135 L 602 138 Z',
|
||||
'VE' => 'M 235 220 L 270 212 L 273 240 L 237 245 Z',
|
||||
'VN' => 'M 708 185 L 726 178 L 730 215 L 710 218 Z',
|
||||
'YE' => 'M 563 188 L 608 180 L 610 202 L 565 207 Z',
|
||||
'ZM' => 'M 515 258 L 543 252 L 545 278 L 517 281 Z',
|
||||
'ZW' => 'M 518 275 L 540 270 L 542 292 L 520 294 Z',
|
||||
];
|
||||
|
||||
// Compute top 10 for the ranked sidebar
|
||||
// Computes top 10 for the ranked sidebar
|
||||
$topCountries = collect($countryData)->sortDesc()->take(10);
|
||||
|
||||
// Country name lookup (ISO-2 → English name)
|
||||
@@ -208,9 +30,6 @@
|
||||
'UG'=>'Uganda','UA'=>'Ukraine','AE'=>'UAE','GB'=>'United Kingdom','US'=>'United States','UY'=>'Uruguay',
|
||||
'UZ'=>'Uzbekistan','VE'=>'Venezuela','VN'=>'Vietnam','YE'=>'Yemen','ZM'=>'Zambia','ZW'=>'Zimbabwe',
|
||||
];
|
||||
|
||||
// Unique Alpine.js component ID to allow multiple instances
|
||||
$mapId = 'world-map-' . Str::random(8);
|
||||
@endphp
|
||||
|
||||
<x-filament-widgets::widget>
|
||||
@@ -234,15 +53,17 @@
|
||||
</div>
|
||||
|
||||
{{-- Legend --}}
|
||||
<div class="hidden items-center gap-2 sm:flex">
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.world_map_fewer') }}</span>
|
||||
<div class="flex gap-0.5">
|
||||
@foreach([10, 25, 45, 65, 85, 100] as $intensity)
|
||||
<div class="h-4 w-4 rounded-sm" style="background: hsl(243 100% {{ max(30, 90 - $intensity * 0.55) }}% / {{ max(0.12, $intensity / 100) }});"></div>
|
||||
@endforeach
|
||||
@if(!empty($countryData))
|
||||
<div class="hidden items-center gap-2 sm:flex">
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.world_map_fewer') }}</span>
|
||||
<div class="flex gap-0.5">
|
||||
@foreach([10, 25, 45, 65, 85, 100] as $intensity)
|
||||
<div class="h-4 w-4 rounded-sm" style="background: hsl(243 100% {{ max(30, 90 - $intensity * 0.55) }}% / {{ max(0.12, $intensity / 100) }});"></div>
|
||||
@endforeach
|
||||
</div>
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.world_map_more') }}</span>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.world_map_more') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if(empty($countryData))
|
||||
@@ -261,19 +82,101 @@
|
||||
{{-- SVG Map --}}
|
||||
<div class="relative lg:col-span-3"
|
||||
x-data="{
|
||||
countryData: {{ json_encode($countryData) }},
|
||||
normalized: {{ json_encode($normalized) }},
|
||||
countryNames: {{ json_encode($countryNames) }},
|
||||
tooltip: { show: false, country: '', count: 0, x: 0, y: 0 },
|
||||
showTooltip(country, count, event) {
|
||||
this.tooltip = { show: true, country, count, x: event.offsetX, y: event.offsetY };
|
||||
|
||||
init() {
|
||||
this.$nextTick(() => {
|
||||
const svg = this.$el.querySelector('#world-map');
|
||||
if (!svg) return;
|
||||
|
||||
// 1. Annotate paths/groups inside the SVG dynamically
|
||||
const elements = svg.querySelectorAll('[id]');
|
||||
elements.forEach(el => {
|
||||
const code = el.id.toUpperCase();
|
||||
if (code.length !== 2) return;
|
||||
|
||||
const count = this.countryData[code] || 0;
|
||||
if (count > 0) {
|
||||
const intensity = this.normalized[code] || 0;
|
||||
el.classList.add('world-map-country-active');
|
||||
el.style.setProperty('--intensity', intensity / 100);
|
||||
} else {
|
||||
el.classList.add('world-map-country-inactive');
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Render dynamic pulsing dots at the center of top 5 countries
|
||||
const topActiveCodes = Object.keys(this.countryData).slice(0, 5);
|
||||
topActiveCodes.forEach((code, index) => {
|
||||
const el = svg.querySelector(`#${code.toLowerCase()}`);
|
||||
if (el) {
|
||||
try {
|
||||
const bbox = el.getBBox();
|
||||
if (bbox && bbox.width > 0 && bbox.height > 0) {
|
||||
const cx = bbox.x + bbox.width / 2;
|
||||
const cy = bbox.y + bbox.height / 2;
|
||||
|
||||
// Create pulsing halo circle
|
||||
const pulse = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
pulse.setAttribute('cx', cx);
|
||||
pulse.setAttribute('cy', cy);
|
||||
pulse.setAttribute('r', '3');
|
||||
pulse.setAttribute('class', 'world-map-pulse-dot');
|
||||
pulse.style.setProperty('--delay', `${index * 0.4}s`);
|
||||
|
||||
// Create solid core circle
|
||||
const core = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
core.setAttribute('cx', cx);
|
||||
core.setAttribute('cy', cy);
|
||||
core.setAttribute('r', '3');
|
||||
core.setAttribute('class', 'world-map-core-dot');
|
||||
|
||||
svg.appendChild(pulse);
|
||||
svg.appendChild(core);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Could not get bounding box for country ' + code, e);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
hideTooltip() { this.tooltip.show = false; }
|
||||
|
||||
handleMouseMove(event) {
|
||||
const activeEl = event.target.closest('.world-map-country-active');
|
||||
if (activeEl) {
|
||||
const code = activeEl.id.toUpperCase();
|
||||
const count = this.countryData[code] || 0;
|
||||
const name = this.countryNames[code] || code;
|
||||
|
||||
this.tooltip.show = true;
|
||||
this.tooltip.country = name;
|
||||
this.tooltip.count = count;
|
||||
|
||||
const rect = this.$el.getBoundingClientRect();
|
||||
this.tooltip.x = event.clientX - rect.left;
|
||||
this.tooltip.y = event.clientY - rect.top;
|
||||
} else {
|
||||
this.tooltip.show = false;
|
||||
}
|
||||
},
|
||||
|
||||
hideTooltip() {
|
||||
this.tooltip.show = false;
|
||||
}
|
||||
}"
|
||||
@mousemove="handleMouseMove($event)"
|
||||
@mouseleave="hideTooltip()"
|
||||
>
|
||||
{{-- Tooltip --}}
|
||||
<div x-show="tooltip.show"
|
||||
x-cloak
|
||||
:style="`left: ${tooltip.x + 12}px; top: ${tooltip.y - 8}px`"
|
||||
class="pointer-events-none absolute z-20 rounded-lg border border-gray-200 bg-white px-3 py-2 shadow-xl dark:border-gray-700 dark:bg-gray-800"
|
||||
style="min-width: 130px;"
|
||||
style="min-width: 130px; transition: left 0.05s ease, top 0.05s ease;"
|
||||
>
|
||||
<p class="text-xs font-semibold text-gray-800 dark:text-gray-200" x-text="tooltip.country"></p>
|
||||
<p class="mt-0.5 text-xs text-indigo-600 dark:text-indigo-400">
|
||||
@@ -281,80 +184,8 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-xl bg-gray-50 dark:bg-gray-800/50">
|
||||
<svg viewBox="0 0 950 500" xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-full w-full"
|
||||
style="min-height: 280px; max-height: 420px;"
|
||||
>
|
||||
{{-- Ocean background --}}
|
||||
<rect width="950" height="500" fill="currentColor" class="text-gray-100 dark:text-gray-800" rx="8"/>
|
||||
|
||||
{{-- Grid lines (latitude/longitude) --}}
|
||||
<g class="opacity-30" stroke="currentColor" stroke-width="0.5" class="text-gray-300 dark:text-gray-600">
|
||||
@foreach([83, 167, 250, 333, 417] as $x)
|
||||
<line x1="{{ $x }}" y1="0" x2="{{ $x }}" y2="500" stroke="#94a3b8" stroke-width="0.4" opacity="0.4"/>
|
||||
@endforeach
|
||||
@foreach([100, 200, 300, 400] as $y)
|
||||
<line x1="0" y1="{{ $y }}" x2="950" y2="{{ $y }}" stroke="#94a3b8" stroke-width="0.4" opacity="0.4"/>
|
||||
@endforeach
|
||||
</g>
|
||||
|
||||
{{-- Countries --}}
|
||||
@foreach($countryPaths as $code => $path)
|
||||
@php
|
||||
$count = $countryData[$code] ?? 0;
|
||||
$intensity = $normalized[$code] ?? 0;
|
||||
$name = $countryNames[$code] ?? $code;
|
||||
|
||||
if ($count > 0) {
|
||||
// Active country: indigo hue, darkness based on intensity
|
||||
$lightness = max(30, 88 - ($intensity * 0.55));
|
||||
$alpha = max(0.15, $intensity / 100);
|
||||
$fill = "hsl(243 100% {$lightness}% / {$alpha})";
|
||||
$stroke = "hsl(243 80% 40% / 0.4)";
|
||||
$strokeWidth = "0.8";
|
||||
} else {
|
||||
// Inactive country: neutral gray
|
||||
$fill = "hsl(220 13% 91%)";
|
||||
$stroke = "hsl(220 13% 80%)";
|
||||
$strokeWidth = "0.4";
|
||||
}
|
||||
@endphp
|
||||
<path
|
||||
d="{{ $path }}"
|
||||
fill="{{ $fill }}"
|
||||
stroke="{{ $stroke }}"
|
||||
stroke-width="{{ $strokeWidth }}"
|
||||
class="{{ $count > 0 ? 'cursor-pointer transition-all duration-200 hover:brightness-90 hover:stroke-indigo-500' : '' }}"
|
||||
@if($count > 0)
|
||||
@mouseenter="showTooltip('{{ $name }}', {{ $count }}, $event)"
|
||||
@mouseleave="hideTooltip()"
|
||||
@endif
|
||||
style="transition: fill 0.2s ease;"
|
||||
/>
|
||||
@endforeach
|
||||
|
||||
{{-- Pulse dots on top countries --}}
|
||||
@php
|
||||
$dotPositions = [
|
||||
'US' => [135, 140], 'GB' => [471, 108], 'DE' => [502, 115], 'FR' => [480, 124],
|
||||
'IN' => [638, 188], 'CN' => [692, 167], 'BR' => [287, 265], 'RU' => [685, 90],
|
||||
'AU' => [775, 340], 'CA' => [127, 80], 'JP' => [782, 145], 'MX' => [162, 165],
|
||||
'ES' => [468, 138], 'IT' => [504, 137], 'TR' => [553, 140], 'PL' => [520, 112],
|
||||
'NL' => [488, 110], 'SA' => [580, 182], 'ZA' => [517, 299], 'AR' => [268, 360],
|
||||
];
|
||||
@endphp
|
||||
@foreach($topCountries->take(5)->keys() as $rank => $code)
|
||||
@if(isset($dotPositions[$code]))
|
||||
@php [$dx, $dy] = $dotPositions[$code]; @endphp
|
||||
<circle cx="{{ $dx }}" cy="{{ $dy }}" r="5" fill="hsl(243 100% 55%)" opacity="0.9">
|
||||
<animate attributeName="r" values="5;9;5" dur="{{ 1.5 + $rank * 0.3 }}s" repeatCount="indefinite"/>
|
||||
<animate attributeName="opacity" values="0.9;0.2;0.9" dur="{{ 1.5 + $rank * 0.3 }}s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
<circle cx="{{ $dx }}" cy="{{ $dy }}" r="3" fill="hsl(243 100% 65%)" opacity="1"/>
|
||||
@endif
|
||||
@endforeach
|
||||
</svg>
|
||||
<div class="overflow-hidden rounded-xl border border-gray-100 dark:border-gray-800/80">
|
||||
{!! $svgContent !!}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -401,3 +232,100 @@
|
||||
@endif
|
||||
</div>
|
||||
</x-filament-widgets::widget>
|
||||
|
||||
<style>
|
||||
/* Responsive World Map SVG */
|
||||
#world-map {
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
display: block;
|
||||
background-color: #f8fafc; /* Slate-50 (light mode ocean) */
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.dark #world-map {
|
||||
background-color: #0b0f19; /* Custom premium dark blue-gray (dark mode ocean) */
|
||||
}
|
||||
|
||||
/* Transition for SVG map paths */
|
||||
#world-map path, #world-map g {
|
||||
transition: fill 0.3s ease, stroke 0.3s ease, stroke-width 0.2s ease;
|
||||
}
|
||||
|
||||
/* Inactive countries */
|
||||
.world-map-country-inactive {
|
||||
fill: #f1f5f9 !important; /* Slate-100 */
|
||||
stroke: #cbd5e1 !important; /* Slate-300 */
|
||||
stroke-width: 0.4px !important;
|
||||
}
|
||||
|
||||
.dark .world-map-country-inactive {
|
||||
fill: #1e293b !important; /* Slate-800 */
|
||||
stroke: #334155 !important; /* Slate-700 */
|
||||
}
|
||||
|
||||
/* Active countries heatmap */
|
||||
.world-map-country-active {
|
||||
/* Light mode: higher intensity = darker, richer indigo-700; lower intensity = light indigo-100 */
|
||||
fill: hsla(243, 85%, calc(90% - (var(--intensity) * 45%)), calc(0.25 + var(--intensity) * 0.75)) !important;
|
||||
stroke: rgba(99, 102, 241, 0.4) !important; /* Indigo-500 with opacity */
|
||||
stroke-width: 0.8px !important;
|
||||
}
|
||||
|
||||
.world-map-country-active:hover {
|
||||
stroke: #6366f1 !important; /* Indigo-500 highlight */
|
||||
stroke-width: 1.2px !important;
|
||||
filter: brightness(0.95);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dark .world-map-country-active {
|
||||
/* Dark mode: higher intensity = glowing indigo-400; lower intensity = deep indigo-900 */
|
||||
fill: hsla(243, 90%, calc(25% + (var(--intensity) * 40%)), calc(0.3 + var(--intensity) * 0.7)) !important;
|
||||
stroke: rgba(129, 140, 248, 0.5) !important; /* Indigo-400 with opacity */
|
||||
}
|
||||
|
||||
.dark .world-map-country-active:hover {
|
||||
stroke: #818cf8 !important; /* Indigo-400 highlight */
|
||||
stroke-width: 1.2px !important;
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
/* Pulse Dots CSS */
|
||||
.world-map-pulse-dot {
|
||||
fill: #6366f1;
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
animation: world-map-pulse 2.2s infinite ease-in-out;
|
||||
animation-delay: var(--delay, 0s);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dark .world-map-pulse-dot {
|
||||
fill: #818cf8;
|
||||
}
|
||||
|
||||
.world-map-core-dot {
|
||||
fill: #4f46e5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dark .world-map-core-dot {
|
||||
fill: #6366f1;
|
||||
}
|
||||
|
||||
@keyframes world-map-pulse {
|
||||
0% {
|
||||
r: 3px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
50% {
|
||||
r: 9px;
|
||||
opacity: 0.25;
|
||||
}
|
||||
100% {
|
||||
r: 3px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
409
resources/views/widgets/world-map.svg
Normal file
409
resources/views/widgets/world-map.svg
Normal file
@@ -0,0 +1,409 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
x="0px"
|
||||
y="0px"
|
||||
width="784.077px"
|
||||
height="458.627px"
|
||||
viewBox="30.767 241.591 784.077 458.627"
|
||||
id="world-map">
|
||||
<title>Simple World Map</title>
|
||||
<desc>
|
||||
Author: Al MacDonald
|
||||
Editor: Fritz Lekschas
|
||||
License: CC BY-SA 3.0
|
||||
ID: ISO 3166-1 or "_[a-zA-Z]" if an ISO code is not available
|
||||
</desc>
|
||||
<g>
|
||||
<path id="_somaliland" d="M512.674,502.797l3.526,2.403l1.046-0.052l8.757-3.008l0.994,3.206l-0.701,2.706l-1.893,1.503l-4.729-0.302l-6.769-4.158L512.674,502.797L512.674,502.797z"/>
|
||||
<path id="ae" d="M528.466,468.135l0.753,3.008l8.522,0.752l0.596-6.172l1.644-0.897l0.448-2.257l-2.688,0.753l-2.99,4.521L528.466,468.135L528.466,468.135z"/>
|
||||
<path id="af" d="M545.85,435.383l1.374,10.771l3.423,0.752l0.32,1.937l-2.455,2.049l4.573,3.691l8.885-3.198l0.709-3.786l5.593-3.491l2.145-8.091l1.599-1.722l-1.659-2.887l5.412-3.347l-0.691-0.968l-2.498,0.155l-0.226,2.299l-3.354-0.033l-0.062-3.068l-1.079-1.288l-1.815,1.649l0.052,1.515l-2.739,1.036l-5.059-0.319l-6.568,6.881L545.85,435.383L545.85,435.383z"/>
|
||||
<path id="al" d="M450.679,420.438v3.984l1.142,2.152l0.82-0.096l1.409-2.566l-0.821-1.15l-0.319-2.844l-1.089-1.012L450.679,420.438L450.679,420.438z"/>
|
||||
<path id="am" d="M507.47,420.549l4.149,5.411l-1.218,1.427l-2.939-0.51l-3.646-3.268l0.196-2.146L507.47,420.549L507.47,420.549z"/>
|
||||
<g id="ao">
|
||||
<path class="mainland" d="M437.366,547.461l2.948,11.003l-0.069,3.478l-4.313,4.633l-0.647,7.527l16.597,0.147l5.395,1.954l4.45-0.58l-2.594-3.251l0.01-9.282l5.101-0.217v-3.621l-4.142-0.172l-0.829-8.575l-1.746,0.024l-0.943-0.848l-1.027,0.054l-1.365,2.646h-5.255l-1.22-1.227l0.363-1.738l-1.436-2.101L437.366,547.461L437.366,547.461z"/>
|
||||
<path d="M435.577,544.453l1.504,1.953l1.945-1.842l-0.571-1.909l-0.483-0.035L435.577,544.453L435.577,544.453z"/>
|
||||
</g>
|
||||
<g id="ar">
|
||||
<path class="mainland" d="M279.05,600.613l1.677,1.571l-6.371,9.467l-2.239,2.479l0.777,10.813l4.918,5.974l-4.132,7.209l-3.129,1.35h-3.579l1.003,5.627l-5.593,1.92l1.34,4.729l-3.354,10.701l4.141,3.38l-2.239,5.515l-3.804,5.975l2.014,4.165l-4.918,0.786l-4.028-4.951l-0.674-15.432l-6.258-26.209l1.893-9.163l-4.028-11.713l2.68-15.204l2.463-2.931l-0.605-2.222l3.164-2.888l7.054,0.483l3.942,4.21l4.555,0.078l4.668,2.853l-1.375,3.217l0.329,3.25l6.612-0.312L279.05,600.613L279.05,600.613z"/>
|
||||
<path d="M264.745,687.564l0.225,4.951l3.803-0.337l3.242-2.144l-5.48-1.124L264.745,687.564L264.745,687.564z"/>
|
||||
</g>
|
||||
<path id="at" d="M430.46,403.459l-0.562,1.167l0.483,0.83l2.015-0.415h1.711l1.857,1.572l3.95-0.717l2.904-1.729l0.743-1.167l-0.112-1.504l-2.611-1.954l-3.501,0.035l-0.294,1.988l-3.683,1.797L430.46,403.459L430.46,403.459z"/>
|
||||
<g id="au">
|
||||
<path class="mainland" d="M672.961,609.067l-0.303,21.938l-3.371,2.472l-0.303,2.161l4.598,3.086l11.35-2.161h5.826l2.145-3.095l12.879-2.472l9.198,2.784l-0.614,3.708l1.228,3.708l7.055-1.236l0.303,1.851l-4.599,3.396l1.53,1.236l3.37-1.236l-0.917,10.2l6.44,4.944l3.683-1.236l1.841,1.852l10.735-1.548l10.123-16.381l3.682-0.925l7.357-13.596l1.841-11.739l-4.599-5.869l1.842-1.236l-3.684-11.436l-3.984-2.783l0.614-15.448l-3.684-2.782l-0.916-8.652h-1.842l-6.138,20.392l-3.37,0.312l-7.668-7.728l4.297-11.437l-7.971-1.547l-8.896,2.472l-2.454,7.104l-3.984,0.925l-0.304-4.944l-16.252,9.889l0.304,3.708l-2.454,3.397h-6.139l-13.19,5.56L672.961,609.067L672.961,609.067z"/>
|
||||
<path d="M728.775,668.089l-1.531,6.181l0.304,4.322l4.599-0.312l5.213-8.03L728.775,668.089L728.775,668.089z"/>
|
||||
</g>
|
||||
<path id="az" d="M508.931,418.674l-0.873,1.486l4.071,5.342l1.418-0.458l2.334,2.446l1.011-4.287l2.533,0.406l-0.104-1.229l-4.165-3.646l-0.795,2.143L508.931,418.674L508.931,418.674z"/>
|
||||
<path id="ba" d="M442.708,411.084l-0.319,0.527l5.801,5.981l2.127-3.13l-0.078-1.234l-1.857-2.256L442.708,411.084L442.708,411.084z"/>
|
||||
<path id="bd" d="M616.256,457.908l-1.134,2.049l2.938,5.584l0.087,4.357l0.535,1.166l3.449,0.061l1.953-1.875l1.418,0.855l0.285,2.652l1.133-0.708l0.069-3.389l-0.951-0.112l-0.596-2.879l-2.403-0.086l-0.596-1.6l1.469-1.962l0.024-0.97h-4.269L616.256,457.908L616.256,457.908z"/>
|
||||
<path id="be" d="M414.019,391.704l-0.554,1.383l5.947,3.925l0.4,0.051l0.375-1.094l0.837-0.588l-1.336-1.499h-0.916l-1.255-1.426L414.019,391.704L414.019,391.704z"/>
|
||||
<path id="bf" d="M404.493,493.496l3.146-0.25l5.16,7.295l-4.789,3.613l-3.466-0.892l-4.66,0.062l-0.752,2.73l-3.907,0.19l-1.071-1.461l1.385-4.442L404.493,493.496L404.493,493.496z"/>
|
||||
<path id="bg" d="M457.092,414.066l0.139,4.305l1.452,3.025l5.454,0.095l2.455-1.737l2.412-0.959l-0.588-2.75l0.545-1.469l-1.228-0.641l-1.687,0.139l-1.323,1.332l-5.549,0.043L457.092,414.066L457.092,414.066z"/>
|
||||
<path id="bi" d="M478.504,538.385l3.691-0.078l-0.959,3.232l-0.935,0.812h-1.142l-0.812-2.187L478.504,538.385L478.504,538.385z"/>
|
||||
<path id="bj" d="M411.512,515.288h1.833l0.104-5.204l2.315-3.363l-0.104-5.852l-2.102-0.052l-3.604,2.816l1.504,2.87L411.512,515.288L411.512,515.288z"/>
|
||||
<path id="bn" d="M689.038,515.08l-2.489,3.018l2.04,0.641l1.149-1.608L689.038,515.08L689.038,515.08z"/>
|
||||
<path id="bo" d="M238.631,561.361l7.114-3.104l2.351,0.226l1.565,6.534l10.839,3.604l1.79,5.523l4.469,0.562l1.902,4.729l-1.34,4.278l-7.27,0.562l-2.68,6.872l-5.705-0.112l-1.79-0.337l-3.293,3.197l-1.625-0.156l-5.593-12.957l1.547-2.316l0.545-9.163l-1.383-5.455L238.631,561.361L238.631,561.361z"/>
|
||||
<path id="br" d="M286.631,618.464l5.402-10.391l0.198-8.73l10.079-6.501h5.645l4.435-7.512l0.804-14.418l-1.815-3.855l10.684-9.751l0.406-10.761l-14.514-7.105l-17.53-5.48l-8.264-0.812l2.221-4.669l-0.604-7.104l-1.808-0.596l-2.671,5.308l-1.4,1.754l-3.595-1.59l-12.093,4.261l-4.028-5.073l0.648-5.299l-3.804,3.872l-4.201-2.265l-0.424,0.597l0.009,1.841l3.622,1.945l-5.437,5.73l-3.432-0.034l-3.475-3.535l-3.933,0.121l-0.484,4.2l2.256,2.739l-2.663,8.532l-3.112,0.241l-4.953,3.13l-1.21,6.146l4.296,4.599l0.787-0.89l3.017-0.812l2.576,4.34l7.374-3.164l2.861,0.165l1.971,6.976l10.52,3.337l1.815,5.564l4.478,0.537l2.135,5.314l-1.443,4.729l1.884,2.474l-0.276,3.683l5.048-0.477l4.625,5.844l-0.363,4.105l2.74,2.316l-6.57,9.95L286.631,618.464L286.631,618.464z"/>
|
||||
<g id="bs">
|
||||
<path d="M222.121,463.112l-1.089-0.337l-0.086,2.101l1.34,1.349l0.917-1.349L222.121,463.112L222.121,463.112z"/>
|
||||
<path d="M224.29,466.397l-1.504,0.838l1.417,2.021l0.752-1.011L224.29,466.397L224.29,466.397z"/>
|
||||
<path d="M229.14,467.91l-1.591-0.087l0.165,1.012l1.167,1.687l1.002-1.099L229.14,467.91L229.14,467.91z"/>
|
||||
<path d="M228.388,465.896l-2.593-1.099l-0.501-2.609l1.002-0.425l1.003,2.023l1.002,0.76L228.388,465.896L228.388,465.896z"/>
|
||||
<path d="M225.881,460.588l-1.34-0.337l-0.251-1.686l-1.417-0.501l0.917-0.926l1.668,0.588l1.253,0.762L225.881,460.588L225.881,460.588z"/>
|
||||
</g>
|
||||
<path id="bt" d="M616.108,453.561l1.34,1.833l4.528,0.034l-0.458-2.507L616.108,453.561L616.108,453.561z"/>
|
||||
<path id="bw" d="M454.56,594.589l1.858,0.57l-0.26,5.314l1.911,0.26l4.391-3.959l5.273,0.57l1.399-3.545l6.673-6.095l-8.013-9.223l-0.104-1.514l-0.883-0.26l-2.43,2.24l-6.31,0.154l-0.884,7.866l-2.479,0.57L454.56,594.589L454.56,594.589z"/>
|
||||
<path id="by" d="M456.418,382.861l1.297,2.135l-0.519,1.703l0.086,1.349l0.476,1.616l2.68-1.521l3.329,0.086l2.334,0.959h5.922l1.729-4.141l1.037-1.564v-1.045l-3.717-5.23l-3.285-1.305l-2.68-0.303l-2.335,0.743l0.088,2.351l-3.241,4.098L456.418,382.861L456.418,382.861z"/>
|
||||
<path id="bz" d="M191.823,483.228l-0.043,3.154h0.726l2.472-4.615h-1.677L191.823,483.228L191.823,483.228z"/>
|
||||
<g id="ca">
|
||||
<path class="mainland" d="M151.767,281.182l1.72,2.602l0.864,3.475l4.305,1.081l3.017-3.25l2.585,1.306l7.321,0.647l5.169-2.17l0.864,7.157h3.017v-3.034l3.017,0.216l7.538,8.895l4.953,3.035l-2.584,4.123l1.081,1.081l9.673,1.953l0.216,4.34l2.585,0.433l0.648-6.51l4.089-1.08l3.017,4.556l6.457,3.034l3.233,0.647l2.152-2.602l0.216-4.124l3.873-2.387l1.288,3.476l-3.449,6.077l0.432,3.033l1.937-3.033l3.873-3.475l0.216-4.556l-2.152-3.476l0.648-2.817l5.169-2.603l2.369,1.738l0.432,15.188l3.657-3.25l2.152,1.305l-3.017,5.204l3.873,0.865l5.602-8.679l4.737,4.986l-1.936,8.895l-4.737,2.603l-4.521-2.169l-8.177,1.736l0.864,2.818l-2.152,3.476l-6.673,1.521l-7.538,5.86l-6.673,8.896l-0.864,2.817l4.521,1.737l1.72,4.339l6.241,6.293l9.906,4.339l-2.152,9.975l-0.216,2.818l2.585,1.736l3.449-4.555l0.432-8.679l5.385-0.216l2.584-4.988l0.433-7.589l6.889-13.45l8.609,3.034l4.521,6.293l-1.937,6.292l3.449,1.954l8.394-5.646l2.368,15.404l7.754,9.327l0.216,4.771l-8.609,2.17l-4.088,4.338l-8.609-1.953l-4.305-0.217l-7.538,5.86l4.521-1.081l5.601-1.08l1.081,1.305l-1.504,4.771l0.216,4.34l2.585,1.736l2.584-0.648l1.296-1.953h1.72l-2.801,5.205l-5.385,0.215l-2.368,3.476h-3.017l-0.864-2.603l4.305-4.338l-5.169,1.737l-0.233-7.374l-1.487-0.863l-4.521,1.953l-0.432,3.691h-10.338l-8.832,6.08l-11.842,3.908l-1.288-1.738l5.964-8.902l-3.388-3.261l-2.152-4.132l-4.383-3.346l-4.702-0.389l-8.428-5.904l-61.122-10.043l-1.011-4.142l-5.602-5.204v-4.339l0.865-3.907l-0.433-2.168l-2.152-2.171l-0.432-3.475l5.602-3.908l-3.449-18.653l-4.737-0.217l-4.305-5.645L151.767,281.182L151.767,281.182z"/>
|
||||
<path d="M130.684,350.117l-1.47,2.818l0.51,1.996l0.959,0.598l-0.225,0.812l-1.029,0.294l0.294,2.965l1.106,1.115l0.882-0.959l-1.106-2.887l0.657-2.299l1.616-2.152l-1.175-1.997L130.684,350.117L130.684,350.117z"/>
|
||||
<path d="M135.542,367.008l-1.323,0.52l2.429,2.818l0.588,3.336l2.429,2.592l2.058-0.371v-3.406l-2.498-1.557L135.542,367.008L135.542,367.008z"/>
|
||||
<path d="M268.15,295.833l-1.53,1.547l1.34,2.126l6.328,0.77l-4.028-4.252L268.15,295.833L268.15,295.833z"/>
|
||||
<path d="M191.105,270.143l0.19,3.475l-6.898,7.148l1.729,5.791l4.979-1.348l2.878-4.253l7.278-2.706l5.938-0.389l-4.599-5.022l-2.299,1.737l-1.729-0.579l-0.959-2.126l-2.109-2.126L191.105,270.143L191.105,270.143z"/>
|
||||
<path d="M200.113,259.908l-1.53,2.706l7.477,2.706l2.68-4.055l1.15,2.707h1.919l3.639-4.055l-4.409-1.158l-1.729-1.348l-2.299,2.316L200.113,259.908L200.113,259.908z"/>
|
||||
<path d="M213.148,265.318l-5.938,2.508v1.928l7.667,2.896l-1.729,1.928l1.15,2.507l4.789-2.126h4.028l1.919,3.086l3.259-3.285l-0.77-3.095l-2.68,0.968l-0.38-3.863l1.34-2.316h-1.34l-2.109,1.349l-0.959,0.769l0.579,2.707l-1.53,1.158l-2.299-0.19l-0.579-3.476L213.148,265.318L213.148,265.318z"/>
|
||||
<path d="M221.005,259.329l-0.579,1.927l3.639,1.738l2.68-1.547l-0.19-1.158L221.005,259.329L221.005,259.329z"/>
|
||||
<path d="M223.875,256.044l-2.679,0.968l0.19,1.35l5.938-0.389l-0.19-1.349L223.875,256.044L223.875,256.044z"/>
|
||||
<path d="M236.72,259.329l-0.38,1.348l-0.959,1.349v1.929l3.639-0.579l3.83,3.285h1.34v-3.285l-3.83-4.253L236.72,259.329L236.72,259.329z"/>
|
||||
<path d="M246.497,263.191l1.53,1.738l-1.34,2.316l0.959,2.507l4.218-2.317v-1.736l-2.49-2.896L246.497,263.191L246.497,263.191z"/>
|
||||
<path d="M252.055,258.75l0.19,3.086h5.178l1.34,1.158l-0.19,1.348l-4.599,0.58l3.259,4.443l4.409,0.77l6.128-2.705l-8.817-13.33l-2.68,1.738l0.19,2.316l-3.069-1.158L252.055,258.75L252.055,258.75z"/>
|
||||
<path d="M207.399,280.576l-7.278,1.928l-4.219,3.673l0.38,4.054l7.667,2.317l-1.729,3.864l-5.558-3.477l-1.53,2.896l3.639,2.507l-0.19,4.054l5.558,1.547l6.708-0.389l1.149-2.126l4.979,5.601l3.449-1.157l0.579-3.864l2.49,1.737l0.38-3.864l-3.068-1.928l0.19-12.162l-2.68-2.126l-2.87,3.864L207.399,280.576L207.399,280.576z"/>
|
||||
<path d="M230.782,289.073l-2.489-1.158l-1.34,1.737l2.68,4.253l0.19,4.054l5.749-3.475v-5.022l2.109-2.126l-2.109-1.547h-3.449L230.782,289.073L230.782,289.073z"/>
|
||||
<path d="M243.048,287.335l-4.028,3.285l0.959,4.054h2.49l1.149-2.126l1.729,1.737l1.729-0.19l4.599-3.864L243.048,287.335L243.048,287.335z"/>
|
||||
<path d="M242.659,280.956l-0.959,1.928l4.218,1.548l1.15-1.738L242.659,280.956L242.659,280.956z"/>
|
||||
<path d="M240.169,273.617l-4.218,0.579l-2.49,2.315l4.599,0.19l-1.34,3.475l0.959,1.548l1.34-0.19l3.259-5.212L240.169,273.617L240.169,273.617z"/>
|
||||
<path d="M247.456,272.27l-2.299,0.77l0.38,3.086l3.83,2.507l0.19,1.927l-1.149,1.159l0.579,3.864l14.755,4.823l4.028,1.349l4.028-3.475l-4.789-3.864l-4.409,1.158l-6.128-0.579l-2.299-2.316l-0.579-6.371l-3.83-1.928L247.456,272.27L247.456,272.27z"/>
|
||||
<path d="M259.523,292.357l-4.218-0.39l-4.979,1.927l-2.68,3.666l0.769,10.043l8.238,0.39l7.857,3.864l5.559,6.371l4.218-0.19l-1.15,5.981l-3.829,6.371l-4.218,1.926l-3.069-0.578l-1.53-1.348l-2.299,3.086l0.959,3.086l3.259,0.189l4.028-1.928l3.449,8.886l8.626,5.603l5.938-7.529l-4.979-8.108l2.878-3.284l4.028,6.758l7.278-6.371l-1.339-2.896l-4.98,1.548l-3.448-9.466l3.259-5.403l-6.518-6.949l-3.639,2.507l-3.449-7.529l-7.277,0.968l-1.919-9.076l-5.938,4.055l-0.579,5.021h-3.259l0.38-4.443L259.523,292.357L259.523,292.357z"/>
|
||||
<path d="M262.021,274.006v1.738l-4.218,0.968l1.149,1.927l4.789,1.928l5.368,0.58l3.831,2.705l3.829-2.127l-2.681-2.705h3.449l2.109-2.316l5.178-0.77v-1.158l-2.878-1.928l0.379-2.127l8.048,1.348l11.886-4.633l-4.41-1.348l1.15-1.547h9.197l1.531-1.547l-18.593-6.569l-4.41-1.547l-4.789,3.476l-5.368-4.443l-2.878-0.19l-0.579,3.675l-3.638-3.285l-4.218,1.349l0.769,2.126l6.328,1.35l-0.38,3.086l3.449,2.125l8.438-2.125l0.19,2.896l-6.898,3.285l-4.218-3.285l-3.83,0.389l3.83,5.411l-1.919,0.969l-2.878-2.508l-2.109,1.348l1.919,3.666h3.259l-0.769,3.475l-2.68-0.389l-3.449-3.674L262.021,274.006L262.021,274.006z"/>
|
||||
<path d="M244.941,327.159l-3.657,4.599l-0.225,5.065l3.198-1.841h3.881l2.74,2.533l2.515-2.076L244.941,327.159L244.941,327.159z"/>
|
||||
<path d="M289.466,386.977l-9.136,8.748l0.916,2.074l11.186,4.141l1.599-2.758l-0.916-4.599l-3.657,0.458l-2.057-2.299l3.423-3.449L289.466,386.977L289.466,386.977z"/>
|
||||
</g>
|
||||
<path id="cd" d="M438.023,546.597l8.912-0.155l1.808,2.567l-0.069,1.893l0.665,0.605h4.426l1.271-2.499h1.808l0.734,0.742l2.479-0.069l0.735,8.714l4.287,0.139v0.675l11.521,5.195l0.536,1.012h2.411l-0.268-3.648l-4.356-2.092l0.269-2.766l1.876-4.392l4.287-0.139l-3.683-12.224l0.068-5.195l5.826-9.11l0.068-1.278l-0.874-0.477l0.035-2.472l-1.062-0.095l-1.072-1.366l-17.59-0.795l-3.226,3.138l-5.28-3.475l-1.858,1.141l-1.348,11.35l-3.337,2.575l-1.003,2.283l0.179,3.381l-6.017,4.918l-1.6-0.726l0.216,0.94L438.023,546.597L438.023,546.597z"/>
|
||||
<path id="cf" d="M443.452,519.229l4.028,4.356l1.59-2.057l2.532,0.104l0.544-2.006l2.49-1.556l5.169,3.562l2.982-2.956l11.574,0.51l-10.736-11.082l1.443-0.897l0.198-1.954l-2.438-1.149h-3.58l-5.766,5.715l-0.197,2.351l-4.573-0.146l-0.146,1.003l-2.982-0.305l-2.688,5.108L443.452,519.229L443.452,519.229z"/>
|
||||
<path id="cg" d="M439.424,526.551l-0.052,1.255l4.131,0.104l0.146,10.728l-3.777-0.104l-2.187-1.703l-1.693,0.951l-0.078,0.476l0.873,0.423l0.251,2.204l-2.334,2.006l0.501,1.057l2.585-2.006h1.244l0.396,1.2l1.644,0.7l5.271-4.46l-0.104-3.259l1.099-2.653l3.38-2.507l0.907-8.479l-2.402,0.009l-2.783,3.812L439.424,526.551L439.424,526.551z"/>
|
||||
<path id="ch" d="M423.787,402.82l-3.771,4.011l0.078,0.405l1.547-0.483l1.393,1.937l2.352-0.83l1.625,1.263l0.667-0.38l2.005-3.146l-0.511-0.484l-1.979-0.051l-0.959-1.963L423.787,402.82L423.787,402.82z"/>
|
||||
<path id="ci" d="M388.484,521.562l3.697-2.617l4.6-0.806l4.693,1.013l-2.395-3.622l-0.701-2.213l0.701-6.544l-4.192,0.198l-1.9-1.814l-3.994,0.104l-1.901,0.304l0.198,4.425l-1.002,0.406l-1.203,2.215l3.095,3.62L388.484,521.562L388.484,521.562z"/>
|
||||
<g id="cl">
|
||||
<path d="M261.391,683.51l-3.691,8.109l6.371,0.674l0.112-5.403L261.391,683.51L261.391,683.51z"/>
|
||||
<path d="M260.137,682.239l-2.775,3.068l-0.337,3.604l-5.368-3.043l-5.705-8.221l-1.677-2.932l2.351-3.043l-0.225-3.83l-2.68-1.124l-2.126-1.572l0.449-2.144l2.792-0.787l0.562-12.387l-4.356-2.48l-2.844-64.477l0.735-1.278l5.567,12.836l1.781,0.035l0.579,2.049l-2.369,2.868l-2.723,15.448l3.873,11.895l-1.79,9.007l6.31,26.485l0.666,15.49l4.521,5.229L260.137,682.239L260.137,682.239z"/>
|
||||
<path d="M241.717,649.833l-1.115,1.686l0.562,2.932l1.115,0.112l0.562-3.718L241.717,649.833L241.717,649.833z"/>
|
||||
</g>
|
||||
<path id="cm" d="M428.031,519.428l2.783,2.561l-0.199,3.959l15.266-0.354l1.245-1.399l-4.375-4.711l-0.648-1.703l2.784-5.212l-1.893-3.458l-1.591-0.855v-1.755l1.841-1.202l0.104-5.463l-1.46-0.164l-0.024,2.87l-6.414,11.972l-3.925,0.199l-2.688,1.85L428.031,519.428L428.031,519.428z"/>
|
||||
<g id="cn">
|
||||
<path class="mainland" d="M594.498,386.128l-2.99,7.521l-4.124-0.217l-4.349,9.518l3.691,4.701l-7.606,10.504l-3.907-0.658l-2.611,3.285l0.648,1.971l3.043,0.217l1.521,3.5l3.044,0.658l9.344,12.04v6.129l4.563,2.844l4.996-0.873l6.303,3.719l7.605,2.187l3.691-0.439l4.132-0.441l8.687-5.688l2.828,0.44l1.08,2.567l2.396,0.718l3.26,4.813l-2.17,4.814l1.306,3.285l3.69,1.312l0.647,3.942l4.35,0.439l0.647-1.971l6.302-3.285l3.907,0.217l4.563,5.03l3.043-1.312l1.954,0.216l0.873,2.412l1.521,0.216l2.169-3.06l8.688-3.285l7.823-9.413l2.61-8.974l-0.217-5.912l-3.259-0.656l1.953-2.188l-0.434-3.501l-8.255-8.314v-4.157l2.386-3.062l2.388-1.098l0.216-2.412h-6.085l-1.089,3.285l-2.828-0.656l-3.475-3.718l2.17-5.688l3.043-3.285l2.827,0.217l-0.434,5.031l1.521,1.313l3.691-3.717l1.306-0.216l-0.433-2.844l3.476-4.158l2.61,0.216l1.521-4.813l1.781-0.942l0.182-3l-1.729-1.815l-0.147-4.736l3.329-0.217l-0.216-12.214l-2.334,1.4L694.267,377l-3.897-0.009l-11.298-6.354l-8.16-9.837l-8.281-0.086l-2.107,1.833l2.68,6.137l-0.935,5.758l-3.335,1.383l-1.876-0.147l-0.139,5.696l1.954,0.441l3.476-1.53l4.563,2.187v2.188l-3.26,0.216l-2.611,5.688l-2.386,0.215l-8.472,11.16l-8.902,3.941l-6.086,0.441l-4.124-2.844l-5.869,3.068l-6.302-1.971l-1.521-4.158l-10.642-0.657l-5.646-9.188h-2.386l-1.92-4.262L594.498,386.128z"/>
|
||||
<path d="M671.802,472.655l-2.064,0.579l-1.487,1.833l1.236,2.411l1.814,0.163l2.066-1.832l0.492-2.411L671.802,472.655L671.802,472.655z"/>
|
||||
</g>
|
||||
<path id="co" d="M234.326,498.251l-1.781-0.182l-11.773,9.707l-1.245,3.414l-1.608,0.182l0.717,7.546l-4.105,10.07l4.46,3.776l5.714,0.363l3.924,5.757l5.705,0.183l-0.182,4.312h2.135l2.316-7.909l-2.144-2.694l0.536-5.031l4.46-0.363l-0.536-11.688l-9.993-3.232l-2.316-6.293L234.326,498.251L234.326,498.251z"/>
|
||||
<path id="cr" d="M202.905,502.745l1.202,2.352l0.977,1.297l-1.314,3.898l-2.507-1.764l-4.097-3.752v-2.48L202.905,502.745L202.905,502.745z"/>
|
||||
<path id="cu" d="M205.904,469.846v1.1l4.599,0.086l2.169-1.263l0.337,0.926l4.512,1.098l4.011,3.622l-0.917,1.262l0.165,1.436l3.345,0.839l3.345-1.514l1.504-1.513l-2.169-1.098l-11.194-6.569l-3.924-0.424L205.904,469.846L205.904,469.846z"/>
|
||||
<g id="cv">
|
||||
<path d="M350.01,490.264l-1.642,0.942l1.175,0.941l1.409-0.708L350.01,490.264L350.01,490.264z"/>
|
||||
<path d="M354.046,492.165l-1.071,0.951l0.762,1.409l1.832-0.821L354.046,492.165L354.046,492.165z"/>
|
||||
<path d="M351.704,494.836l-1.375,0.821l1.479,1.979l1.168-0.612L351.704,494.836L351.704,494.836z"/>
|
||||
</g>
|
||||
<path id="cy" d="M484.555,437.794l1.062,0.771l-3.294,3.119l-1.573-0.052l-1.167-0.821l0.156-1.529l2.386-0.155L484.555,437.794L484.555,437.794z"/>
|
||||
<path id="cz" d="M437.202,398.921h2.567h1.263l2.049,1.461l3.795-3.155l-3.683-2.627l-3.648-1.765l-2.498,0.45l-3.389,2.178L437.202,398.921L437.202,398.921z"/>
|
||||
<path id="de" d="M422.257,384.234l3.086-0.5v-2.178l2.584-0.425l1.418,1.427l1.495,0.164l2.334-1.012l2.083,0.588l1.832,1.592l0.251,5.955l1.832,2.438l-2.411,0.337l-4.003,2.515l0.338,0.839l3.579,3.354l-0.251,1.677l-3.328,1.677l-3.085,0.086l-0.753,1.59h-1.581l-0.752-1.676l-2.749-0.675l-0.088-2.767l-1.39-0.77l0.114-1.861l-0.406-1.328l-1.982-1.824l0.414-2.854l2.161-1.011L422.257,384.234L422.257,384.234z"/>
|
||||
<g id="dk">
|
||||
<path d="M427.123,370.076l-3.586,3.968l-0.13,2.584l1.635,4.263l2.559-0.484l-0.319-3.483l1.764-1.971l-0.034-1.548l-1.245-3.223L427.123,370.076L427.123,370.076z"/>
|
||||
<path d="M428.979,377.354l-1.062,0.229v1.583l1.128,0.875l0.997-0.25l-0.243-1.503L428.979,377.354z"/>
|
||||
<path d="M432.306,375.848l-0.949,0.23l-1.056,0.968l0.448,1.954l1.292,0.507l-1.334,0.535l-0.255,0.685h2.005l0.602-1.099l-0.768-0.378l0.25-0.962l0.916-1.205l-0.25-1.042L432.306,375.848z"/>
|
||||
</g>
|
||||
<path id="dj" d="M508.991,499.771l-0.493,2.903l3.424-0.052l0.052-4.271l-1.253-0.77L508.991,499.771L508.991,499.771z"/>
|
||||
<path id="dm" d="M256.23,485.371l-0.761,1.616l0.917,1.228l1.141-0.994L256.23,485.371L256.23,485.371z"/>
|
||||
<path id="do" d="M242.434,481.533l-4.573-2.991l-2.887-1.021l-0.578,5.521l0.578-0.047l0.761,1.461l0.994-1.15l2.896-0.77l2.516,0.536L242.434,481.533L242.434,481.533z"/>
|
||||
<path id="dz" d="M424.625,435.764l-3.526-1.186l-14.677,2.758l-3.198,2.429l1.953,10.088l-5.835,0.233l-3.509,5.646l-8.359,2.005l0.025,4.105l27.532,21.048l4.692,0.398l15.654-12.231l-1.565-1.971l-2.938-0.398l-1.764-2.955V453.5l-1.177-1.184l0.199-3.155l-3.13-3.155l-0.389-3.354l1.366-0.986l-0.59-3.553L424.625,435.764L424.625,435.764z"/>
|
||||
<g id="ec">
|
||||
<path class="mainland" d="M213.986,529.43l-4.088,2.541l-0.294,3.771l-0.821,1.234l2.576,2.473l-1.115,1.219l0.259,3.113l4.607,1.098l6.976-8.256l-0.017-2.878l-3.345-0.216L213.986,529.43L213.986,529.43z"/>
|
||||
<path d="M183.533,531.443l-0.536,2.378l-0.994,1.002l0.683,1.228l1.754-0.69l0.838-1.461l-0.536-1.537L183.533,531.443L183.533,531.443z"/>
|
||||
</g>
|
||||
<g id="ee">
|
||||
<path class="mainland" d="M462.562,363.299l-4.84-0.173l-3.068,1.875l-0.043,1.392l1.987,1.875l6.182,1.047L462.562,363.299L462.562,363.299z"/>
|
||||
<path d="M452.236,364.042l-1.308,0.44l1.308,0.226l0.595,0.69l0.711-0.852l-0.709-1.215L452.236,364.042z"/>
|
||||
<path d="M452.792,365.792l-1.862,0.833l-0.643,1.111l0.643,0.722l2.362-0.875l1.137-0.752L452.792,365.792z"/>
|
||||
</g>
|
||||
<path id="eg" d="M466.16,449.222l2.308,0.062l4.495,1.244l2.135,0.061l2.646-2.213h1.234l2.249,1.245h2.845l0.51-0.035l1.798,5.17l0.51,1.668l0.477,2.498l-0.85,0.622l-1.461-0.734l-1.686-5.498l-1.521-0.111l-0.111,1.867l1.012,3.232l8.1,10.027l0.173,4.305l-2.359,2.723l-22.163-0.251L466.16,449.222L466.16,449.222z"/>
|
||||
<path id="er" d="M496.224,493.859l-0.216-5.093l3.423-3.992l0.926,0.709l1.686,5.637l8.091,6.023l-1.47,1.808l-5.922-5.091L496.224,493.859L496.224,493.859z"/>
|
||||
<g id="es">
|
||||
<path class="mainland" d="M402.565,416.322h-11.014l-2.222-1.004l-1.071,0.078l-1.297,2.696l0.458,2.775l4.21,0.389l0.536,1.771l-1.833,10.33l0.078,1.851l2.981,1.617l3.439,0.232l6.881-1.694l3.363-4.233l0.077-4.313l5.965-5.395l0.302-2.386l-5.428-0.078L402.565,416.322L402.565,416.322z"/>
|
||||
<path d="M374.265,458.444l-1.513,0.873l0.7,0.709L374.265,458.444L374.265,458.444z"/>
|
||||
<path d="M369.009,458.608l-1.875,0.476l0.935,1.418h1.407L369.009,458.608L369.009,458.608z"/>
|
||||
<path d="M364.549,457.191l-1.176,1.184l1.643,1.418l0.935-2.126L364.549,457.191L364.549,457.191z"/>
|
||||
<path d="M413.578,426.877l-1.375,0.467l0.304,1.235h1.988l0.839-0.925L413.578,426.877L413.578,426.877z"/>
|
||||
</g>
|
||||
<path id="et" d="M489.982,508.606l6.292-14.005l6.25,0.035l5.541,4.814l-0.39,3.968h4.296l0.44,2.386l6.95,4.157l4.286,0.217l-8.15,8.756l-11.194,3.449h-2.773l-4.944-4.219l-1.953-0.82l-3.786-5.576l-2.499,0.035l-0.294-2.559L489.982,508.606L489.982,508.606z"/>
|
||||
<path id="fi" d="M453.072,340.202l1.79,0.786l1.104,2.074l-1.104,1.436l-5.55,6.068l-0.952,3.199l1.271,4.633l4.278,3.199l5.706-2.715l4.598-0.64l4.279-6.872l-3.173-7.512l-3.018-7.192l0.477-4.633l-1.901-0.32l-0.492-3.38l-2.559-4.175l-2.836,1.962l-1.114,4.556l-3.008-1.807l-4.185-1.021l-0.934,1.09l1.607,1.453l2.931-0.053l2.359,3.812L453.072,340.202L453.072,340.202z"/>
|
||||
<g id="fk">
|
||||
<path d="M281.194,678.393l-2.273-0.25l-2.265,1.521l1.642,1.781L281.194,678.393L281.194,678.393z"/>
|
||||
<path d="M283.459,677.252l-0.752,2.411l-2.144,1.901l0.13,0.631l3.655-1.4l1.513-1.901L283.459,677.252L283.459,677.252z"/>
|
||||
</g>
|
||||
<g id="fr">
|
||||
<path class="mainland" d="M412.973,393.588l-1.91,0.467l-3.82,4.158l-1.149,0.078l-1.53-1.081l-0.993,0.233l-0.762,2.386l-5.584,0.155l0.156,1.236l3.82,2.543l4.435,3.543l-0.077,4.236l-2.368,4.157l5.126,2.464l5.204,0.154l1.606-1.85l3.286,0.078l0.916,0.848l3.285-0.233l1.686-2.162l-2.145-2.541l-0.155-1.616l0.458-1.771l-1.071-1.539l-1.833,0.535l-0.232-1.383l4.054-4.469v-2.697l-2.348-0.767l-1.432-0.987L412.973,393.588L412.973,393.588z"/>
|
||||
<path d="M276.163,517.285l5.058,3.154l-2.646,5.255l-0.959,1.21l-2.809-1.615l0.079-5.663L276.163,517.285L276.163,517.285z"/>
|
||||
<path d="M540.023,586.93l-1.972,0.129l-0.129,1.722l1.313,0.269l1.972-0.925L540.023,586.93L540.023,586.93z"/>
|
||||
<path d="M516.857,562.666l0.656,1.461h1.055l0.526-1.857L516.857,562.666L516.857,562.666z"/>
|
||||
<path d="M258.823,489.822l-0.917,0.847l0.683,1.383l1.296-0.38L258.823,489.822L258.823,489.822z"/>
|
||||
<path d="M428.039,418.016l-1.687,1.695l-0.154,1.538l1.374,0.847l0.536-0.076l0.303-2.24L428.039,418.016L428.039,418.016z"/>
|
||||
<path d="M254.095,484.065l-1.296,0.535l0.458,1.149l1.521-0.994l-0.303-0.311L254.095,484.065L254.095,484.065z"/>
|
||||
</g>
|
||||
<path id="ga" d="M435.438,526.646l-0.104,2.151l-4.875-0.104l-2.982,5.766l7.012,7.667l1.736-1.453l-0.052-1.503l-1.192-0.554v-1.057l2.688-1.701l2.387,1.807l2.638,0.052l-0.054-9.067l-4.176-0.196l-0.052-1.903L435.438,526.646L435.438,526.646z"/>
|
||||
<g id="gb">
|
||||
<path class="mainland" d="M400.629,367.984l-1.582,2.395l0.631,0.959h3.648v1.6l-0.952,1.278l0.632,3.354l2.058,3.994l1.581,3.672l2.533,0.959l1.105,1.92l-0.155,1.755l-1.582,0.959l-0.156,0.795l1.106,0.64l-0.951,1.279l-2.221,0.959l-4.279-0.477l-6.664,3.035l-2.221-1.115l6.345-3.674l-0.796-0.477l-3.328-0.32l2.058-3.033l0.319-2.56l2.696-0.319l-0.475-4.953l-3.174-0.155l-0.95-1.115l0.155-3.674l-1.901,0.156l1.901-6.388l3.492-2.56L400.629,367.984L400.629,367.984z"/>
|
||||
<path d="M393.974,378.693l-2.853,0.32l-0.156,2.56l1.901,1.278l2.058-0.475l0.796-1.436L393.974,378.693L393.974,378.693z"/>
|
||||
</g>
|
||||
<path id="ge" d="M495.144,415.596l2.827,3.691l3.527,1.625l2.169-0.01l3.726-1.01l0.935-1.461l-11.021-4.123L495.144,415.596L495.144,415.596z"/>
|
||||
<path id="gh" d="M399.091,513.179l0.968,2.273l2.524,3.959l1.4-0.052l3.819-2.171l-0.268-12.354l-2.957-0.864l-4.141,0.112L399.091,513.179L399.091,513.179z"/>
|
||||
<g id="gl">
|
||||
<path class="mainland" d="M292.587,282.398l-1.176,1.877l2.118,2.117l-0.942,2.118l3.06,3.994l3.761-1.176l4.936-0.466l5.644,6.111l3.761,10.104l-3.051,6.345l4.227-0.708l2.354,1.409l0.232,3.06l-5.168,0.233l2.818,2.817l3.526,0.709l-7.754,10.339l-0.942,6.345l1.643,5.169l-1.176,3.06l2.118,6.578l3.993,4.469l1.176-0.232l2.584-0.709l0.233,3.76l1.642,2.352l3.052-0.234l2.353-8.695l7.053-8.697l10.581-4.227l6.578-8.229l3.05,1.408h6.345l5.17-5.168l6.346-2.584l0.708-3.994l-3.993-3.527l-3.526-1.175l-1.885-4.937l4.469-2.584l7.054,3.76l2.353-2.584l-3.761-2.117l7.996-10.813l-1.409-4.702l-3.761-0.232l1.408-4.228l4.703-2.118l9.637-8.461l-2.816-3.053l-10.814,0.941l-5.645,5.646l3.295-7.287l-3.762-0.942l-2.117,3.761l-3.051-2.585l-8.464,0.941l2.353-3.76l13.865-0.467l-3.527-4.702l-15.04-2.817l-6.11,0.941l0.233,3.061l-6.345-2.118l0.232-2.118l-4.47,0.941l-0.942,2.353l4.703,1.642l-4.936,3.527l-3.527-3.994l-4.936-1.408l-0.709,3.76h-4.937l-1.885-3.994l-7.754-1.176l-4.228,2.117l-0.233,2.818l-5.402-0.709l-3.294,1.409l0.234,3.293v1.644l-6.111,1.176l-2.818-1.877l-1.885,3.052l2.819,3.06l5.878-0.709l0.467,1.885l-4.469,2.118L292.587,282.398L292.587,282.398z"/>
|
||||
<path d="M311.396,319.066l1.409,2.119l-0.708,2.584h-1.409l-1.885-2.119l0.467-1.643L311.396,319.066L311.396,319.066z"/>
|
||||
<path d="M370.159,313.189l3.993,1.176l-0.234,3.293l-4.227-2.118l-0.942-1.176L370.159,313.189L370.159,313.189z"/>
|
||||
</g>
|
||||
<path id="gm" d="M366.719,497.006l-0.111,0.959l5.98-0.086l0.304-0.891l-0.13-0.898l-1.721,0.7L366.719,497.006L366.719,497.006z"/>
|
||||
<path id="gn" d="M369.77,505.304l2.629,4.046l3.423-2.974l3.511-0.155l2.922,3.881l2.48,1.635l0.933-1.816l0.83-0.466l-0.061-3.993l-1.65-4.737l-5.065,0.562l-6.267-0.501l-0.034,1.606L369.77,505.304L369.77,505.304z"/>
|
||||
<g id="gq">
|
||||
<path d="M427.184,522.134l-0.396,1.703l1.191,0.648l1.143-0.855l-0.397-1.755L427.184,522.134L427.184,522.134z"/>
|
||||
<path d="M430.771,526.697l-0.054,1.202l3.924,0.198l-0.052-1.356L430.771,526.697L430.771,526.697z"/>
|
||||
</g>
|
||||
<g id="gr">
|
||||
<path class="mainland" d="M453.004,427.213l-0.096,1.15l4.003,2.014l1.911,0.734l-1.003,1.055l-2.23,0.227l-0.319,1.01l0.771,1.738l2.498,1.33l1.09,0.096l0.138-2.981l1.635-1.972l-4.46-5.272l0.589-1.789l1.046-0.043l1.591,1.279l1.002-0.501l0.319-1.79l3.732,0.534l1.134-3.239l-1.953,1.375l-5.731-0.14l-3.726,1.929L453.004,427.213L453.004,427.213z"/>
|
||||
<path d="M461.69,438.442l1.408,0.043l0.589,0.873h2.05l1.363-0.501l0.459,0.553l-0.907,1.192l-4.002,0.139l-0.728-0.959l-0.77-0.458L461.69,438.442L461.69,438.442z"/>
|
||||
</g>
|
||||
<path id="gt" d="M183.456,491.11l5.126,3.752l5.169-6.423l-0.881-1.331l-1.764-0.062v-3.761l-1.322-0.804l-4.002,1.192l1.53,3.526L183.456,491.11L183.456,491.11z"/>
|
||||
<path id="gw" d="M368.196,502.356l1.211,2.395l3.396-2.922l0.035-0.899l-4.003-0.579L368.196,502.356L368.196,502.356z"/>
|
||||
<path id="gy" d="M261.399,510.654l6.241,5.652l-2.481,2.87l-0.199,1.703l3.259,3.362l-0.078,3.232l-5.67,2.16l-3.397-4.59l0.726-5.515l-1.452-4.105L261.399,510.654L261.399,510.654z"/>
|
||||
<path id="hn" d="M194.408,488.742l7.987-0.303l2.369,2.817l-1.478-0.338l-2.844,0.121l-3.717,3.492l-1.59,3.536l-1.046-0.555l-0.009-3.872l-2.299-1.539L194.408,488.742L194.408,488.742z"/>
|
||||
<path id="hr" d="M443.417,407.816l-3.051,2.515h-3.095l-0.372,2.178l1.418,0.372l0.709-1.055l1.114,0.977l0.891,3.112l6.109,2.853l0.605-0.691l-6.197-6.396l0.631-1.166l5.888-0.226l0.596-1.876l-3.838,0.111L443.417,407.816L443.417,407.816z"/>
|
||||
<path id="ht" d="M231.845,477.159l2.974,0.311l-0.354,3.648l-0.294,1.919l-3.466-0.19l-0.614,0.926l-1.063-0.077l-0.38-1.997l3.656-0.304l-0.225-2.073l-1.677-0.691L231.845,477.159L231.845,477.159z"/>
|
||||
<path id="hu" d="M444.386,403.01l-1.003,1.573l0.078,2.403l1.599,0.82l4.919,0.147l6.854-5.774l0.034-1.279l-0.742-0.371l-4.953,2.248L444.386,403.01L444.386,403.01z"/>
|
||||
<g id="id">
|
||||
<path d="M639.517,513.628l-0.241,1.971l5.869,9.862h1.711l12.23,20.461l4.894,0.492l2.445-7.148l-3.916-2.464l-0.734-3.941L639.517,513.628L639.517,513.628z"/>
|
||||
<path d="M697.475,540.891l1.954,2.396l-1.271,3.596v0.684h2.887l1.021-8.989l0.934,0.26l1.694,8.212l1.615,0.434l1.53-3.512l-1.53-5.308l-1.271-2.31l3.993-2.911l-0.935-1.288l-3.819,2.48h-1.021l-1.866-2.74l0.597-1.201l3.146-1.539l4.754,1.453l1.444-0.089l3.568-3.335l-1.442-1.451l-3.312,2.565h-2.127l-3.224-1.538l-2.29,0.086l-2.55,4.106l-1.616,7.105L697.475,540.891L697.475,540.891z"/>
|
||||
<path d="M718.791,524.805l-1.616,3.935l2.549,3.337h0.849l1.105-2.223l0.597-0.77l-1.105-1.201l-1.617-0.597L718.791,524.805L718.791,524.805z"/>
|
||||
<path d="M723.805,537.729l-3.482,0.77l-1.021,1.115l0.847,1.453l2.291-0.855l1.442-0.855l2.126,1.712l0.935-0.771l-1.693-2.057L723.805,537.729L723.805,537.729z"/>
|
||||
<path d="M666.045,548.854l-2.377,1.625l0.51,1.364l7.564,1.712l3.82,0.684l1.615,1.712l4.331,0.345l2.04,1.711l1.867-0.432l1.702-1.539l-3.146-1.452l-2.715-2.308l-7.053-1.713L666.045,548.854L666.045,548.854z"/>
|
||||
<path d="M690.768,556.295l-1.865,1.029l1.104,1.201l2.715-1.027L690.768,556.295L690.768,556.295z"/>
|
||||
<path d="M693.991,555.526l0.338,1.625l1.954,0.51l0.761-0.942l-0.848-1.288L693.991,555.526L693.991,555.526z"/>
|
||||
<path d="M698.668,559.805l-2.377,0.347l2.126,1.798h1.694L698.668,559.805L698.668,559.805z"/>
|
||||
<path d="M699.342,556.979l-0.511,1.027l3.821,0.596l2.974-1.711l-1.694-0.511l-2.715,0.771l-1.021-0.856L699.342,556.979L699.342,556.979z"/>
|
||||
<path d="M711.833,557.583l-4.416,3.683l0.423,0.942l1.866-0.345l2.205-2.059l4.331-0.597l-0.848-1.452L711.833,557.583L711.833,557.583z"/>
|
||||
<path d="M734.126,532.446l-3.604,0.406l-2.315,1.693l0.959,1.938l3.925,0.726v0.727l-2.48,2.015l1.201,4.192l1.201,0.078l1.037-4.115h1.919l0.806,4.027l9.361,7.746l0.241,6.051l3.198,3.467l1.442-0.077l0.319-21.369l-5.438-3.785l-5.125,3.467l-1.843,1.132l-3.043-1.937l-0.078-6.128L734.126,532.446L734.126,532.446z"/>
|
||||
<path d="M690.689,519.532l-1.997,7.503l-10.831,3.656l-3.241-3.804l-1.573,0.433l2.939,11.34l4.398,0.493l5.869,2.222v2.222l2.688-0.493l3.916-5.42v-4.435l2.204-4.435l2.445,0.491l-2.938-6.163l-0.449-3.968L690.689,519.532L690.689,519.532z"/>
|
||||
</g>
|
||||
<path id="ie" d="M394.915,383.085l-0.786,5.187l-6.975,2.56h-2.223l-1.582-1.115v-0.96l3.492-2.238l-0.951-1.92l0.156-2.714l3.018,0.155l1.383-3.25l-0.183,2.887l2.344,1.858L394.915,383.085L394.915,383.085z"/>
|
||||
<path id="il" d="M486.378,444.899l-1.365,4.348l1.771,5.213l2.031-7.616v-1.633L486.378,444.899L486.378,444.899z"/>
|
||||
<path id="in" d="M595,509.688l3.958-1.938l2.352-8.505l-0.104-10.441l13.468-14.54v-3.447l2.774-1.081l-0.104-3.984l-2.991-5.817l1.712-3.121l3.742,3.449l4.808,0.216v1.938l-1.495,1.616l0.318,0.863l2.567,0.104l0.536,2.904h0.752l1.928-3.449l0.958-9.042l3.207-2.265l0.104-3.12l-1.279-2.481l-2.031-0.104l-7.951,5.256l0.5,3.38l-5.584-0.019l-1.97-2.41l-1.072,0.138l0.363,3.354l-12.075-0.863l-7.485-3.337l-0.397-4.106l-4.988-3.094l-0.06-6.371l-3.423-3.916l-7.867,0.752l0.855,3.424l3.854,3.12l-6.665,13.641l-4.46,0.337l-0.734,1.644l4.392,4.062l-0.216,4.105l-4.486-0.069l-0.484,2.04l3.727-0.164l0.104,1.616l-2.671,1.4l1.711,3.232l3.312,1.08l2.031-1.504l0.959-2.688l1.177-0.535l1.392,1.398l-0.424,3.449l-0.96,1.617l0.217,2.8L595,509.688L595,509.688z"/>
|
||||
<path id="iq" d="M502.793,433.637l-1.348,6.664l-5.585,4.65l0.354,2.195l5.455,0.371l8.688,7.07l4.857-0.138l0.13-1.634l1.78-1.91l2.49,1.409l0.329-0.312l-4.815-6.405l-2.282-0.139l-3.033-3.898l0.604-2.868l0.926-0.121l0.319-1.271l-4.132-4.348L502.793,433.637L502.793,433.637z"/>
|
||||
<path id="ir" d="M507.409,427.516l-1.057,1.098l0.104,1.738l1.314,1.842l4.658,5.101l-0.709,2.04h-0.812l-0.406,2.04l2.637,3.371l2.43,0.207l4.865,6.732l2.732,0.208l2.126,1.529l0.104,3.062l8.411,4.9h3.139l1.927-1.634l2.43-0.104l1.418,3.268l9.085,1.262l0.27-3.337l3.007-1.089l0.139-1.193l-2.395-3.268l-5.334-4.287l2.801-2.55l-0.198-1.124l-3.511-0.544l-1.486-11.843l-0.173-2.723l-9.518-3.641l-4.218,0.951l-2.36,2.896l-2.092-0.139l-0.604,0.511l-4.66-0.303l-5.878-4.288l-2.188-2.394l-1.003,0.24l-1.808,2.066L507.409,427.516L507.409,427.516z"/>
|
||||
<path id="is" d="M366.261,340.521l-1.693-0.959l-2.283,1.443l-1.962,1.814l0.052,1.013l2.541,0.319l-0.156,1.815l-0.898,0.908l0.217,0.588l2.541,0.164v2.938l3.656,0.641l2.17,1.229l2.437,0.104l4.186-2.083l3.231-4.271l0.053-2.887l-1.963-1.66l-1.642-1.392l-0.743,0.536l-1.115,1.443l-1.271-0.164l-1.271-1.393l-1.642,0.156l-2.386,1.979l-1.437,1.547l-0.795-0.69l-0.053-1.712l0.795-0.536L366.261,340.521L366.261,340.521z"/>
|
||||
<g id="it">
|
||||
<path class="mainland" d="M423.233,409.391l-0.535,1.356l0.146,1.478l2.065,2.412l3.25-0.113l7.175,8.334l4.479,1.297l2.646,2.498l0.631,5.695l1.417-0.828l1.229-3.104l-0.303-2.229l2.101-0.19l0.304-1.263l-5.922-2.834l-5.619-5.523l-2.238-3.303l-0.545-3.137l2.861-0.684l-0.734-2.066l-1.755-1.478l-1.513-0.069l-2.108,0.58l-1.99,2.781l-1.201,0.795l-1.858-1.141L423.233,409.391L423.233,409.391z"/>
|
||||
<path d="M440.668,431.898l-1.253-0.674l-4.278,0.674l0.146,1.158l3.847,1.937l0.579,0.631l1.012,0.147L440.668,431.898L440.668,431.898z"/>
|
||||
<path d="M427.806,423.566l-2.289,1.158l0.303,4.469l1.833,0.311l1.374-1.312v-4.235L427.806,423.566L427.806,423.566z"/>
|
||||
</g>
|
||||
<path id="jm" d="M221.533,480.798l-3.008,0.761v0.84l1.755,1.011h1.841l1.167-1.349L221.533,480.798L221.533,480.798z"/>
|
||||
<path id="jo" d="M489.473,447.251l-2.126,7.416l-0.096,1.133h3.346l3.743-3.303l0.094-1.253l-1.529-1.564l2.739-2.272l-0.396-2.109l-0.752,0.173l-2.282,1.635L489.473,447.251L489.473,447.251z"/>
|
||||
<g id="jp">
|
||||
<path d="M709.317,426.193l-1.41,1.418l0.579,1.996l1.236,0.086l0.83,4.332l0.993,1.08l1.738-1.582l0.151-4.773l-2-2.125L709.317,426.193L709.317,426.193z"/>
|
||||
<path d="M716.688,422.188l-2.659,2.156l-0.591,2.719l1.812,1.25l2.625-2.75l0.37-3.062L716.688,422.188z"/>
|
||||
<path d="M713.613,418.033l-4.219,4.832v2.322l2.604-0.312l4.085-3.592l2.731-0.502l0.663,0.779l0.015,2.377l0.688,1.25h1.255l1.763-2.158l0.743-2.836l3.552-0.086l3.476-4.166l-1.814-6.916l-0.83-3.664l1.815-1.496l-4.133-6.241l-0.944-0.744l-1.875,0.744l-0.481,2.584v2.083l0.994,1.167l0.328,5.498l-2.56,3.164l-1.485-0.917l-1.159,2.584l-0.251,2.412l0.909,1.418l-0.579,1.08l-1.902-1.582h-1.322l-1.157,0.666L713.613,418.033L713.613,418.033z"/>
|
||||
<path d="M720.729,380.396l-1.321,1.168l0.665,2.498l1.158,1.166l-0.086,3.83l-1.487,0.578l-1.158,2.584l3.388,4.659l2.23-0.752l0.415-1.167l-2.396-2.161l1.487-1.919l1.572,0.25l3.43,2.305l0.37-2.584l1.63-2.979l2.281-2.312l-2.469-1.125l-0.944-1.801l-1.236,0.83l-1.071,1.331l-2.316-0.501l-2.396-1.582L720.729,380.396L720.729,380.396z"/>
|
||||
<path d="M733.201,377.812l-2.317,3.25l0.164,1.582l1.158-0.502l2.723-3.414L733.201,377.812L733.201,377.812z"/>
|
||||
<path d="M736.261,373.066l-0.829,2.248l0.086,1.496l1.409-0.918l1.322-2.662v-0.994L736.261,373.066L736.261,373.066z"/>
|
||||
</g>
|
||||
<path id="ke" d="M491.142,521.365l2.3,4.484l-2.759,5.783l-0.361,1.754l13.77,8.516l4.271-6.708l-2.161-1.754l-0.043-8.835l2.705-2.956l-4.313,1.435l-3.259,0.044l-5.1-4.305l-1.608-0.692l-2.98,0.276l-0.526,0.883L491.142,521.365L491.142,521.365z"/>
|
||||
<path id="kg" d="M565.463,411.316l-0.268,2.188l0.216,1.35l7.521,2.523l-6.604,2.662l-0.751-0.623l-1.427,0.917l0.068,0.501l0.761,0.346l4.635,0.121l2.351-0.709l3.018-3.803l3.776,0.656l4.557-6.311l-12.188-1.66l-1.686,4.088l-2.127-2.281L565.463,411.316L565.463,411.316z"/>
|
||||
<path id="kh" d="M655.076,497.982l3.535,3.776l6.576-4.875l0.579-7.692l-3.396,2.343l-1.764-0.985l-2.396-0.32l-1.341-0.941l-0.648,0.035l-1.754,2.878l0.285,1.332l1.781,0.994l-0.216,2.705L655.076,497.982L655.076,497.982z"/>
|
||||
<path id="km" d="M514.359,560.013l0.396,1.321l1.711,0.269l0.656-1.72L514.359,560.013L514.359,560.013z"/>
|
||||
<path id="kp" d="M687.751,407.047l1.59,0.666l0.484,5.566l3.155,0.183l2.974-3.483l-1.029-0.916l0.121-3.734l2.731-3.303l-1.392-2.506l0.908-1.039l0.501-2.592l-1.582-0.719l-1.35,0.684l-1.668,5.064l-2.697-0.232l-3.12,3.682L687.751,407.047L687.751,407.047z"/>
|
||||
<path id="kr" d="M696.446,410.443l5.342,4.356l0.909,4.22l-0.183,2.264l-2.61,2.939l-2.248,0.12l-2.551-5.506l-0.968-2.629l1.028-0.795l-0.242-1.099l-1.271-0.569L696.446,410.443L696.446,410.443z"/>
|
||||
<path id="kw" d="M519.2,452.774l-1.945-1.056l-1.349,1.356l0.146,2.715l3.139,1.201L519.2,452.774L519.2,452.774z"/>
|
||||
<path id="kz" d="M513.495,402.163l3.544-1.513l3.959-0.139l0.276,6.051h-2.317l-1.772,2.888l2.317,3.847l3.414,1.928l0.311,2.205l1.255-0.416l1.157-1.375l1.91,0.416l0.96,1.928h2.455v-2.473l-1.504-4.4l-0.684-3.569l4.364-1.929l5.869,0.959l3.684,3.709l8.323-0.821l4.644,6.597l5.455,0.274l1.504-2.472l1.91-0.416l0.274-2.748l2.862-0.139l1.503,1.789l1.505-3.57l12.957,1.789l2.179-2.887l-3.683-4.537l4.91-10.719l3.958,0.275l2.731-6.594l-5.455-0.554l-3.138-3.024l-8.644,1.002l-11.134-10.762l-3.926,3.482l-11.902-5.402l-14.601,7.148l-0.405,5.083l3.413,3.985l-6.655,3.76l-8.636-0.19l-1.807-2.653l-6.769-0.373l-6.414,4.123l-0.14,5.638L513.495,402.163L513.495,402.163z"/>
|
||||
<path id="la" d="M650.745,466.397l-2.092,1.062l-1.737,5.065l2.904,3.699l-0.485,4.09l0.485,0.196l4.832-2.342l6.482,7.243l-0.157,4.563l1.41,0.762l3.482-2.827l-0.285-2.238l-10.053-9.552l0.095-1.461l1.254-0.873l-0.874-2.438l-4.158-0.684L650.745,466.397L650.745,466.397z"/>
|
||||
<path id="lb" d="M487.139,440.041l0.053,1.686l-0.708,2.56l2.438,0.208l0.156-3.631L487.139,440.041L487.139,440.041z"/>
|
||||
<path id="lc" d="M258.746,493.28l-0.614,1.306l0.994,1.071l1.296-0.691L258.746,493.28L258.746,493.28z"/>
|
||||
<path id="lk" d="M603.264,505.399l0.217,2.351l0.216,1.712l-1.271,0.216l0.64,3.848l1.909,1.07l2.966-1.711l-0.847-4.054l0.216-1.495l-2.756-2.559L603.264,505.399L603.264,505.399z"/>
|
||||
<path id="lr" d="M378.198,515.027l9.491,6.345l-0.227-4.805l-2.869-3.38l-2.801-2.481L378.198,515.027L378.198,515.027z"/>
|
||||
<path id="ls" d="M470.896,606.829l2.637-2.032l1.245,0.053l1.503,1.875l-0.155,1.877l-2.533,0.934v0.728l-2.792-0.156l-0.674-2.031L470.896,606.829L470.896,606.829z"/>
|
||||
<path id="lt" d="M452.14,375.236l-2.146,0.363l0.173,2.025l3.355,0.25l1.27,1.042l0.333,1.81l1.034,1.443l3.069-0.13l2.938-3.743l-0.172-2.222l-5.533-0.867L452.14,375.236z"/>
|
||||
<path id="lu" d="M420.424,397.582l0.76,0.679l0.88,0.083l0.194-1.734l-0.253-0.974l-1.224,0.583L420.424,397.582z"/>
|
||||
<path id="lv" d="M462.823,369.964l-6.362-1.037l-1.086,2.823l-1.833,0.548l-0.959-1.173l-0.962-1.811l-1.038,0.762l-0.589,3.132v1.708l2.242-0.375l4.665,0.083l5.617,1.044l2.249-0.657l-0.13-2.524L462.823,369.964L462.823,369.964z"/>
|
||||
<path id="ly" d="M429.958,453.518l1.35-0.225l0.397-3.112h0.674l2.758-4.528l6.804,1.979l1.856,2.887l6.69,3.062l3.482-1.47l-0.338-1.471l-1.521-1.469l0.173-1.021l2.473-2.093h4.894l1.856,2.489l3.934,0.57l0.511,31.889l-2.922-0.112l-17.651-9.181l-1.91,1.081l-7.253-1.814l-1.971-2.603l-2.87-0.397l-1.458-2.603L429.958,453.518L429.958,453.518z"/>
|
||||
<path id="ma" d="M402.505,439.903h-9.982l-1.954,4.339l-4.504,2.17l-3.719,10.062l-7.243,4.341l-10.174,16.761l9.983-0.199l0.389-4.927h2.541v-6.708h8.81l0.196-8.679l8.42-1.972l3.526-5.723l5.48-0.198L402.505,439.903L402.505,439.903z"/>
|
||||
<path id="md" d="M465.14,401.376l2.681,4.123l-0.226,2.334l0.96,0.043l2.272-3.847l-2.731-3.389L466.549,400L465.14,401.376L465.14,401.376z"/>
|
||||
<path id="me" d="M449.68,416.677l-1.266,1.789l0.362,1.099l1.504,0.275l1.184-1.607L449.68,416.677z"/>
|
||||
<path id="mg" d="M526.988,561.474l-1.842,4.374l-3.154,5.566l-5.523,0.396l-2.369,2.783l0.397,8.488l-3.423,3.977l0.396,6.761l2.897,3.311l3.423-0.396l3.423-2.524l-0.787-3.977l7.894-13.657l-1.582-1.72l1.582-3.312l1.711,0.526l0.526-1.322l-1.582-6.76l-0.924-2.784L526.988,561.474L526.988,561.474z"/>
|
||||
<path id="mk" d="M456.643,418.924l-2.912,0.959l0.139,2.473l0.683,0.873l3.458-1.606L456.643,418.924L456.643,418.924z"/>
|
||||
<path id="ml" d="M377.584,494.845l2.662-1.823l14.799-0.087l-3.423-23.806l3.907-0.112l18.903,14.428l2.541,0.362l-0.959,8.021l-11.886,1.08l-9.171,6.847l-1.669,4.686l-6.37,0.269l-1.625-4.677l-4.884,0.346l0.189-1.53L377.584,494.845L377.584,494.845z"/>
|
||||
<path id="mm" d="M645.533,501.596l-2.396-3.838l1.737-2.438l-1.642-3.018l-1.548-0.294l-0.294-5.064l-2.316-4.486l-0.675,1.071l-1.547,2.628l-1.937,0.294l-0.968-1.271l-0.484-3.414l-1.453-2.73l-5.913-5.576l1.453-0.959l0.27-4.037l2.16-3.631l0.935-9.032l3.129-2.136l0.103-3.293l1.877,0.622l2.956,4.279l-2.194,4.701l1.479,3.691l3.655,1.435l0.666,4.021l4.91,0.761l-1.357,2.343l-6.188,2.438l-0.674,3.993l4.547,5.844l0.189,3.12l-1.062,1.072l0.094,0.977l3.389,4.971l0.096,5.16L645.533,501.596L645.533,501.596z"/>
|
||||
<path id="mn" d="M597.438,386.215l5.03-6.673l6.043,2.792l4.105,1.098l5.03-4.615l-3.414-2.517l2.248-3.172l6.707,2.369l2.325,3.812l4.199,0.112l2.196-1.634l4.521-0.182l0.985,1.678l7.512,0.38l4.754-4.849l6.578,0.69l-0.38,6.604l2.879,0.656l3.535-1.606l3.743,1.85l-0.088,0.935l-2.714,0.078l-2.827,5.93l-2.195,0.216l-8.54,11.16l-8.723,3.847l-5.455,0.424l-4.529-2.923l-5.791,3.095l-5.705-1.771l-1.617-4.141l-10.805-0.762l-5.532-9.377l-2.688-0.174L597.438,386.215L597.438,386.215z"/>
|
||||
<path id="mr" d="M364.998,478.266l1.885,2.463l-0.389,10.65l2.74-1.972l1.952-0.397l2.741,0.985l3.128,4.34l2.939-1.971l14.288-0.199l-3.526-23.866l3.786-0.018l-7.054-5.402l0.009,3.51l-8.93,0.01l-0.043,6.698l-2.567-0.009l-0.328,4.944L364.998,478.266L364.998,478.266z"/>
|
||||
<path id="mt" d="M440.815,438.339l-1.443,0.294l0.052,1.6l1.297,0.433l0.579-0.484L440.815,438.339L440.815,438.339z"/>
|
||||
<path id="mu" d="M544.89,584.008l-1.312,1.721l0.259,1.857l2.768-2.256L544.89,584.008L544.89,584.008z"/>
|
||||
<g id="mv">
|
||||
<path d="M582.396,516.386l0.26,2.256l1.442,0.527l0.26-1.989L582.396,516.386L582.396,516.386z"/>
|
||||
<path d="M584.238,521.156l-0.13,2.784l1.055,0.525l0.925-1.856L584.238,521.156L584.238,521.156z"/>
|
||||
<path d="M584.506,526.595l-0.925,0.925l1.056,0.925l1.313-0.925L584.506,526.595L584.506,526.595z"/>
|
||||
</g>
|
||||
<path id="mw" d="M487.968,567.074l2.689,2.81l-0.053,3.597l0.52,1.514l3.57-3.855l-0.414-4.899l-1.912-1.46l-1.701-8.603l-2.949-0.104l1.34,6.196L487.968,567.074L487.968,567.074z"/>
|
||||
<path id="mx" d="M133.847,433.982l4.175,13.146l-1.945,1.089l0.216,2.61l3.674,2.827v5.229l4.538,4.356l-1.945-12.847l-2.593-8.497l0.648-5.877l2.161,0.217l0.865,1.962l-0.865,5.005l11.237,21.99v7.841l9.077,10.666l9.94,4.573l4.106-2.396l5.835,4.789l3.458-3.483l-1.513-3.925l4.97-1.521l1.513,0.873l1.513-1.521h2.377l4.322-7.624l-2.161-1.962l-8.428,1.962l-1.945,5.662L182.106,480l-5.834-2.396l-2.593-8.271l1.962-10.434l-4.011-2.498l-1.91-10.02l-1.599-0.683l-2.922,2.965l-3.354-1.789l-1.313-6.682l-13.286-1.393l-6.863-5.16L133.847,433.982L133.847,433.982z"/>
|
||||
<g id="my">
|
||||
<path class="mainland" d="M648.359,511.796l1.736,3.898l0.391,5.064l2.324,3.604l5.096,3.083l1-0.791l1.464-0.288l-0.212-1.91l-1.841-4.478l-2.697-5.731l-0.227,1.003l-3.25-0.146l-2.334-3.354L648.359,511.796L648.359,511.796z"/>
|
||||
<path d="M675.527,526.896l2.61,3.018l10.012-3.467l1.979-7.643l4.46-0.319l4.08-2.956l-5.29-3.854l-1.211-2.119l-2.61,4.815l0.959,2.766l-1.591,2.31l-2.999-0.771l-7.27,5.333l0.188,3.086L675.527,526.896L675.527,526.896z"/>
|
||||
</g>
|
||||
<path id="mz" d="M482.791,596.359l2.326,1.928l5.479-3.335l0.881-4.953v-8.178l8.791-7.191l1.506,0.053l5.322-5.107l-0.828-10.529l-13.81,1.743l0.519,3.338l2.02,1.757l0.57,5.731l-4.756,4.642l-1.141-2.603l0.207-3.44l-2.74-2.973l-6.725,3.13l6.258,3.182l0.209,9.274l-4.141,6.146L482.791,596.359L482.791,596.359z"/>
|
||||
<path id="na" d="M444.221,603.863l2.897,0.208l1.702,1.72l4.037,0.052l0.984-11.462v-7.503l2.585-0.52l0.986-7.867l6.569-0.206l2.323-1.927l-3.933-0.156l-5.325,0.726l-5.739-2.082h-16.13l0.415,4.58l5.376,7.918l-0.934,4.062l0.053,2.136L444.221,603.863L444.221,603.863z"/>
|
||||
<path id="nc" d="M798.706,602.576l-0.303,1.547l3.983,5.559l2.145,0.926l0.303-2.161L798.706,602.576L798.706,602.576z"/>
|
||||
<path id="ne" d="M413.396,500.17l2.204-0.053l1.988-2.981l3.336-0.597l3.553,2.17l7.58,0.216l5.861-2.386l2.204-1.894l0.164-2.489l4.088-4.123l1.08-9.104l-2.688-5.636l-6.88-1.677l-15.923,12.413l-2.256-0.217l-0.969,8.617l-8.124,0.813L413.396,500.17L413.396,500.17z"/>
|
||||
<path id="ng" d="M413.984,515.185l3.389,0.164l4.088,4.557l1.987,0.544l1.558-0.761l2.367-0.328l0.805-3.303l3.225-2.117l3.492-0.163l6.396-11.766l-0.104-2.653l-2.955-2.273l-5.913,2.603l-7.909-0.111l-3.77-2.386l-2.688,0.596l-1.4,2.438l-0.104,6.88l-2.256,3.198L413.984,515.185L413.984,515.185z"/>
|
||||
<path id="ni" d="M203.216,491.62l1.893,0.381l0.061,3.881l-2.204,6.293l-5.938-0.588l-1.323-3.034l1.764-3.682l3.345-3.112L203.216,491.62L203.216,491.62z"/>
|
||||
<path id="nl" d="M421.349,384.572l-3.915,1.928l0.829,0.752l0.088,1.928l-0.829-0.164l-0.917-1.426l-2.188,3.467l3.363,0.699l1.253,1.323l0.666,0.017l0.44-2.99l2.117-0.891L421.349,384.572L421.349,384.572z"/>
|
||||
<g id="no">
|
||||
<path class="mainland" d="M460.567,327.409l1.747-1.279l-0.157-1.435l-1.106-0.641l0.157-1.754h0.95v-0.96l-4.123-1.114l-6.181,0.64l-0.631,2.714l-1.428-0.477l-0.951-1.599l-3.017,0.155l-0.32,3.033l-1.426,0.641l-0.795-1.6l-6.345,5.109l1.271,1.436l-2.378,1.115l-5.393,10.701l-1.901,1.279l0.155,0.959l1.9,0.959l-0.475,2.074l-3.173-0.164l-0.952-1.115l-2.057,2.395l-1.271,0.959l-0.32,2.24l-1.105,0.64l-2.854,0.64l-1.426,4.479l0.951,7.348l1.106,3.354l1.271,1.279l2.853-0.156l4.124-3.994l1.581-2.713l0.478,3.992l2.697-4.789l0.154-13.424l2.195-1.383l0.657-7.408l6.655-9.586l3.173-1.115l1.427-1.755l4.754,1.115l2.377,1.435l0.796-3.992l3.968-2.396L460.567,327.409L460.567,327.409z"/>
|
||||
<path d="M437.056,285.762l-1.426-1.435l-3.164,1.539h-5.809l-0.917,3.389l3.26,2.878l1.425-0.208l2.04-3.491l1.729,1.235l-1.229,2.464l-0.614,3.596l1.427,2.256l3.06-5.135l3.979-4.832l-1.531-1.33L437.056,285.762L437.056,285.762z"/>
|
||||
<path d="M438.784,279.6l-2.55,2.359l1.529,2.359h2.749l1.124,1.539l3.363,1.746l3.871-2.256l2.654-2.256l-0.916-1.85l-2.654-1.539l-1.938,1.746l-1.321-1.644l-1.021,0.104l-1.322,2.878l-1.936-1.954l-0.208-1.331L438.784,279.6L438.784,279.6z"/>
|
||||
<path d="M444.593,290.179l-2.04,1.851l-1.729,1.332l0.812,1.435l1.636,0.511l2.652-1.236l1.229-1.539l-1.124-1.85L444.593,290.179L444.593,290.179z"/>
|
||||
</g>
|
||||
<path id="np" d="M595.182,448.789l0.397,3.691l6.983,3.162l11.193,0.83l-0.423-2.705l-7.478-2.058l-6.346-3.777L595.182,448.789L595.182,448.789z"/>
|
||||
<g id="nz">
|
||||
<path d="M804.221,655.729l0.917,10.199l-1.228,4.634l-4.6,3.396l0.305,4.02v4.322l1.228,1.548l12.577-10.814v-2.472h-3.068l-4.298-14.521L804.221,655.729L804.221,655.729z"/>
|
||||
<path d="M795.023,677.979l2.455,4.633l-6.752,6.492l-0.613,3.396l-4.6,0.613l-7.667,7.104l-7.054-3.396l-0.613-2.474l12.879-5.558L795.023,677.979L795.023,677.979z"/>
|
||||
</g>
|
||||
<path id="om" d="M532.244,481.879l6.388-3.683l1.133-5.402l-1.399-0.804l0.579-5.792l1.219-0.709l1.306,2.049l7.771,4.062v2.258l-9.413,13.854l-4.331,0.147L532.244,481.879L532.244,481.879z"/>
|
||||
<path id="pa" d="M205.68,506.748l-1.262,3.941l4.167,1.079l2.584,0.512l0.441-3.052l2.775-1.4l2.463,1.271l0.968,1.547l1.175-0.138l0.925-2.81l-3.078-1.271l-2.334-1.271l-2.333,1.59l-2.775,1.4l-2.835-1.141L205.68,506.748L205.68,506.748z"/>
|
||||
<path id="pe" d="M209.518,541.246l-1.677,1.695l0.113,2.703l14.643,26.694l15.205,9.802l2.351-3.941l0.562-8.669l-1.228-5.402l-4.141-6.984l-2.463,0.786l-1.115,1.236l-4.918-5.636l1.228-6.647l5.705-3.717l-0.449-3.492l-5.809-0.226l-3.017-5.064l-1.677-0.562l0.113,3.044l-7.486,8.895l-5.593-1.349L209.518,541.246L209.518,541.246z"/>
|
||||
<g id="pg">
|
||||
<path class="mainland" d="M752.132,540.183l-0.319,21.126l3.044-0.164l4.002-4.676l3.361,0.164l2.161,1.937l0.718,5.964l6.882,3.631l1.763-0.648v-2.179l-5.523-4.599l-2.723-6.293l2.161-1.047l-1.601-3.466l-3.197-0.078l-0.805-3.709l-8.479-5.722L752.132,540.183L752.132,540.183z"/>
|
||||
<path d="M778.176,546.008l-0.819,0.191l-0.501,2.222l-1.573,1.021l-4.729,0.83l0.19,1.779l4.979-0.249l3.155-1.972l-0.188-3.432L778.176,546.008L778.176,546.008z"/>
|
||||
<path d="M776.093,540.797l-0.762,1.08l4.159,3.683l0.569,2.161l1.133-0.13l0.13-2.221l-1.263-1.14L776.093,540.797L776.093,540.797z"/>
|
||||
</g>
|
||||
<g id="ph">
|
||||
<path d="M697.337,496.306l-0.743,1.418l-0.414,1.746l-4.132,5.246l0.25,1.081l1.737-0.251l5.368-5.999L697.337,496.306L697.337,496.306z"/>
|
||||
<path d="M704.027,494.309l-0.088,4.331l1.573,1.582l0.578,3.077l1.574,0.337l0.742-1.919l-1.236-0.916l-0.328-5.411L704.027,494.309L704.027,494.309z"/>
|
||||
<path d="M708.496,495.978l-0.087,3.829l0.908,1.495l1.571-1.832l-0.414-3.328L708.496,495.978L708.496,495.978z"/>
|
||||
<path d="M709.481,492.641l1.572,2.083l0.743,1.997h1.409l-0.25-3.415l-1.573-1.08L709.481,492.641L709.481,492.641z"/>
|
||||
<path d="M712.542,500.472l0.328,2.498l-2.896,2.334l-2.396,0.251l-2.56,2.749l0.087,1.252l2.396-0.751l1.651-1.08l1.408,3.578l2.48,1.747l0.994-0.338l0.907-1.08l-1.979-1.997l1.159-0.916l1.322,1.08l0.906-1.495l-0.906-1.833l-0.164-4.08L712.542,500.472L712.542,500.472z"/>
|
||||
<path d="M699.074,475.076l-2.23,1.581l-0.25,4.997l3.477,6.742l1.158,0.915l1.485-1.003l2.56,0.415l0.492,2.248l1.901,0.164l0.908-1.245l-1.158-1.582l-1.409-1.331l-2.974-0.327l-1.573-2.585l1.816-2.749l0.163-2.411l-1.236-3.077L699.074,475.076L699.074,475.076z"/>
|
||||
<path d="M700.232,489.979l0.657,2.335l1.158,0.752l0.83-1.081l-1.323-1.832L700.232,489.979L700.232,489.979z"/>
|
||||
</g>
|
||||
<path id="pk" d="M553.638,455.082l2.248,3.337l-0.216,1.72l-2.991,1.186l-0.217,2.801h3.424l1.175-0.969h6.519l5.878,5.17l0.752-2.481h4.383l0.104-3.12l-4.486-4.305l0.96-2.369l4.598-0.318l6.199-12.924l-3.425-2.688l-1.278-4.521l8.333-0.752l-4.917-7l-2.62-0.709l-1.07,1.297l-0.805,0.061l-4.919,3.12l1.608,2.696l-1.815,1.937l-2.249,8.29l-5.558,3.553l-0.752,3.882L553.638,455.082L553.638,455.082z"/>
|
||||
<path id="pl" d="M457.109,390.184l0.733,1.348l0.174,1.435l-0.604,1.392l-1.383,2.664l-1.167,0.526l-1.514-0.657l-0.908,0.043l-2.204,0.83l-2.506-0.742l-4.062-2.879l-3.978-2.135l-1.6-2.438l-0.303-5.75l3.112-2.705l4.062-1.35l1.328-0.138l0.315,1.007l1.725,0.692l4.766,0.09l1.47-0.043l2.421,3.708l-0.604,1.521l0.259,1.789L457.109,390.184L457.109,390.184z"/>
|
||||
<path id="pr" d="M249.297,482.068l-2.282-0.77l-1.833,1.149l0.917,1.071l3.121,0.458L249.297,482.068L249.297,482.068z"/>
|
||||
<g id="pt">
|
||||
<path class="mainland" d="M387.499,421.716l-0.536,7.478l-1.53,1.384l0.156,0.846l1.071,1.772l-0.691,2.16l1.149,0.39l2.68-0.312l-0.155-2.16l1.756-10.02l-0.382-1.383L387.499,421.716L387.499,421.716z"/>
|
||||
<path d="M367.834,443.481l-0.934,1.185l0.934,1.185l1.408-0.709L367.834,443.481L367.834,443.481z"/>
|
||||
<path d="M337.112,426.713l-1.175,1.184l2.107,1.185l0.234-1.649L337.112,426.713L337.112,426.713z"/>
|
||||
<path d="M343.448,426.004l-1.408,0.941l1.175,0.941l1.876-0.476L343.448,426.004L343.448,426.004z"/>
|
||||
<path d="M344.382,429.314l-0.7,1.892l0.935,1.185l1.175-0.941L344.382,429.314L344.382,429.314z"/>
|
||||
<path d="M350.01,433.092l-0.467,1.184l0.701,0.709l1.875-1.184L350.01,433.092L350.01,433.092z"/>
|
||||
</g>
|
||||
<path id="py" d="M267.199,584.458l1.902,2.074l-0.225,4.392l5.48-0.338l4.141,5.299l-0.337,4.729l-2.681,4.054L270,604.893l-0.225-2.256l1.564-3.718l-5.368-3.38h-4.469l-3.354-3.604l2.438-6.968L267.199,584.458L267.199,584.458z"/>
|
||||
<path id="qa" d="M527.273,463.018l-0.449,3.467l1.331,1.012l1.209-0.112l0.45-4.365l-1.047-0.752L527.273,463.018L527.273,463.018z"/>
|
||||
<path id="ro" d="M457.731,401.281l-0.226,1.279l-5.005,4.166l4.184,6.137l2.682,1.877h4.823l1.591-1.331l2.135-0.276l1.591,0.959l2.818-3.207l-0.545-1.607l-2.861-0.734l-1.953-0.095l0.094-2.749l-2.593-4.08L457.731,401.281L457.731,401.281z"/>
|
||||
<path id="rs" d="M452.001,407.279l-1.772,1.332h-0.863l-0.588,1.832l2.092,2.43l0.139,1.928l-0.882,1.246l3.068,3.197l3.317-1.012l-0.274-4.721L452.001,407.279L452.001,407.279z"/>
|
||||
<g id="ru">
|
||||
<path class="mainland" d="M722.059,302.16l1.522,5.256l3.043,0.873l3.042-4.814l-1.737-3.285l0.648-2.845h4.563l-1.09,2.188l0.434,7.883l-6.519,16.199l0.648,3.501l-0.216,5.912l12.161,17.729l2.387,0.657l0.216-14.443l2.386-2.188l-2.61-5.688l2.171-2.412l-4.78-6.346l-2.611,0.217l-0.865-10.503l6.734-1.755l0.432-3.068l3.478-0.873l1.953,1.756l2.386-9.631l4.124-7l3.259-1.756l2.827,0.217v-3.285l-4.563-0.873l-6.302-5.256l3.044-3.5l-2.61-5.913l2.169-2.187l2.61,3.5l6.519,2.411l7.166,0.657l0.873-3.061l-3.69-3.717l4.123-5.688l-9.345-3.285l-2.386,4.814l-3.043-3.941l-17.158-5.913l-16.295,2.844l-2.387,1.314v1.313l3.476,1.756l-0.434,4.157l-6.301-2.628l-13.898,5.473l-2.388-5.031h-9.561l-4.349,4.599l-15.421-3.501l-14.115,2.844l-1.737,4.375l2.17,0.656l-0.216,3.285l-13.685,1.529l0.873,4.375l-12.604-2.188l3.044-5.688l-12.819-0.657l1.089,5.913l-4.123,1.971l-3.476-3.285l-14.116,2.412l-5.428,5.031l-0.217,3.06l-3.476,0.216l-0.433-3.5l11.082-9.629v-6.57l-7.166-1.971l-9.345,3.061l-3.907-3.942h-1.737l-2.171,4.374l1.738,1.971l-12.388,6.787l-10.642,8.1l-6.519,8.972v3.717l6.949,2.845l-3.475,2.627l-7.382-2.627l-3.044,2.627l-4.563-5.256l-0.873,1.972l4.996,15.758l1.305,0.44l3.478-1.754l1.736,1.313v2.844l-3.259-1.313l-1.955,1.529l1.308,2.844l-1.09,7.443l-6.734,0.657l-0.432-2.412l3.907-2.411l0.873-6.57l-4.349-5.688l-1.521-9.846l-6.948-1.098l-0.648,3.5l1.305,1.754l-2.825,2.412l1.089,6.57l4.124,1.755l0.873,4.814l-4.133-2.627l-10.641-1.972l-1.306,3.502l-8.473,3.06l-1.305-2.187l-11.082,6.127l-0.216,4.158l-4.348,0.657l1.306-3.06v-3.061l-4.349-1.529l-2.826,1.098l2.386,4.599l1.737,3.06v2.412l-3.26-0.656l-0.647-0.657l-3.26,3.501l1.737,3.061l-7.383-0.217l2.387,3.069l-0.647,1.313h-3.907l-2.827-1.971l-0.647-5.472l-4.563-1.755v-2.187l9.561,1.972l5.213,0.44l2.17-3.285l-1.954-3.501l-13.899-5.472l-4.798,1.192l-1.642,1.41l0.511,3.24l2.039,0.354l-0.476,5.101l6.293,14.781l-4.548,7.209l-0.312,1.625l2.309,1.625l-2.084,1.375l-1.383,0.026l0.26,6.353l1.91,2.706l0.026,2.627l2.446,0.225l3.741,1.426l3.959,5.446l0.045,1.435l-1.288,2.205l2.956-0.164l2.878,0.83l3.891,5.506l9.577,0.873l-0.415,6.552l-3.301,2.827l0.683,1.105l-3.26,3.502l-0.864,3.284l1.954,2.845l6.301,2.187l2.611-1.53l16.727,6.346l0.648-1.756l-3.476-3.283v-4.158l-2.171-0.657l0.434-3.5l3.476-4.158l-6.231-4.667l0.432-6.492l6.665-4.383l7.822,0.441l1.306,2.412l8.04,0.44l5.869-3.284l-3.044-3.285l0.647-6.129l15.205-7.442l11.695,5.272l3.907-3.5l11.514,10.942l8.688-0.873l3.044,3.06l8.255,0.873l5.429-7.441l6.949,3.068l3.69,0.658l3.692-3.285l-3.26-2.188l2.827-4.374l8.039,2.628l1.736,3.501l3.477,0.216l2.17-1.529l5.869-0.217l0.647,1.53l6.733,0.44l4.562-4.814l9.345,1.098l2.827-1.098l0.864-5.256l-2.826-6.346l2.826-2.411h8.904l8.471,10.069l10.857,6.129h3.26l0.432-2.627l3.907-2.412l0.433,14.228l-3.475,0.216v3.5l1.953,2.412l-0.363,3.129l1.443,0.598l0.874-2.188l1.306,0.441l0.864,0.873l3.907-0.873l3.906-11.385l0.434-14.229l-4.996-11.385l-6.301-7.657l-3.044,0.44v2.412l-7.382-2.845l2.826-6.128l2.387-16.199l9.992-3.06l4.779-3.06h5.213l-1.312,1.754l1.306,2.188l4.563-4.814l2.609,0.215l-0.432-2.844l-4.132-0.873l2.827-10.287L722.059,302.16L722.059,302.16z"/>
|
||||
<path d="M450.108,378.288l-1.296,2.396l4.665,0.043h0.95l-0.179-1.352l-0.728-0.854L450.108,378.288z"/>
|
||||
<path d="M741.137,353.246l-1.071,1.332l0.087,2.083l0.992-0.086l1.651-2.913L741.137,353.246L741.137,353.246z"/>
|
||||
<path d="M776.793,272.303l-2.04,1.331l-0.483,1.694l0.96,1.089l2.16-0.726l2.161,0.726l1.201,0.363l-0.121-3.994L776.793,272.303L776.793,272.303z"/>
|
||||
<path d="M488.539,272.648l1.487,0.598l-1.046,1.798v2.55l-2.23,1.35h-2.377l-1.34-1.651l0.146-1.798l1.046-1.35h2.084L488.539,272.648L488.539,272.648z"/>
|
||||
<path d="M494.192,270.998v1.798l1.486,1.202l2.083-0.146l1.788-1.651v-1.202h-1.634l-1.34,0.449l-1.046-1.201L494.192,270.998L494.192,270.998z"/>
|
||||
<path d="M502.681,271.152l1.046,2.248l2.084,0.147l1.486-0.596l-0.742-2.101l-1.937-0.451L502.681,271.152L502.681,271.152z"/>
|
||||
<path d="M511.161,268.154l-1.635-0.303l-1.487,1.504l0.744,1.349l0.449,2.101l1.937-1.496l0.449-1.65L511.161,268.154L511.161,268.154z"/>
|
||||
<path d="M520.237,284.051l-0.45,2.1l-3.424,3l-7.294,1.651l-5.957,9.897l-1.046,2.853l5.957,1.505l0.889-3.597l1.79-5.549l4.615-2.403l3.872-3l2.826-1.201h1.487v-4.046L520.237,284.051L520.237,284.051z"/>
|
||||
<path d="M501.039,305.946l4.019,0.45l1.342,4.649l3.423,3.597l-1.193,2.402h-2.083l-1.937-2.248l-4.313-0.146l-1.789-2.403v-1.651l2.682-0.752L501.039,305.946L501.039,305.946z"/>
|
||||
<path d="M563.855,254.809l-1.938-1.203h-2.23l-0.448,1.35l-2.377,1.35l-1.79,0.596l-0.294,1.798l4.167,0.303L563.855,254.809L563.855,254.809z"/>
|
||||
<path d="M568.463,255.257l-1.047,2.247l-2.083-0.146l-3.276,2.402l-0.89,3h2.083l1.193-1.953l2.826,2.101l2.681-1.202l1.937-1.65l-0.744-2.551l-1.046-1.798L568.463,255.257L568.463,255.257z"/>
|
||||
<path d="M572.784,256.908l1.046,4.201l1.634,3.897l1.79-3.146l3.423-0.752v-2.248l-2.229-1.651L572.784,256.908L572.784,256.908z"/>
|
||||
<path d="M654.453,250.184l2.326,1.953l1.649-0.683l0.484-2.74l-3.389-2.342l-2.23,1.469l-5.428,0.493v2.445l-5.724,0.096v4.002l6.69,4.979l1.747-1.271l-0.39-3.519l4.27-1.071l-0.873-1.66l-1.547-1.563L654.453,250.184L654.453,250.184z"/>
|
||||
<path d="M660.66,247.84l1.547,2.932l6.017-0.684l1.65-2.152l-0.389-1.857l-1.65-0.684l-1.548,1.176l-4.46,0.978L660.66,247.84L660.66,247.84z"/>
|
||||
<path d="M660.271,259.268l-3.01-0.777l-1.736,1.857l-0.778,2.541l4.071-0.389l3.104-1.564L660.271,259.268L660.271,259.268z"/>
|
||||
<path d="M738.231,242.369l-2.523-0.778l-2.904,1.072l-1.453,2.151l1.842,2.446l4.85-2.151l0.968-1.072L738.231,242.369L738.231,242.369z"/>
|
||||
<path d="M739.156,358.329v3.665l1.159,0.415l0.828-1.332v-2.827L739.156,358.329L739.156,358.329z"/>
|
||||
<path d="M705.35,345.086l-0.076,5.333l6.689,10.33l2.396,8.989l4.219,7.996l1.649,0.58l1.409-1.168l0.657-1.918l-6.033-6.578l0.164-3.416l1.322-0.578l0.329-1.997l-11.816-16.735L705.35,345.086L705.35,345.086z"/>
|
||||
<path d="M751.967,328.516l-1.649,0.164l0.993,1.418l2.065,1.418l0.579-0.666L751.967,328.516L751.967,328.516z"/>
|
||||
<path d="M755.183,329.52l0.251,1.416l2.56,0.752l0.251-1.002L755.183,329.52L755.183,329.52z"/>
|
||||
<path d="M769.229,334.956l1.08,1.937l1.8-0.121l0.361-1.332L769.229,334.956L769.229,334.956z"/>
|
||||
<path d="M787.356,337.98l1.442,2.662l1.08-1.209v-1.815L787.356,337.98L787.356,337.98z"/>
|
||||
</g>
|
||||
<path id="rw" d="M479.896,532.931l2.429,2.238l-0.104,2.396l-3.769,0.077v-2.646L479.896,532.931L479.896,532.931z"/>
|
||||
<path id="sa" d="M519.812,458.021l6.061,8.443l1.953,1.558l0.874,3.785l9.327,0.734l1.055,0.554l-1.046,4.667l-6.129,3.613l-8.964,2.715l-4.78,4.668l-5.679-3.312l-3.439,3.009l-4.791-7.823l-3.284-1.504l-1.192-1.807v-3.916l-11.954-14.452l-0.451-2.561h3.44l4.184-3.611l0.146-1.808l-1.192-1.201l2.395-1.953l5.084,0.303l8.669,7.226l5.117-0.232l0.329,1.263L519.812,458.021L519.812,458.021z"/>
|
||||
<g id="sb">
|
||||
<path d="M783.786,549.882l1.072,2.981l1.892,1.842l0.57-0.51l-0.189-1.972l-2.145-2.603L783.786,549.882L783.786,549.882z"/>
|
||||
<path d="M789.016,554.324l0.131,1.971l1.2,1.141l1.134-0.7l-1.012-2.102L789.016,554.324L789.016,554.324z"/>
|
||||
<path d="M790.528,559.218l-1.012,1.081l1.071,1.97l1.262,0.381l-0.06-1.331L790.528,559.218L790.528,559.218z"/>
|
||||
<path d="M792.992,558.076l0.882,2.161l1.702,2.03l0.943-1.521l-1.263-2.161L792.992,558.076L792.992,558.076z"/>
|
||||
<path d="M797.409,561.317l0.501,2.671l1.203,1.65l1.011-2.092L797.409,561.317L797.409,561.317z"/>
|
||||
<path d="M798.792,567.291l-0.44,0.76l1.452,1.911l1.012,0.062l-0.632-2.482L798.792,567.291L798.792,567.291z"/>
|
||||
<path d="M795.576,571.094l-1.514,0.7l1.323,1.843l1.133-0.64L795.576,571.094L795.576,571.094z"/>
|
||||
</g>
|
||||
<g id="sc">
|
||||
<path d="M535.676,548.87l-0.525,1.062l1.442,1.192l1.056-1.192L535.676,548.87L535.676,548.87z"/>
|
||||
<path d="M543.049,540.919l-1.582,1.062l1.186,1.857h1.582L543.049,540.919L543.049,540.919z"/>
|
||||
<path d="M543.706,545.56l-1.055,1.193l0.786,1.192l1.442,0.269l0.13-2.522L543.706,545.56L543.706,545.56z"/>
|
||||
</g>
|
||||
<path id="sd" d="M466.144,505.035l-2.55-1.504l-2.325-4.59l0.13-4.271l3.224-2.772l0,0l0.155-10.228l2.127,0.062l-0.242-5.68l22.302,0.198l3.189-3.215l6.881,11.004l-3.77,4.442v6.785l-4.599,9.891l-1.04,2.3l-3.709-5.316l-2.708,3.441l-3.059,0.834l-9.941-1l-4.333,1.541L466.144,505.035z"/>
|
||||
<g id="se">
|
||||
<path class="mainland" d="M445.232,329.52l1.693,1.563h3.173l1.746,3.354l0.477,5.748l-4.278,3.035v3.033l-3.017,4.158l-1.746,0.154l-2.378,3.994l0.155,3.838l4.124,3.035l-0.319,1.754l-1.582,2.396l-2.377,2.074l0.155,6.872l-3.647,1.279l-1.271,2.713h-1.747l-0.949-4.789l-3.969-6.084l3.26-5.455l0.225-13.477l2.248-1.236l0.545-7.709l6.405-9.172L445.232,329.52L445.232,329.52z"/>
|
||||
<path d="M445.898,368.927l-1.824,1.443l0.917,2.118l1.616-1.573L445.898,368.927L445.898,368.927z"/>
|
||||
</g>
|
||||
<path id="sg" d="M658.314,527.705l0.686,0.389l1.548-0.126l-0.13-1.167L659.156,527L658.314,527.705z"/>
|
||||
<path id="si" d="M442.708,405.076l-2.195,1.314l-4.097,0.898l0.82,2.368l2.87,0.034l2.646-2.213L442.708,405.076L442.708,405.076z"/>
|
||||
<path id="sk" d="M443.607,400.875l0.597,0.527l0.077,0.898l6.596-0.146l4.875-2.102l-0.077-2.135l-0.934,0.415l-1.34-0.718l-0.821-0.035l-2.161,0.865l-2.938-0.709L443.607,400.875L443.607,400.875z"/>
|
||||
<path id="sl" d="M372.804,509.79l4.884,4.72l3.483-4.227l-2.179-3.415l-3,0.303L372.804,509.79L372.804,509.79z"/>
|
||||
<path id="sn" d="M372.424,498.771l-5.792-0.141l0,0l1.072,2.603l0.596-1.607l7.271,0.761l0.806-0.028l3.405,0.12l0.121-1.504l-3.111-3.733l-3.467-4.693l-2.152-0.898l-1.661,0.424l0,0l-3.405,2.472l-0.776,1.384l-0.242,1.383l1.253,0.899l4.185-0.061l2.688-0.728l0.303,1.322l-0.241,1.746L372.424,498.771z"/>
|
||||
<path id="so" d="M526.703,501.941l3.777-1.452l1.34,0.804l-0.147,3.354l-3.482,9.923l-18.854,20.19l-2.187-1.503l-0.147-8.521l2.835-3.26l6.018-1.857l8.824-9.318l2.31-2.058l0.647-3.008L526.703,501.941L526.703,501.941z"/>
|
||||
<path id="sr" d="M268.384,516.715l1.763,1.616l2.731-1.694l2.49,0.078l-0.32,0.968l-1.046,2.179l-0.164,5.421l-4.97,2.022l0.242-3.476l-3.207-2.991l0.164-1.538L268.384,516.715L268.384,516.715z"/>
|
||||
<path id="ss" d="M489.336,508.019l-2.04,0.898l0.647,3.553h2.542l3.448,5.004l-2.767,0.354l-0.709,1.288l-0.069,1.857l-8.298-0.146l-0.848-1.288l-5.8-0.328l-10.649-10.962l1.063-0.64l4.517-1.364l9.897,0.754l3.366-0.754l2.235-2.996L489.336,508.019z"/>
|
||||
<g id="st">
|
||||
<path d="M421.911,530.554l0.993-0.502l0.743,0.604l-0.743,1.148l-0.899-0.354L421.911,530.554L421.911,530.554z"/>
|
||||
<path d="M423.907,527.398l1.496-0.251l0.501,0.951l-0.743,0.805l-0.743-0.104L423.907,527.398L423.907,527.398z"/>
|
||||
</g>
|
||||
<path id="sv" d="M189.308,495.217l4.062,2.022l-0.061-3.207l-2.083-1.271L189.308,495.217L189.308,495.217z"/>
|
||||
<path id="sy" d="M487.545,437.18l-0.302,2.196l2.437,1.021l-0.104,6.086l2.438-0.053l2.438-1.842l0.916-0.155l5.532-4.398l1.114-6.39l-11.056,1.125l-1.167,2.559L487.545,437.18L487.545,437.18z"/>
|
||||
<path id="sz" d="M482.531,596.983l-2.169,0.361l-0.935,2.552l1.659,1.513h2.015l1.702-2.446L482.531,596.983L482.531,596.983z"/>
|
||||
<path id="td" d="M440.971,494.983l0.112-2.552l4.098-3.983l1.099-9.785l-2.731-5.221l1.911-0.979l18.498,9.639l-0.113,9.456l-3.259,2.775v4.875l2.136,4.132h-3.77l-6.24,6.173l-0.165,1.867l-4.605-0.061l-0.061,0.846l-2.628-0.345l-1.798-3.397l-1.351-0.666l0.174-1.037l1.693-1.297v-6.066l-2.343-0.363l-2.826-2.102L440.971,494.983L440.971,494.983L440.971,494.983z"/>
|
||||
<path id="tg" d="M408.495,516.81l2.316-1.356l-0.053-8.946l-1.504-2.438l-0.967,0.812L408.495,516.81L408.495,516.81z"/>
|
||||
<path id="th" d="M646.043,472.915l2.8,3.604v4.384l0.968,0.482l4.452-2.144l0.873,0.294l5.316,6.138l-0.19,4.192l-1.737-0.294l-1.548-0.979l-1.158,0.097l-2.031,3.404l0.39,1.851l1.642,0.873l-0.095,2.049l-1.157,0.588l-3.97-2.731v-2.438l-1.642-0.095l-0.674,1.07l-0.347,10.909l2.567,4.686l4.547,4.383l-0.188,1.271l-2.423-0.094l-2.221-3.311h-2.325l-2.902-2.345l-0.874-2.437l1.254-2.049l0.432-1.851l1.366-2.421l-0.061-5.565l-3.337-4.823l-0.139-0.588l1.081-1.089l-0.251-3.83l-4.441-5.627l0.519-3.241L646.043,472.915L646.043,472.915z"/>
|
||||
<path id="tj" d="M559.74,422.234l3.552-4.408h1.34l0.467,0.984l-1.642,1.192v0.985l1.081,0.777l5.195,0.312l1.693-0.727l0.77,0.154l0.52,1.66l3.085,0.312l1.548,3.267l-0.467,0.985l-0.614,0.053l-0.612-1.245l-1.341-0.104l-2.315,0.312l-0.156,2.18l-2.316-0.155l0.104-2.75l-1.694-1.658l-2.575,2.125l0.053,1.4l-2.265,0.778h-1.341l0.104-4.824L559.74,422.234L559.74,422.234z"/>
|
||||
<path id="tm" d="M528.328,418.561l-0.535,2.273h-3.588v3.078l3.854,2.541l-1.191,3.482v1.608l1.599,0.269l2.127-2.811l4.789-1.07l10.234,3.881l0.129,2.809l5.714,0.536l6.38-6.698l-0.796-2.145l-4.253-0.935l-11.964-7.771l-0.535-2.81h-4.521l-1.997,3.753h-1.997L528.328,418.561L528.328,418.561z"/>
|
||||
<path id="tn" d="M425.516,435.624l4.78-1.927l1.572,1.02l0.061,1.244l-0.734,0.959l0.111,1.703l0.735,0.396v3.061l-0.847,1.418l0.111,0.908l3.207,1.132l-2.584,4.02l-1.012-0.061l-0.173,3.232l-1.124,0.174l-0.959-0.849l0.225-3.284l-3.146-3.061l-0.397-2.662l1.521-1.192L425.516,435.624L425.516,435.624z"/>
|
||||
<path id="tr" d="M472.812,421.906l-2.305-1.426l-1.271-1.013l-2.138,0.916l0,0l-1.477,3.74l2.219-0.5l1.562-1.188l3.438,0.938l-1.946,1.877L465.719,425l-1.91,2.093v1.021l1.22,1.021v1.123l-0.511,1.332l0.511,1.123l1.625-0.812l1.625,1.737l-0.406,1.228l-0.604,0.82l0.907,1.021l4.461,0.916l3.139-1.331v-1.937l1.521,0.303l3.648,2.144l3.948-0.614l1.721-1.633l1.114,0.406v1.841h1.521l1.313-2.55l11.549-1.229l5.04-0.613l-1.331-1.746l-0.025-2.359l1.011-1.21l-3.682-2.956l0.197-2.551h-2.022l-3.354-1.643l0,0l-1.929,2.041l-7.088-0.209l-4.253-2.549l-4.082,0.366l-4.544,2.729L472.812,421.906z"/>
|
||||
<path id="tt" d="M258.97,502.572l-0.917,0.847l-0.994,0.155v1.228l1.833,1.687l0.76-1.229l0.458-1.383l-0.156-1.149L258.97,502.572L258.97,502.572z"/>
|
||||
<path id="tw" d="M695.686,453.76l-3.06,2.334l-0.163,4.494l2.646,3.078l0.656-0.58L695.686,453.76L695.686,453.76z"/>
|
||||
<path id="tz" d="M492.22,560.017l13.797-1.69l-3.395-6.569l-0.182-6.292l1.098-3.009l-14.367-9.023l-4.502,0.743l-1.564,1.158l-0.139,2.637l-1.012,3.656l-1.055,1.253l-1.514,0.141l2.809,9.418l4.816,2.838l4.195,0.1L492.22,560.017z"/>
|
||||
<path id="ua" d="M460.662,388.791l-2.507,1.409l0.622,2.663l-2.316,4.884l0.02,2.151l1.089,0.691l6.983,0.346l1.954-1.615l2.092,0.699l2.999,4.002l-2.194,3.942l2.61,0.761l3.414-3.933l1.954,0.354l1.815,1.262l-1.601,2.109l2.161,3.371h2.299l1.185-2.248l2.438-0.494l0.069-1.823l-4.529-0.7l0.14-1.963h4.391l4.737-3.794l2.092-1.824l0.345-5.757l-9.336-0.838l-3.829-5.402l-2.646-0.908l-3.207,0.139l-1.443,3.569l-6.57,0.087l-2.135-0.986L460.662,388.791L460.662,388.791z"/>
|
||||
<path id="ug" d="M480.311,532.23l2.619,2.454l1.643-1.045l4.442-0.728l0.762,0.079l0.284-1.688l2.508-5.272l-2.109-4.392l-6.837,0.044l-0.043,1.808l0.917,0.882l-0.139,1.807L480.311,532.23L480.311,532.23z"/>
|
||||
<g id="us">
|
||||
<path class="mainland" d="M143.589,375.989l-0.865,3.475l-3.017-1.954h-1.504l-0.865,3.691l-10.554,23.65l2.801,20.606l3.449,1.737l0.648,5.645h7.105l6.889,5.204l13.562,1.305l1.504,6.941l2.152,1.521l3.017-3.033l2.369,1.08l2.152,9.976l3.656,2.386l3.017-5.645l9.258-6.726l6.025,2.817l5.169,0.433l0.216-3.25l10.762,0.217l2.152,2.386l0.432,5.42l-1.288,3.034l1.504,5.203h3.233l3.232-4.987l-1.288-2.386l-1.288-5.204l1.936-5.86l8.826-7.59l6.673-1.953l-0.864-6.293l9.258-9.983l9.258-1.521l-1.504-5.193l9.042-5.205v-6.94l-0.865-0.433l-3.233,1.082l-0.432,4.252l-10.745,0.129l-8.419,5.594l-13.217,4.322l-2.109-2.586l5.999-9.076l-2.965-2.826l-2.014-3.838l-4.175-3.354l-4.538-0.38l-8.575-5.852L143.589,375.989L143.589,375.989z"/>
|
||||
<path d="M74.791,285.234l2.991,5.594l1.919-0.432v-1.938L74.791,285.234L74.791,285.234z"/>
|
||||
<path d="M57.926,334.428l-0.147,2.602l1.867-0.432v-1.158L57.926,334.428L57.926,334.428z"/>
|
||||
<path d="M55.057,335.586l-3.734,1.885l0.579,2.022l1.435-1.158l2.87-1.306L55.057,335.586L55.057,335.586z"/>
|
||||
<path d="M39.541,338.042l-2.584-0.579l-0.432,1.158l0.285,2.169L39.541,338.042L39.541,338.042z"/>
|
||||
<path d="M34.078,337.902l-2.446-1.01l-0.865,1.59l1.582,1.591L34.078,337.902L34.078,337.902z"/>
|
||||
<path d="M95.485,277.922l-7.252,1.721l1.496,8.168l7.892,2.151l0.423,1.72l-11.73,3.657l-6.613,10.96l2.343,11.609l3.838,2.576l2.991-2.793l0.856,1.721l-3.63,4.296l-14.082,6.449l-8.964,2.151l-0.216,3.225l20.694-6.016l8.532-2.369l7.892-9.674l8.748-5.8l-4.478,7.521l4.91,0.647l8.324-3.656l1.496,6.017l5.757,1.288l5.973,5.8l0.423,4.296l-0.855,1.072l1.063,4.08h1.496l0.216-6.881h1.703l0.423,16.977l4.271-3.657l-2.991-17.625h-4.478l-4.91-6.231l24.108-40.844l-23.892-18.696l-26.667,5.161l-1.063,8.168l5.757,3.439l-2.135,5.594L95.485,277.922L95.485,277.922z"/>
|
||||
</g>
|
||||
<path id="uy" d="M274.633,612.481l-1.773,1.894l0.735,10.183l5.566,1.615l7.08-7.097L274.633,612.481L274.633,612.481z"/>
|
||||
<path id="uz" d="M558.643,428.477l2.662,0.138v-4.556l-2.522-1.47l4.253-5.358h1.729l1.729,2.015l4.521-1.738l-6.25-2.144l-0.242-1.297l-1.485,0.363l-1.461,2.541l-6.302-0.207l-4.625-6.543l-8.125,0.803l-3.872-3.838l-5.358-0.906l-3.891,1.582l2.257,7.502l0.024,2.524l1.643,0.035l2.014-3.839l5.359,0.068l0.796,2.947l11.487,7.625l4.442,1.021L558.643,428.477L558.643,428.477z"/>
|
||||
<g id="vc">
|
||||
<path d="M258.823,496.582l-1.063,0.77l0.839,1.539l1.374-0.77L258.823,496.582L258.823,496.582z"/>
|
||||
<path d="M257.526,499.573l-0.994,0.994l0.38,0.612h1.219l0.38-1.003L257.526,499.573L257.526,499.573z"/>
|
||||
</g>
|
||||
<path id="ve" d="M231.5,503.558l0.38,2.239l2.809,0.892l0.64-4.124l2.965-3.068l2.965,3.475l6.82,1.859l5.774-1.211l3.933,4.849l2.965,1.858l-3.25,4.953l1.089,3.752l-1.858,2.299l-1.928,1.616l-4.175-2.102l-0.959,0.969v2.99l3.052,1.452l-2.248,2.43l-2.248,2.43l-2.965-0.242l-2.982-3.275l-0.631-12.326l-10.183-3.476l-1.85-5.42L231.5,503.558L231.5,503.558z"/>
|
||||
<path id="vn" d="M659.035,502.287l1.027,1.616l0.19,1.851l2.705,0.294l3.286-4.383l3.095-0.873l1.643-4.478l-0.771-7.209l-3.189-4.384l-3.362-2.688l-4.278-7.349l3.068-5.135l-4.393-5.039l-3.517-0.154l-3.164,1.702l0.942,4.071l4.219,0.743l1.133,3.138l-1.487,0.969l0.096,0.777l9.896,9.683l0.388,2.844l-0.595,8.989L659.035,502.287L659.035,502.287z"/>
|
||||
<g id="vu">
|
||||
<path d="M811.006,582.479l-1.071,1.435l0.45,1.616l0.535,0.362l0.979-1.262L811.006,582.479L811.006,582.479z"/>
|
||||
<path d="M811.542,586.879l0.087,1.167l1.158,0.363l0.805-0.449l-0.805-1.264L811.542,586.879L811.542,586.879z"/>
|
||||
<path d="M813.236,597.303l-0.536,0.812l0.804,0.897l1.34-0.448L813.236,597.303L813.236,597.303z"/>
|
||||
</g>
|
||||
<g id="ye">
|
||||
<path class="mainland" d="M509.432,489.131l1.244,3.7v3.613l2.991,2.714l21.074-8.584l0.198-2.359l-3.381-6.067l-8.479,2.706l-4.866,4.787l-5.645-3.336L509.432,489.131L509.432,489.131z"/>
|
||||
<path d="M533.315,498.138l1.842,2.059l2.489-1.504l0.897-0.304l-1.141-1.105l-2.188,0.647L533.315,498.138L533.315,498.138z"/>
|
||||
</g>
|
||||
<path id="za" d="M476.731,588.02l-6.829,6.311l-1.625,3.898l-5.411-0.674l-4.503,4.002l-2.991-0.294l0.241-5.531l-1.062-0.372l-0.743,11.314l-5.308-0.052l-1.6-1.886l-2.344-0.024l2.137,6.129l3.812,3.604l-2.723,3.172l1.764,3.977l4.08,1.558l3.25-2.767l9.311,0.053l0.667-0.83l4.132-0.728l13.978-13.917l-0.053-4.382l-1.495,1.937h-2.238l-2.723-2.282l1.383-3.44l2.378-0.482l-0.217-7.071L476.731,588.02L476.731,588.02z M473.455,604.011l1.306-0.052l2.118,2.299l-0.061,2.662l-2.481,1.253l-0.155,0.883l-3.785,0.043l-1.186-2.853l1.081-2.092L473.455,604.011L473.455,604.011z"/>
|
||||
<path id="zm" d="M459.78,571.656l2.739,3.802l4.244,0.261l1.504,0.829l4.443,0.053l3.829-5.367l10.702-4.789l0.934-4.219l-1.244-6.043l-5.584-3.181l-3.727,0.26l-1.857,4.114l0.053,1.876l4.391,2.136l0.26,4.642l-3.775,0.208l-0.935-1.564l-10.495-4.479l-0.311,3.44l-4.962,0.155L459.78,571.656L459.78,571.656z"/>
|
||||
<path id="zw" d="M468.52,578.226l7.755,8.757l5.946,1.513l3.984-6.248l-0.312-8.281l-6.465-3.337l-2.431,1.098l-3.62,5.524l-5.014-0.053L468.52,578.226L468.52,578.226z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 72 KiB |
@@ -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');
|
||||
|
||||
@@ -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
|
||||
{
|
||||
@@ -39,83 +40,113 @@ class AggregateAndPruneVisitsCommand extends Command
|
||||
$this->info('Found '.count($dates).' days to aggregate.');
|
||||
|
||||
foreach ($dates as $date) {
|
||||
// Accumulate stats per short_url_id using chunked reads — avoids loading
|
||||
// potentially millions of rows into PHP memory at once.
|
||||
$statsByUrl = [];
|
||||
$nextDate = Carbon::parse($date)->addDay()->toDateString();
|
||||
// Wrap date aggregation in a database transaction for data integrity
|
||||
DB::transaction(function () use ($date): void {
|
||||
// Accumulate stats per short_url_id using chunked reads — avoids loading
|
||||
// potentially millions of rows into PHP memory at once.
|
||||
$statsByUrl = [];
|
||||
$nextDate = Carbon::parse($date)->addDay()->toDateString();
|
||||
|
||||
ShortUrlVisit::where('visited_at', '>=', $date.' 00:00:00')
|
||||
->where('visited_at', '<', $nextDate.' 00:00:00')
|
||||
->chunk(1000, function ($chunk) use (&$statsByUrl): void {
|
||||
foreach ($chunk as $visit) {
|
||||
$urlId = $visit->short_url_id;
|
||||
DB::table('short_url_visits')
|
||||
->where('visited_at', '>=', $date.' 00:00:00')
|
||||
->where('visited_at', '<', $nextDate.' 00:00:00')
|
||||
->select([
|
||||
'short_url_id',
|
||||
'is_qr_scan',
|
||||
'ip_hash',
|
||||
'device_type',
|
||||
'browser',
|
||||
'operating_system',
|
||||
'country',
|
||||
'country_code',
|
||||
'utm_source',
|
||||
'utm_medium',
|
||||
'utm_campaign',
|
||||
'browser_language',
|
||||
'city',
|
||||
'referer_host',
|
||||
])
|
||||
->orderBy('id')
|
||||
->chunk(1000, function ($chunk) use (&$statsByUrl): void {
|
||||
foreach ($chunk as $visit) {
|
||||
$urlId = $visit->short_url_id;
|
||||
|
||||
if (! isset($statsByUrl[$urlId])) {
|
||||
$statsByUrl[$urlId] = [
|
||||
'total' => 0,
|
||||
'ip_hashes' => [],
|
||||
'device_stats' => [],
|
||||
'browser_stats' => [],
|
||||
'os_stats' => [],
|
||||
'country_stats' => [],
|
||||
'city_stats' => [],
|
||||
'referer_stats' => [],
|
||||
'utm_source_stats' => [],
|
||||
'utm_medium_stats' => [],
|
||||
'utm_campaign_stats' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$s = &$statsByUrl[$urlId];
|
||||
$s['total']++;
|
||||
|
||||
if ($visit->ip_hash) {
|
||||
$s['ip_hashes'][$visit->ip_hash] = true;
|
||||
}
|
||||
|
||||
$inc = function (?string $value, string $key) use (&$s): void {
|
||||
if ($value) {
|
||||
$s[$key][$value] = ($s[$key][$value] ?? 0) + 1;
|
||||
if (! isset($statsByUrl[$urlId])) {
|
||||
$statsByUrl[$urlId] = [
|
||||
'total' => 0,
|
||||
'ip_hashes' => [],
|
||||
'device_stats' => [],
|
||||
'browser_stats' => [],
|
||||
'os_stats' => [],
|
||||
'country_stats' => [],
|
||||
'city_stats' => [],
|
||||
'referer_stats' => [],
|
||||
'utm_source_stats' => [],
|
||||
'utm_medium_stats' => [],
|
||||
'utm_campaign_stats' => [],
|
||||
'qr_scans' => 0,
|
||||
'language_stats' => [],
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
$inc($visit->device_type, 'device_stats');
|
||||
$inc($visit->browser, 'browser_stats');
|
||||
$inc($visit->operating_system, 'os_stats');
|
||||
$inc($visit->country, 'country_stats');
|
||||
$inc($visit->utm_source, 'utm_source_stats');
|
||||
$inc($visit->utm_medium, 'utm_medium_stats');
|
||||
$inc($visit->utm_campaign, 'utm_campaign_stats');
|
||||
$s = &$statsByUrl[$urlId];
|
||||
$s['total']++;
|
||||
|
||||
if ($visit->city) {
|
||||
$cityKey = "{$visit->city} ({$visit->country_code})";
|
||||
$s['city_stats'][$cityKey] = ($s['city_stats'][$cityKey] ?? 0) + 1;
|
||||
if ($visit->is_qr_scan) {
|
||||
$s['qr_scans']++;
|
||||
}
|
||||
|
||||
if ($visit->ip_hash) {
|
||||
$s['ip_hashes'][$visit->ip_hash] = true;
|
||||
}
|
||||
|
||||
$inc = function (?string $value, string $key) use (&$s): void {
|
||||
if ($value) {
|
||||
$s[$key][$value] = ($s[$key][$value] ?? 0) + 1;
|
||||
}
|
||||
};
|
||||
|
||||
$inc($visit->device_type, 'device_stats');
|
||||
$inc($visit->browser, 'browser_stats');
|
||||
$inc($visit->operating_system, 'os_stats');
|
||||
$inc($visit->country, 'country_stats');
|
||||
$inc($visit->utm_source, 'utm_source_stats');
|
||||
$inc($visit->utm_medium, 'utm_medium_stats');
|
||||
$inc($visit->utm_campaign, 'utm_campaign_stats');
|
||||
$inc($visit->browser_language, 'language_stats');
|
||||
|
||||
if ($visit->city) {
|
||||
$cityKey = "{$visit->city} ({$visit->country_code})";
|
||||
$s['city_stats'][$cityKey] = ($s['city_stats'][$cityKey] ?? 0) + 1;
|
||||
}
|
||||
|
||||
if ($visit->referer_host) {
|
||||
$s['referer_stats'][$visit->referer_host] = ($s['referer_stats'][$visit->referer_host] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if ($visit->referer_host) {
|
||||
$s['referer_stats'][$visit->referer_host] = ($s['referer_stats'][$visit->referer_host] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
foreach ($statsByUrl as $urlId => $s) {
|
||||
ShortUrlDailyStats::updateOrCreate([
|
||||
'short_url_id' => $urlId,
|
||||
'date' => $date,
|
||||
], [
|
||||
'visits_count' => $s['total'],
|
||||
'unique_visits_count' => count($s['ip_hashes']),
|
||||
'device_stats' => $s['device_stats'],
|
||||
'browser_stats' => $s['browser_stats'],
|
||||
'os_stats' => $s['os_stats'],
|
||||
'country_stats' => $s['country_stats'],
|
||||
'city_stats' => $s['city_stats'],
|
||||
'referer_stats' => $s['referer_stats'],
|
||||
'utm_source_stats' => $s['utm_source_stats'],
|
||||
'utm_medium_stats' => $s['utm_medium_stats'],
|
||||
'utm_campaign_stats' => $s['utm_campaign_stats'],
|
||||
]);
|
||||
}
|
||||
foreach ($statsByUrl as $urlId => $s) {
|
||||
ShortUrlDailyStats::updateOrCreate([
|
||||
'short_url_id' => $urlId,
|
||||
'date' => $date,
|
||||
], [
|
||||
'visits_count' => $s['total'],
|
||||
'unique_visits_count' => count($s['ip_hashes']),
|
||||
'device_stats' => $s['device_stats'],
|
||||
'browser_stats' => $s['browser_stats'],
|
||||
'os_stats' => $s['os_stats'],
|
||||
'country_stats' => $s['country_stats'],
|
||||
'city_stats' => $s['city_stats'],
|
||||
'referer_stats' => $s['referer_stats'],
|
||||
'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'],
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
$this->info("Aggregated stats for {$date}.");
|
||||
}
|
||||
@@ -131,6 +162,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,9 +25,13 @@ class SyncBufferedCountersCommand extends Command
|
||||
if (Cache::getDefaultDriver() === 'redis' && class_exists(Redis::class)) {
|
||||
$tempKey = "{$dirtyKey}:temp:".time();
|
||||
try {
|
||||
Redis::rename($dirtyKey, $tempKey);
|
||||
$dirtyIds = Redis::smembers($tempKey);
|
||||
Redis::del($tempKey);
|
||||
if (Redis::exists($dirtyKey)) {
|
||||
Redis::rename($dirtyKey, $tempKey);
|
||||
$dirtyIds = Redis::smembers($tempKey);
|
||||
Redis::del($tempKey);
|
||||
} else {
|
||||
$dirtyIds = [];
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// If key does not exist or rename fails, fallback
|
||||
$dirtyIds = [];
|
||||
@@ -44,24 +48,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;
|
||||
|
||||
152
src/Filament/Resources/ShortUrlPixelResource.php
Normal file
152
src/Filament/Resources/ShortUrlPixelResource.php
Normal file
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlPixelResource\Pages\ListShortUrlPixels;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlPixel;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Actions\DeleteAction;
|
||||
use Filament\Tables\Actions\EditAction;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Columns\ToggleColumn;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class ShortUrlPixelResource extends Resource
|
||||
{
|
||||
protected static ?string $model = ShortUrlPixel::class;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-funnel';
|
||||
|
||||
protected static ?int $navigationSort = 51;
|
||||
|
||||
protected static ?string $recordTitleAttribute = 'name';
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('filament-short-url::default.pixels_navigation_label') ?? 'Retargeting Pixels';
|
||||
}
|
||||
|
||||
public static function getModelLabel(): string
|
||||
{
|
||||
return __('filament-short-url::default.pixel_resource_title') ?? 'Retargeting Pixel';
|
||||
}
|
||||
|
||||
public static function getPluralModelLabel(): string
|
||||
{
|
||||
return __('filament-short-url::default.pixels_navigation_label') ?? 'Retargeting Pixels';
|
||||
}
|
||||
|
||||
public static function getNavigationGroup(): string|\UnitEnum|null
|
||||
{
|
||||
try {
|
||||
return ShortUrlResource::getNavigationGroup();
|
||||
} catch (\Throwable) {
|
||||
return __('filament-short-url::default.navigation_group');
|
||||
}
|
||||
}
|
||||
|
||||
public static function getNavigationSort(): ?int
|
||||
{
|
||||
try {
|
||||
return ShortUrlResource::getNavigationSort() + 1;
|
||||
} catch (\Throwable) {
|
||||
return static::$navigationSort;
|
||||
}
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
TextInput::make('name')
|
||||
->label(__('filament-short-url::default.pixel_name') ?? 'Pixel Name')
|
||||
->required()
|
||||
->maxLength(150)
|
||||
->placeholder('e.g. Meta Ads - Yacht Promo'),
|
||||
|
||||
Select::make('type')
|
||||
->label(__('filament-short-url::default.pixel_type') ?? 'Provider')
|
||||
->options([
|
||||
'meta' => 'Meta / Facebook Pixel',
|
||||
'google' => 'Google Tag (GA4 / GTM)',
|
||||
'linkedin' => 'LinkedIn Insight Tag',
|
||||
'tiktok' => 'TikTok Pixel',
|
||||
'pinterest' => 'Pinterest Tag',
|
||||
])
|
||||
->required()
|
||||
->native(false),
|
||||
|
||||
TextInput::make('pixel_id')
|
||||
->label(__('filament-short-url::default.pixel_id_label') ?? 'Pixel ID / Tag ID')
|
||||
->required()
|
||||
->maxLength(100)
|
||||
->placeholder('e.g. 1234567890 or G-XXXXXXXXXX'),
|
||||
|
||||
Toggle::make('is_active')
|
||||
->label(__('filament-short-url::default.pixel_status_active') ?? 'Active')
|
||||
->default(true),
|
||||
])
|
||||
->columns(1);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->label(__('filament-short-url::default.pixel_name') ?? 'Pixel Name')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->weight('bold'),
|
||||
|
||||
TextColumn::make('type')
|
||||
->label(__('filament-short-url::default.pixel_type') ?? 'Provider')
|
||||
->badge()
|
||||
->formatStateUsing(fn (string $state): string => match ($state) {
|
||||
'meta' => 'Meta',
|
||||
'google' => 'Google',
|
||||
'linkedin' => 'LinkedIn',
|
||||
'tiktok' => 'TikTok',
|
||||
'pinterest' => 'Pinterest',
|
||||
default => ucfirst($state),
|
||||
})
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'meta' => 'info',
|
||||
'google' => 'warning',
|
||||
'linkedin' => 'primary',
|
||||
'tiktok' => 'gray',
|
||||
'pinterest' => 'danger',
|
||||
default => 'gray',
|
||||
}),
|
||||
|
||||
TextColumn::make('pixel_id')
|
||||
->label(__('filament-short-url::default.pixel_id_label') ?? 'ID')
|
||||
->searchable()
|
||||
->copyable()
|
||||
->fontFamily('mono')
|
||||
->color('gray'),
|
||||
|
||||
ToggleColumn::make('is_active')
|
||||
->label(__('filament-short-url::default.pixel_status_active') ?? 'Active')
|
||||
->sortable(),
|
||||
])
|
||||
->filters([])
|
||||
->actions([
|
||||
EditAction::make()->modalWidth('md'),
|
||||
DeleteAction::make(),
|
||||
])
|
||||
->bulkActions([]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListShortUrlPixels::route('/'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlPixelResource\Pages;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlPixelResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ManageRecords;
|
||||
|
||||
class ListShortUrlPixels extends ManageRecords
|
||||
{
|
||||
protected static string $resource = ShortUrlPixelResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make()
|
||||
->icon('heroicon-o-plus')
|
||||
->size('sm')
|
||||
->color('primary')
|
||||
->modalWidth('md'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,13 @@ namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlGlobalOverview;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Forms;
|
||||
use Filament\Resources\Pages\ManageRecords;
|
||||
use Illuminate\Support\HtmlString;
|
||||
|
||||
class ListShortUrls extends ManageRecords
|
||||
{
|
||||
@@ -21,7 +24,8 @@ class ListShortUrls extends ManageRecords
|
||||
->icon('heroicon-o-adjustments-horizontal')
|
||||
->color('gray')
|
||||
->size('sm')
|
||||
->url(static::getResource()::getUrl('settings')),
|
||||
->url(static::getResource()::getUrl('settings'))
|
||||
->visible(fn () => ShortUrlSettingsPage::canAccess()),
|
||||
|
||||
CreateAction::make()
|
||||
->icon('heroicon-o-plus')
|
||||
@@ -29,12 +33,363 @@ class ListShortUrls extends ManageRecords
|
||||
->color('primary')
|
||||
->modalWidth('4xl')
|
||||
->mutateFormDataUsing(function (array $data): array {
|
||||
// Auto-generate key if not provided
|
||||
if (empty($data['url_key'])) {
|
||||
$data['url_key'] = app(ShortUrlService::class)->generateKey();
|
||||
}
|
||||
|
||||
return $data;
|
||||
})
|
||||
->after(function (ShortUrl $record): void {
|
||||
$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('')
|
||||
->extraAttributes(['class' => 'hidden'])
|
||||
->modalHeading('')
|
||||
->modalWidth('lg')
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelAction(false)
|
||||
->form(function (array $arguments): array {
|
||||
$recordId = $arguments['id'] ?? null;
|
||||
$shortUrl = $arguments['shortUrl'] ?? '';
|
||||
$qrTargetUrl = $shortUrl ? ($shortUrl.'?source=qr') : '';
|
||||
$destHost = $arguments['destHost'] ?? '';
|
||||
$urlKey = $arguments['urlKey'] ?? 'qr-code';
|
||||
$encoded = urlencode($shortUrl);
|
||||
$eid = 'fsu_'.substr(md5($shortUrl), 0, 8);
|
||||
|
||||
$successTitle = __('filament-short-url::default.success_modal_title') ?? 'Your link & QR code are ready!';
|
||||
$successSubtitle = __('filament-short-url::default.success_modal_subtitle') ?? 'Time to get some clicks 🎉';
|
||||
$successHelper = __('filament-short-url::default.success_modal_helper') ?? 'Copy and share manually or choose a platform.';
|
||||
$downloadSvgText = __('filament-short-url::default.qr_download_svg') ?? 'Download SVG';
|
||||
$downloadPngText = __('filament-short-url::default.qr_download_png') ?? 'Download PNG';
|
||||
$closeButtonText = __('filament-short-url::default.close_button') ?? 'Close';
|
||||
$copyLinkText = __('filament-short-url::default.action_copy') ?? 'Copy link';
|
||||
$qrCodeText = __('filament-short-url::default.action_qr') ?? 'QR Code';
|
||||
$openLinkText = __('filament-short-url::default.open_link') ?? 'Open link';
|
||||
$dontShowAgainText = __('filament-short-url::default.dont_show_again') ?? "Don't show sharing options after creating a link";
|
||||
|
||||
$record = $recordId ? ShortUrl::find($recordId) : null;
|
||||
$qrDefaults = $record ? $record->getQrOptions() : config('filament-short-url.qr_defaults', []);
|
||||
$isGrad = ($qrDefaults['gradient_enabled'] ?? false) || (($qrDefaults['color_mode'] ?? '') === 'gradient');
|
||||
$dotStyle = $qrDefaults['dot_style'] ?? 'square';
|
||||
$fgColor = $qrDefaults['foreground_color'] ?? '#000000';
|
||||
$bgColor = ($qrDefaults['bg_transparent'] ?? false) ? 'rgba(0,0,0,0)' : ($qrDefaults['background_color'] ?? '#ffffff');
|
||||
|
||||
$dotsOptions = $isGrad ? [
|
||||
'type' => $dotStyle,
|
||||
'gradient' => [
|
||||
'type' => $qrDefaults['gradient_type'] ?? 'linear',
|
||||
'colorStops' => [
|
||||
['offset' => 0, 'color' => $qrDefaults['gradient_from'] ?? '#4f46e5'],
|
||||
['offset' => 1, 'color' => $qrDefaults['gradient_to'] ?? '#06b6d4'],
|
||||
],
|
||||
],
|
||||
] : [
|
||||
'type' => $dotStyle,
|
||||
'color' => $fgColor,
|
||||
];
|
||||
|
||||
$mainColor = $isGrad ? ($qrDefaults['gradient_from'] ?? '#4f46e5') : $fgColor;
|
||||
|
||||
$eyeConfigEnabled = $qrDefaults['eye_config_enabled'] ?? false;
|
||||
$eyeSquareStyle = $qrDefaults['eye_square_style'] ?? ($dotStyle === 'dots' ? 'dot' : 'square');
|
||||
$eyeDotStyle = $qrDefaults['eye_dot_style'] ?? ($dotStyle === 'dots' ? 'dot' : 'square');
|
||||
$eyeColor = $qrDefaults['eye_color'] ?? $mainColor;
|
||||
|
||||
$cornersSquareOptions = $eyeConfigEnabled ? [
|
||||
'type' => $eyeSquareStyle,
|
||||
'color' => $eyeColor,
|
||||
] : [
|
||||
'type' => $dotStyle === 'dots' ? 'dot' : 'square',
|
||||
'color' => $mainColor,
|
||||
];
|
||||
|
||||
$cornersDotOptions = $eyeConfigEnabled ? [
|
||||
'type' => $eyeDotStyle,
|
||||
'color' => $eyeColor,
|
||||
] : [
|
||||
'type' => $dotStyle === 'dots' ? 'dot' : 'square',
|
||||
'color' => $mainColor,
|
||||
];
|
||||
|
||||
$logo = $qrDefaults['logo'] ?? null;
|
||||
$logoSize = $qrDefaults['logo_size'] ?? 0.3;
|
||||
$logoMargin = $qrDefaults['logo_margin'] ?? 9;
|
||||
$logoHideBackground = $qrDefaults['logo_hide_background'] ?? true;
|
||||
$logoShape = $qrDefaults['logo_shape'] ?? 'square';
|
||||
|
||||
$qrOptionsJson = json_encode([
|
||||
'type' => 'svg',
|
||||
'width' => 200,
|
||||
'height' => 200,
|
||||
'margin' => $qrDefaults['margin'] ?? 1,
|
||||
'dotsOptions' => $dotsOptions,
|
||||
'backgroundOptions' => ['color' => $bgColor],
|
||||
'cornersSquareOptions' => $cornersSquareOptions,
|
||||
'cornersDotOptions' => $cornersDotOptions,
|
||||
'image' => $logo ?: null,
|
||||
'imageOptions' => [
|
||||
'crossOrigin' => 'anonymous',
|
||||
'hideBackgroundDots' => $logoHideBackground,
|
||||
'imageSize' => $logoSize,
|
||||
'margin' => $logoMargin,
|
||||
'logoShape' => $logoShape,
|
||||
],
|
||||
'qrOptions' => ['errorCorrectionLevel' => $logo ? 'H' : 'M'],
|
||||
]);
|
||||
|
||||
$escapedQrOptions = e($qrOptionsJson);
|
||||
|
||||
$html = <<<HTML
|
||||
<div x-data x-init="if(localStorage.getItem('fsu:hide-share-modal')==='1'){ \$nextTick(()=>\$wire.unmountAction()) }">
|
||||
|
||||
<!-- Close Button (x) -->
|
||||
<button type="button" x-on:click="event.preventDefault(); event.stopPropagation(); const f=\$el.closest('.fi-modal-window'); const m=f?(f.getAttribute('x-on:keydown.window.escape')||'').match(/'(fi-[^']*)'/):null; if(m){\$dispatch('close-modal',{id:m[1]})}else{\$wire.unmountAction()}"
|
||||
style="position:absolute;top:16px;right:16px;z-index:50;width:28px;height:28px;border-radius:50%;border:none;background:#f3f4f6;cursor:pointer;display:flex;align-items:center;justify-content:center;color:#6b7280;transition:color .15s,background-color .15s"
|
||||
onmouseover="this.style.color='#374151';this.style.backgroundColor='#e5e7eb'"
|
||||
onmouseout="this.style.color='#6b7280';this.style.backgroundColor='#f3f4f6'"
|
||||
title="{$closeButtonText}">
|
||||
<svg style="width:16px;height:16px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div style="text-align:center;padding:4px 0 8px">
|
||||
<p style="font-size:13px;color:#6b7280;margin:0 0 6px">{$successSubtitle}</p>
|
||||
<h2 style="font-size:23px;font-weight:700;color:#111827;margin:0 0 6px;line-height:1.25">{$successTitle}</h2>
|
||||
<p style="font-size:13px;color:#9ca3af;margin:0">{$successHelper}</p>
|
||||
</div>
|
||||
|
||||
<!-- URL pill -->
|
||||
<div style="display:flex;align-items:center;gap:10px;background:#EFF6FF;border-radius:999px;padding:10px 14px;margin:18px 0 0">
|
||||
<div style="width:30px;height:30px;border-radius:50%;background:#fff;display:flex;align-items:center;justify-content:center;flex-shrink:0;border:1px solid #e5e7eb">
|
||||
<img src="https://icons.duckduckgo.com/ip2/{$destHost}.ico" style="width:16px;height:16px;object-fit:contain" onerror="this.style.display='none'">
|
||||
</div>
|
||||
<span style="flex:1;font-size:14px;font-weight:600;color:#1d4ed8;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{$shortUrl}</span>
|
||||
<div style="display:flex;align-items:center;gap:4px;flex-shrink:0">
|
||||
<button id="{$eid}_copy" type="button"
|
||||
onclick="
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const u='{$shortUrl}';
|
||||
if(navigator.clipboard&&window.isSecureContext){navigator.clipboard.writeText(u);}
|
||||
else{const t=document.createElement('textarea');t.value=u;t.style.cssText='position:fixed;left:-9999px';document.body.appendChild(t);t.select();document.execCommand('copy');t.remove();}
|
||||
const b=document.getElementById('{$eid}_copy');
|
||||
const prev=b.innerHTML;
|
||||
b.innerHTML='<svg style=\'width:16px;height:16px;color:#16a34a\' fill=\'none\' viewBox=\'0 0 24 24\' stroke=\'currentColor\' stroke-width=\'2\'><path stroke-linecap=\'round\' stroke-linejoin=\'round\' d=\'M5 13l4 4L19 7\'/></svg>';
|
||||
setTimeout(()=>b.innerHTML=prev,1800);
|
||||
"
|
||||
style="width:30px;height:30px;border-radius:7px;border:1px solid #e5e7eb;background:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .15s"
|
||||
onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'" title="{$copyLinkText}">
|
||||
<svg style="width:16px;height:16px;color:#6b7280" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path stroke-linecap="round" stroke-linejoin="round" d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
|
||||
</button>
|
||||
<button id="{$eid}_qr_btn" type="button" data-qr-options="{$escapedQrOptions}"
|
||||
onclick="
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const container = document.getElementById('{$eid}_qr_container');
|
||||
const canvas = document.getElementById('{$eid}_qr_canvas');
|
||||
if (container.style.display === 'none') {
|
||||
container.style.display = 'block';
|
||||
const loadScript = () => {
|
||||
return new Promise((resolve) => {
|
||||
if (window.QRCodeStyling) return resolve();
|
||||
const s = document.createElement('script');
|
||||
s.src = 'https://unpkg.com/qr-code-styling@1.6.0-rc.1/lib/qr-code-styling.js';
|
||||
s.onload = () => resolve();
|
||||
s.onerror = () => {
|
||||
const s2 = document.createElement('script');
|
||||
s2.src = 'https://cdn.jsdelivr.net/npm/qr-code-styling@1.6.0-rc.1/lib/qr-code-styling.js';
|
||||
s2.onload = () => resolve();
|
||||
document.head.appendChild(s2);
|
||||
};
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
};
|
||||
loadScript().then(() => {
|
||||
if (!canvas.innerHTML) {
|
||||
const opts = JSON.parse(this.getAttribute('data-qr-options'));
|
||||
opts.data = '{$qrTargetUrl}';
|
||||
const fixSvg = () => {
|
||||
const svg = canvas.querySelector('svg');
|
||||
if (svg) {
|
||||
const w = svg.getAttribute('width') || opts.width || 200;
|
||||
const h = svg.getAttribute('height') || opts.height || 200;
|
||||
svg.setAttribute('viewBox', '0 0 ' + w + ' ' + h);
|
||||
svg.style.width = '100%';
|
||||
svg.style.height = '100%';
|
||||
}
|
||||
};
|
||||
|
||||
if (opts.image && opts.imageOptions) {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = 1200;
|
||||
cv.height = 1200;
|
||||
const cx = cv.getContext('2d');
|
||||
cx.imageSmoothingEnabled = true;
|
||||
cx.imageSmoothingQuality = 'high';
|
||||
|
||||
const isCircle = opts.imageOptions.logoShape === 'circle';
|
||||
const targetDim = 1200 - (parseFloat(opts.imageOptions.margin || 0) * 20);
|
||||
|
||||
cx.save();
|
||||
if (isCircle) {
|
||||
cx.beginPath();
|
||||
cx.arc(600, 600, targetDim / 2, 0, 2 * Math.PI);
|
||||
cx.clip();
|
||||
|
||||
const scale = Math.max(targetDim / img.width, targetDim / img.height);
|
||||
const w = img.width * scale;
|
||||
const h = img.height * scale;
|
||||
const x = (1200 - w) / 2;
|
||||
const y = (1200 - h) / 2;
|
||||
|
||||
cx.drawImage(img, x, y, w, h);
|
||||
} else {
|
||||
cx.beginPath();
|
||||
const offset = (1200 - targetDim) / 2;
|
||||
const radius = 144 * (targetDim / 1200);
|
||||
|
||||
if (typeof cx.roundRect === 'function') {
|
||||
cx.roundRect(offset, offset, targetDim, targetDim, radius);
|
||||
} else {
|
||||
cx.moveTo(offset + radius, offset);
|
||||
cx.lineTo(offset + targetDim - radius, offset);
|
||||
cx.quadraticCurveTo(offset + targetDim, offset, offset + targetDim, offset + radius);
|
||||
cx.lineTo(offset + targetDim, offset + targetDim - radius);
|
||||
cx.quadraticCurveTo(offset + targetDim, offset + targetDim, offset + targetDim - radius, offset + targetDim);
|
||||
cx.lineTo(offset + radius, offset + targetDim);
|
||||
cx.quadraticCurveTo(offset, offset + targetDim, offset, offset + targetDim - radius);
|
||||
cx.lineTo(offset, offset + radius);
|
||||
cx.quadraticCurveTo(offset, offset, offset + radius, offset);
|
||||
cx.closePath();
|
||||
}
|
||||
cx.clip();
|
||||
|
||||
const scale = Math.max(targetDim / img.width, targetDim / img.height);
|
||||
const w = img.width * scale;
|
||||
const h = img.height * scale;
|
||||
const x = (1200 - w) / 2;
|
||||
const y = (1200 - h) / 2;
|
||||
|
||||
cx.drawImage(img, x, y, w, h);
|
||||
}
|
||||
cx.restore();
|
||||
|
||||
opts.image = cv.toDataURL('image/png');
|
||||
opts.imageOptions.margin = 0;
|
||||
|
||||
|
||||
window['qr_{$eid}'] = new window.QRCodeStyling(opts);
|
||||
window['qr_{$eid}'].append(canvas);
|
||||
fixSvg();
|
||||
};
|
||||
img.src = opts.image;
|
||||
} else {
|
||||
window['qr_{$eid}'] = new window.QRCodeStyling(opts);
|
||||
window['qr_{$eid}'].append(canvas);
|
||||
fixSvg();
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
container.style.display = 'none';
|
||||
}
|
||||
"
|
||||
style="width:30px;height:30px;border-radius:7px;border:1px solid #e5e7eb;background:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .15s"
|
||||
onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'" title="{$qrCodeText}">
|
||||
<svg style="width:16px;height:16px;color:#6b7280" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0 1 3.75 9.375v-4.5ZM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 0 1-1.125-1.125v-4.5ZM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0 1 13.5 9.375v-4.5Z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12h.008v.008H15V12Zm0 3h.008v.008H15V15Zm0 3h.008v.008H15V18Zm3-3h.008v.008H18V15Zm0 3h.008v.008H18V18Zm3-3h.008v.008H21V15Zm0 3h.008v.008H21V18Zm0-6h.008v.008H21V12Zm-3 0h.008v.008H18V12Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<a href="{$shortUrl}" target="_blank" rel="noopener noreferrer"
|
||||
style="width:30px;height:30px;border-radius:7px;border:1px solid #e5e7eb;background:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;text-decoration:none;transition:background .15s"
|
||||
onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'" title="{$openLinkText}">
|
||||
<svg style="width:15px;height:15px;color:#6b7280" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QR Code Container (toggled) -->
|
||||
<div id="{$eid}_qr_container" style="display:none;margin:16px 0 0;text-align:center;background:#f9fafb;border:1px solid #e5e7eb;border-radius:12px;padding:16px">
|
||||
<div style="display:flex;justify-content:center;margin-bottom:12px">
|
||||
<div id="{$eid}_qr_canvas" style="background:#fff;padding:8px;border-radius:8px;box-shadow:0 1px 3px rgba(0,0,0,0.05)"></div>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:center;gap:8px">
|
||||
<button type="button" onclick="event.preventDefault();event.stopPropagation();window['qr_{$eid}']?.download({ name: '{$urlKey}-qr', extension: 'svg' })" style="font-size:12px;font-weight:600;color:#374151;border:1.5px solid #e5e7eb;background:#fff;padding:6px 12px;border-radius:8px;cursor:pointer;transition:background .15s" onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'">{$downloadSvgText}</button>
|
||||
<button type="button" onclick="event.preventDefault();event.stopPropagation();window['qr_{$eid}']?.download({ name: '{$urlKey}-qr', extension: 'png' })" style="font-size:12px;font-weight:600;color:#374151;border:1.5px solid #e5e7eb;background:#fff;padding:6px 12px;border-radius:8px;cursor:pointer;transition:background .15s" onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'">{$downloadPngText}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Social sharing -->
|
||||
<div style="display:flex;align-items:center;justify-content:center;gap:8px;margin:18px 0 0;flex-wrap:wrap">
|
||||
<a href="mailto:?body={$encoded}" target="_blank" rel="noopener" title="Email"
|
||||
style="width:52px;height:52px;border-radius:12px;border:1.5px solid #e5e7eb;background:#fff;display:flex;align-items:center;justify-content:center;text-decoration:none;transition:border-color .15s,box-shadow .15s" onmouseover="this.style.borderColor='#d1d5db';this.style.boxShadow='0 2px 8px rgba(0,0,0,0.07)'" onmouseout="this.style.borderColor='#e5e7eb';this.style.boxShadow='none'">
|
||||
<svg style="width:22px;height:22px;color:#374151" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
|
||||
</a>
|
||||
<a href="https://www.linkedin.com/sharing/share-offsite/?url={$encoded}" target="_blank" rel="noopener" title="LinkedIn"
|
||||
style="width:52px;height:52px;border-radius:12px;border:1.5px solid #e5e7eb;background:#fff;display:flex;align-items:center;justify-content:center;text-decoration:none;transition:border-color .15s,box-shadow .15s" onmouseover="this.style.borderColor='#d1d5db';this.style.boxShadow='0 2px 8px rgba(0,0,0,0.07)'" onmouseout="this.style.borderColor='#e5e7eb';this.style.boxShadow='none'">
|
||||
<svg style="width:22px;height:22px;color:#0077b5" fill="currentColor" viewBox="0 0 24 24"><path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.779-1.75-1.75s.784-1.75 1.75-1.75 1.75.779 1.75 1.75-.784 1.75-1.75 1.75zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z"/></svg>
|
||||
</a>
|
||||
<a href="https://www.facebook.com/sharer/sharer.php?u={$encoded}" target="_blank" rel="noopener" title="Facebook"
|
||||
style="width:52px;height:52px;border-radius:12px;border:1.5px solid #e5e7eb;background:#fff;display:flex;align-items:center;justify-content:center;text-decoration:none;transition:border-color .15s,box-shadow .15s" onmouseover="this.style.borderColor='#d1d5db';this.style.boxShadow='0 2px 8px rgba(0,0,0,0.07)'" onmouseout="this.style.borderColor='#e5e7eb';this.style.boxShadow='none'">
|
||||
<svg style="width:22px;height:22px;color:#1877f2" fill="currentColor" viewBox="0 0 24 24"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
|
||||
</a>
|
||||
<a href="https://twitter.com/intent/tweet?url={$encoded}" target="_blank" rel="noopener" title="X (Twitter)"
|
||||
style="width:52px;height:52px;border-radius:12px;border:1.5px solid #e5e7eb;background:#fff;display:flex;align-items:center;justify-content:center;text-decoration:none;transition:border-color .15s,box-shadow .15s" onmouseover="this.style.borderColor='#d1d5db';this.style.boxShadow='0 2px 8px rgba(0,0,0,0.07)'" onmouseout="this.style.borderColor='#e5e7eb';this.style.boxShadow='none'">
|
||||
<svg style="width:19px;height:19px;color:#0f1419" fill="currentColor" viewBox="0 0 24 24"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
|
||||
</a>
|
||||
<a href="https://api.whatsapp.com/send?text={$encoded}" target="_blank" rel="noopener" title="WhatsApp"
|
||||
style="width:52px;height:52px;border-radius:12px;border:1.5px solid #e5e7eb;background:#fff;display:flex;align-items:center;justify-content:center;text-decoration:none;transition:border-color .15s,box-shadow .15s" onmouseover="this.style.borderColor='#d1d5db';this.style.boxShadow='0 2px 8px rgba(0,0,0,0.07)'" onmouseout="this.style.borderColor='#e5e7eb';this.style.boxShadow='none'">
|
||||
<svg style="width:22px;height:22px;color:#25d366" fill="currentColor" viewBox="0 0 24 24"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L0 24l6.335-1.662c1.746.953 3.71 1.458 5.704 1.459h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/></svg>
|
||||
</a>
|
||||
<a href="https://t.me/share/url?url={$encoded}" target="_blank" rel="noopener" title="Telegram"
|
||||
style="width:52px;height:52px;border-radius:12px;border:1.5px solid #e5e7eb;background:#fff;display:flex;align-items:center;justify-content:center;text-decoration:none;transition:border-color .15s,box-shadow .15s" onmouseover="this.style.borderColor='#d1d5db';this.style.boxShadow='0 2px 8px rgba(0,0,0,0.07)'" onmouseout="this.style.borderColor='#e5e7eb';this.style.boxShadow='none'">
|
||||
<svg style="width:22px;height:22px;color:#229ED9" fill="currentColor" viewBox="0 0 24 24"><path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Don't show again -->
|
||||
<div style="display:flex;justify-content:center;margin:20px 0 2px">
|
||||
<button type="button"
|
||||
x-on:click="localStorage.setItem('fsu:hide-share-modal', '1'); const f=\$el.closest('.fi-modal-window'); const m=f?(f.getAttribute('x-on:keydown.window.escape')||'').match(/'(fi-[^']*)'/):null; if(m){\$dispatch('close-modal',{id:m[1]})}else{\$wire.unmountAction()}"
|
||||
style="font-size:12px;color:#9ca3af;background:none;border:none;cursor:pointer;text-decoration:underline;transition:color .15s"
|
||||
onmouseover="this.style.color='#4b5563'"
|
||||
onmouseout="this.style.color='#9ca3af'">
|
||||
{$dontShowAgainText}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
HTML;
|
||||
|
||||
return [
|
||||
Forms\Components\Placeholder::make('share_modal_content')
|
||||
->hiddenLabel()
|
||||
->content(new HtmlString($html)),
|
||||
];
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -3,10 +3,13 @@
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
|
||||
use Bjanczak\FilamentShortUrl\FilamentShortUrlPlugin;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Services\SafeBrowsingService;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlSettingsManager;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\ColorPicker;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Forms\Components\Repeater;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
@@ -21,7 +24,9 @@ use Filament\Schemas\Components\Tabs;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\HtmlString;
|
||||
|
||||
class ShortUrlSettingsPage extends Page implements HasForms
|
||||
{
|
||||
@@ -31,6 +36,28 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
|
||||
protected string $view = 'filament-short-url::settings';
|
||||
|
||||
public static function canAccess(array $parameters = []): bool
|
||||
{
|
||||
try {
|
||||
$plugin = FilamentShortUrlPlugin::get();
|
||||
|
||||
if ($callback = $plugin->getAuthorizeSettingsUsing()) {
|
||||
return (bool) app()->call($callback);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Ignore if plugin is not registered yet in some contexts
|
||||
}
|
||||
|
||||
// Fallback: Check if there's a Model Policy with `manageSettings` method
|
||||
if (Gate::getPolicyFor(ShortUrl::class) &&
|
||||
method_exists(Gate::getPolicyFor(ShortUrl::class), 'manageSettings')) {
|
||||
return Gate::allows('manageSettings', ShortUrl::class);
|
||||
}
|
||||
|
||||
// Default fallback: Check if the user is authorized to view the resource in general
|
||||
return static::getResource()::canViewAny();
|
||||
}
|
||||
|
||||
public ?array $data = [];
|
||||
|
||||
public function mount(): void
|
||||
@@ -67,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'),
|
||||
@@ -78,6 +106,7 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
'qr_gradient_type' => $mgr->get('qr_gradient_type', 'linear'),
|
||||
'global_webhook_url' => $mgr->get('global_webhook_url'),
|
||||
'webhook_events' => $mgr->get('webhook_events', ['visited']),
|
||||
'global_webhook_enabled' => $mgr->get('global_webhook_enabled', false),
|
||||
'api_keys' => $mgr->get('api_keys', []),
|
||||
'api_enabled' => $mgr->get('api_enabled', false),
|
||||
'site_name' => $mgr->get('site_name'),
|
||||
@@ -116,12 +145,14 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
TextInput::make('route_prefix')
|
||||
->label(__('filament-short-url::default.settings_route_prefix'))
|
||||
->helperText(__('filament-short-url::default.settings_route_prefix_helper'))
|
||||
->required()
|
||||
->prefix(new HtmlString('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" style="display: inline-block; vertical-align: middle; margin-right: 6px; margin-top: -3px; width: 15px; height: 15px;" class="text-emerald-600 dark:text-emerald-500"><path fill-rule="evenodd" d="M10 1a4.5 4.5 0 00-4.5 4.5V9H5a2 2 0 00-2 2v7a2 2 0 00 2 2h10a2 2 0 00 2-2v-7a2 2 0 00-2-2h-.5V5.5A4.5 4.5 0 00 10 1zm3 8V5.5a3 3 0 10-6 0V9h6z" clip-rule="evenodd" /></svg>https://'.request()->getHost().'/'))
|
||||
->nullable()
|
||||
->alphaDash()
|
||||
->maxLength(20),
|
||||
|
||||
Select::make('redirect_status_code')
|
||||
->label(__('filament-short-url::default.redirect_code'))
|
||||
->helperText(__('filament-short-url::default.settings_redirect_code_helper'))
|
||||
->options([
|
||||
302 => __('filament-short-url::default.redirect_code_302'),
|
||||
301 => __('filament-short-url::default.redirect_code_301'),
|
||||
@@ -131,10 +162,12 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
TextInput::make('key_length')
|
||||
->label(__('filament-short-url::default.settings_key_length'))
|
||||
->helperText(__('filament-short-url::default.settings_key_length_helper'))
|
||||
->numeric()
|
||||
->integer()
|
||||
->minValue(4)
|
||||
->maxValue(20)
|
||||
->rules(['required', 'integer', 'between:3,20'])
|
||||
->extraInputAttributes([
|
||||
'maxlength' => 2,
|
||||
'oninput' => "this.value = this.value.replace(/[^0-9]/g, ''); if(this.value !== '') { let val = parseInt(this.value); if(val > 20) this.value = 20; }",
|
||||
'onblur' => "if(this.value !== '') { let val = parseInt(this.value); if(val < 3) this.value = 3; if(val > 20) this.value = 20; }",
|
||||
])
|
||||
->required(),
|
||||
|
||||
TextInput::make('cache_ttl')
|
||||
@@ -150,7 +183,17 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
->label(__('filament-short-url::default.settings_trust_cdn_headers'))
|
||||
->helperText(__('filament-short-url::default.settings_trust_cdn_headers_helper'))
|
||||
->columnSpanFull()
|
||||
->inline(false),
|
||||
->inline(false)
|
||||
->live(),
|
||||
|
||||
Placeholder::make('trust_cdn_headers_info')
|
||||
->content(function () {
|
||||
$html = __('filament-short-url::default.settings_trust_cdn_headers_info_callout');
|
||||
|
||||
return new HtmlString($html);
|
||||
})
|
||||
->visible(fn (Get $get): bool => (bool) $get('trust_cdn_headers'))
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
|
||||
Section::make(__('filament-short-url::default.settings_section_queue'))
|
||||
@@ -168,12 +211,25 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
'redis' => 'redis',
|
||||
];
|
||||
})
|
||||
->required(),
|
||||
->required()
|
||||
->live(),
|
||||
|
||||
TextInput::make('queue_name')
|
||||
->label(__('filament-short-url::default.settings_queue_name'))
|
||||
->helperText(__('filament-short-url::default.settings_queue_name_helper'))
|
||||
->required(),
|
||||
->default('default')
|
||||
->required(fn (Get $get): bool => $get('queue_connection') !== 'sync')
|
||||
->visible(fn (Get $get): bool => $get('queue_connection') !== 'sync'),
|
||||
|
||||
Placeholder::make('queue_worker_info')
|
||||
->content(function (Get $get) {
|
||||
$queueName = $get('queue_name') ?: 'default';
|
||||
$html = __('filament-short-url::default.settings_queue_worker_info', ['queue' => $queueName]);
|
||||
|
||||
return new HtmlString($html);
|
||||
})
|
||||
->visible(fn (Get $get): bool => $get('queue_connection') !== 'sync')
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
|
||||
Section::make(__('filament-short-url::default.settings_section_buffering'))
|
||||
@@ -181,7 +237,17 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
Toggle::make('counter_buffering_enabled')
|
||||
->label(__('filament-short-url::default.settings_buffering_enabled'))
|
||||
->helperText(__('filament-short-url::default.settings_buffering_helper'))
|
||||
->inline(false),
|
||||
->inline(false)
|
||||
->live(),
|
||||
|
||||
Placeholder::make('counter_buffering_info')
|
||||
->content(function () {
|
||||
$html = __('filament-short-url::default.settings_buffering_worker_info');
|
||||
|
||||
return new HtmlString($html);
|
||||
})
|
||||
->visible(fn (Get $get): bool => (bool) $get('counter_buffering_enabled'))
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
]),
|
||||
|
||||
@@ -212,6 +278,18 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
->live()
|
||||
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled')),
|
||||
|
||||
Placeholder::make('geoip_headers_warning')
|
||||
->content(function () {
|
||||
$html = __('filament-short-url::default.settings_geoip_headers_warning');
|
||||
|
||||
return new HtmlString($html);
|
||||
})
|
||||
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled') &&
|
||||
$get('geo_ip_driver') === 'headers' &&
|
||||
! (bool) $get('trust_cdn_headers')
|
||||
)
|
||||
->columnSpanFull(),
|
||||
|
||||
// ── Cache TTL (only when geo-ip is on) ──
|
||||
TextInput::make('geo_ip_cache_ttl')
|
||||
->label(__('filament-short-url::default.settings_geoip_cache_ttl'))
|
||||
@@ -219,6 +297,7 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
->numeric()
|
||||
->integer()
|
||||
->minValue(0)
|
||||
->maxValue(31536000)
|
||||
->suffix('s')
|
||||
->required()
|
||||
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled')),
|
||||
@@ -230,6 +309,7 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
->numeric()
|
||||
->integer()
|
||||
->minValue(0)
|
||||
->maxValue(86400)
|
||||
->suffix('s')
|
||||
->required()
|
||||
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled')),
|
||||
@@ -247,43 +327,55 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled') && $get('geo_ip_driver') === 'ip-api'),
|
||||
|
||||
// ── MaxMind path (only for maxmind driver) ──
|
||||
Placeholder::make('maxmind_info')
|
||||
->content(function () {
|
||||
$html = __('filament-short-url::default.settings_maxmind_info_callout');
|
||||
|
||||
return new HtmlString($html);
|
||||
})
|
||||
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled') && $get('geo_ip_driver') === 'maxmind')
|
||||
->columnSpanFull(),
|
||||
|
||||
TextInput::make('maxmind_database_path')
|
||||
->label(__('filament-short-url::default.settings_maxmind_path'))
|
||||
->helperText(__('filament-short-url::default.settings_maxmind_path_helper'))
|
||||
->columnSpanFull()
|
||||
->placeholder('/var/www/html/database/geoip/GeoLite2-Country.mmdb')
|
||||
->suffixAction(
|
||||
Action::make('verifyMaxmindPath')
|
||||
->label(__('filament-short-url::default.settings_maxmind_verify'))
|
||||
->icon('heroicon-o-server')
|
||||
->action(function (?string $state): void {
|
||||
$path = trim($state ?? '');
|
||||
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled') && $get('geo_ip_driver') === 'maxmind'),
|
||||
|
||||
if (empty($path)) {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_maxmind_verify_empty'))
|
||||
->warning()
|
||||
->send();
|
||||
Actions::make([
|
||||
Action::make('verifyMaxmindPath')
|
||||
->label(__('filament-short-url::default.settings_maxmind_verify'))
|
||||
->icon('heroicon-o-check-circle')
|
||||
->color('gray')
|
||||
->action(function (Get $get): void {
|
||||
$path = trim($get('maxmind_database_path') ?? '');
|
||||
|
||||
return;
|
||||
}
|
||||
if (empty($path)) {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_maxmind_verify_empty'))
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
if (file_exists($path) && is_readable($path) && str_ends_with($path, '.mmdb')) {
|
||||
$sizeKb = round(filesize($path) / 1024);
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_maxmind_verify_ok'))
|
||||
->body("{$path} ({$sizeKb} KB)")
|
||||
->success()
|
||||
->send();
|
||||
} else {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_maxmind_verify_fail'))
|
||||
->body($path)
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
})
|
||||
)
|
||||
return;
|
||||
}
|
||||
|
||||
if (file_exists($path) && is_readable($path) && str_ends_with($path, '.mmdb')) {
|
||||
$sizeKb = round(filesize($path) / 1024);
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_maxmind_verify_ok'))
|
||||
->body("{$path} ({$sizeKb} KB)")
|
||||
->success()
|
||||
->send();
|
||||
} else {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_maxmind_verify_fail'))
|
||||
->body($path)
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
}),
|
||||
])
|
||||
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled') && $get('geo_ip_driver') === 'maxmind'),
|
||||
]),
|
||||
]),
|
||||
@@ -563,6 +655,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)
|
||||
@@ -691,12 +788,25 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
|
||||
Section::make(__('filament-short-url::default.settings_section_global_webhook'))
|
||||
->schema([
|
||||
Toggle::make('global_webhook_enabled')
|
||||
->label(__('filament-short-url::default.settings_global_webhook_enabled'))
|
||||
->helperText(__('filament-short-url::default.settings_global_webhook_enabled_helper'))
|
||||
->live()
|
||||
->inline(false)
|
||||
->afterStateUpdated(function (bool $state, $set) {
|
||||
if (! $state) {
|
||||
$set('global_webhook_url', null);
|
||||
$set('webhook_events', ['visited']);
|
||||
}
|
||||
}),
|
||||
|
||||
TextInput::make('global_webhook_url')
|
||||
->label(__('filament-short-url::default.settings_global_webhook_url'))
|
||||
->helperText(__('filament-short-url::default.settings_global_webhook_url_helper'))
|
||||
->url()
|
||||
->nullable()
|
||||
->columnSpanFull(),
|
||||
->columnSpanFull()
|
||||
->visible(fn (Get $get): bool => (bool) $get('global_webhook_enabled')),
|
||||
|
||||
Select::make('webhook_events')
|
||||
->label(__('filament-short-url::default.settings_webhook_events'))
|
||||
@@ -709,7 +819,8 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
'limit_reached' => __('filament-short-url::default.webhook_event_limit_reached'),
|
||||
])
|
||||
->default(['visited'])
|
||||
->columnSpanFull(),
|
||||
->columnSpanFull()
|
||||
->visible(fn (Get $get): bool => (bool) $get('global_webhook_enabled')),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
|
||||
@@ -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')
|
||||
@@ -410,6 +432,7 @@ class ShortUrlForm
|
||||
'none' => __('filament-short-url::default.targeting_type_none'),
|
||||
'device' => __('filament-short-url::default.targeting_type_device'),
|
||||
'geo' => __('filament-short-url::default.targeting_type_country'),
|
||||
'language' => __('filament-short-url::default.targeting_type_language'),
|
||||
'rotation' => __('filament-short-url::default.targeting_type_rotation'),
|
||||
])
|
||||
->default('none')
|
||||
@@ -465,6 +488,33 @@ class ShortUrlForm
|
||||
->columns(2)
|
||||
->visible(fn (Get $get): bool => $get('targeting_rules.type') === 'geo'),
|
||||
|
||||
Repeater::make('targeting_rules.language')
|
||||
->label(__('filament-short-url::default.language_targeting_rules'))
|
||||
->schema([
|
||||
Select::make('language_code')
|
||||
->label(__('filament-short-url::default.language_code'))
|
||||
->options(function (): array {
|
||||
$languages = __('filament-short-url::languages');
|
||||
if (is_array($languages)) {
|
||||
asort($languages, SORT_LOCALE_STRING);
|
||||
|
||||
return $languages;
|
||||
}
|
||||
|
||||
return [];
|
||||
})
|
||||
->searchable()
|
||||
->disableOptionsWhenSelectedInSiblingRepeaterItems()
|
||||
->required(),
|
||||
TextInput::make('url')
|
||||
->label(__('filament-short-url::default.destination_url'))
|
||||
->url()
|
||||
->required()
|
||||
->maxLength(2048),
|
||||
])
|
||||
->columns(2)
|
||||
->visible(fn (Get $get): bool => $get('targeting_rules.type') === 'language'),
|
||||
|
||||
Repeater::make('targeting_rules.rotation')
|
||||
->label(__('filament-short-url::default.rotation_targeting_rules'))
|
||||
->schema([
|
||||
@@ -497,22 +547,14 @@ class ShortUrlForm
|
||||
Section::make(__('filament-short-url::default.marketing_pixels_title'))
|
||||
->description(__('filament-short-url::default.marketing_pixels_desc'))
|
||||
->schema([
|
||||
TextInput::make('pixel_meta_id')
|
||||
->label(__('filament-short-url::default.pixel_meta'))
|
||||
->placeholder('e.g., 1234567890')
|
||||
->maxLength(100)
|
||||
->nullable(),
|
||||
TextInput::make('pixel_google_id')
|
||||
->label(__('filament-short-url::default.pixel_google'))
|
||||
->placeholder('e.g., G-XXXXXXXXXX or AW-XXXXXXXXXX')
|
||||
->maxLength(100)
|
||||
->nullable(),
|
||||
TextInput::make('pixel_linkedin_id')
|
||||
->label(__('filament-short-url::default.pixel_linkedin'))
|
||||
->placeholder('e.g., 1234567')
|
||||
->maxLength(100)
|
||||
->nullable(),
|
||||
])->columns(3),
|
||||
Select::make('pixels')
|
||||
->label(__('filament-short-url::default.pixels_navigation_label') ?? 'Retargeting Pixels')
|
||||
->multiple()
|
||||
->relationship('pixels', 'name', modifyQueryUsing: fn ($query) => $query->where('is_active', true))
|
||||
->preload()
|
||||
->searchable()
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
|
||||
Section::make(__('filament-short-url::default.marketing_webhooks_title'))
|
||||
->description(__('filament-short-url::default.marketing_webhooks_desc'))
|
||||
|
||||
@@ -121,7 +121,7 @@ HTML
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
||||
</svg>
|
||||
'.$record->total_visits.' clicks
|
||||
'.$record->total_visits.' '.__('filament-short-url::default.badge_clicks').'
|
||||
</span>
|
||||
|
||||
<!-- Unique Clicks Badge -->
|
||||
@@ -129,7 +129,16 @@ HTML
|
||||
<svg class="w-3.5 h-3.5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
'.$record->unique_visits.' unique
|
||||
'.$record->unique_visits.' '.__('filament-short-url::default.badge_unique').'
|
||||
</span>
|
||||
|
||||
<!-- QR Scans Badge -->
|
||||
<span class="inline-flex items-center gap-1 bg-[#f4f4f5] dark:bg-gray-800 px-2 py-1 rounded text-[11px] font-medium text-gray-600 dark:text-gray-300">
|
||||
<svg class="w-3.5 h-3.5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0 1 3.75 9.375v-4.5ZM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 0 1-1.125-1.125v-4.5ZM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0 1 13.5 9.375v-4.5Z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12h.008v.008H15V12Zm0 3h.008v.008H15V15Zm0 3h.008v.008H15V18Zm3-3h.008v.008H18V15Zm0 3h.008v.008H18V18Zm3-3h.008v.008H21V15Zm0 3h.008v.008H21V18Zm0-6h.008v.008H21V12Zm-3 0h.008v.008H18V12Z" />
|
||||
</svg>
|
||||
'.$record->qr_scans.' '.__('filament-short-url::default.badge_qr_scans').'
|
||||
</span>
|
||||
|
||||
<!-- Date Added Badge -->
|
||||
@@ -137,7 +146,7 @@ HTML
|
||||
<svg class="w-3.5 h-3.5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
'.$record->created_at->format('M d, Y').'
|
||||
'.$record->created_at->translatedFormat('M d, Y').'
|
||||
</span>
|
||||
|
||||
<!-- Expiry / Single Use Badge -->
|
||||
@@ -146,12 +155,12 @@ HTML
|
||||
<svg class="w-3.5 h-3.5 text-yellow-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Expires: '.$record->expires_at->format('M d, Y').'
|
||||
'.__('filament-short-url::default.badge_expires', ['date' => $record->expires_at->translatedFormat('M d, Y')]).'
|
||||
' : '
|
||||
<svg class="w-3.5 h-3.5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
No expiry
|
||||
'.__('filament-short-url::default.badge_no_expiry').'
|
||||
').'
|
||||
</span>
|
||||
|
||||
@@ -160,7 +169,7 @@ HTML
|
||||
<svg class="w-3.5 h-3.5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
||||
</svg>
|
||||
'.$record->redirect_status_code.' redirect
|
||||
'.__('filament-short-url::default.badge_redirect', ['code' => $record->redirect_status_code]).'
|
||||
</span>
|
||||
</div>
|
||||
')),
|
||||
@@ -294,6 +303,284 @@ HTML
|
||||
')),
|
||||
]),
|
||||
|
||||
Action::make('qrCode')
|
||||
->label(__('filament-short-url::default.action_qr') ?? 'QR Code')
|
||||
->icon('heroicon-o-qr-code')
|
||||
->color('gray')
|
||||
->iconButton()
|
||||
->tooltip(__('filament-short-url::default.action_qr') ?? 'QR Code')
|
||||
->modalWidth('md')
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelAction(false)
|
||||
->form(function (ShortUrl $record): array {
|
||||
$shortUrl = $record->getShortUrl();
|
||||
$qrTargetUrl = $shortUrl.'?source=qr';
|
||||
$destHost = parse_url($record->destination_url, PHP_URL_HOST) ?? '';
|
||||
$urlKey = $record->url_key;
|
||||
$eid = 'fsu_'.substr(md5($shortUrl), 0, 8);
|
||||
|
||||
$qrHelperText = __('filament-short-url::default.qr_modal_helper') ?? 'Scan, copy, or download your custom QR code.';
|
||||
$downloadSvgText = __('filament-short-url::default.qr_download_svg') ?? 'Download SVG';
|
||||
$downloadPngText = __('filament-short-url::default.qr_download_png') ?? 'Download PNG';
|
||||
$closeButtonText = __('filament-short-url::default.close_button') ?? 'Close';
|
||||
$copyLinkText = __('filament-short-url::default.action_copy') ?? 'Copy link';
|
||||
$openLinkText = __('filament-short-url::default.open_link') ?? 'Open link';
|
||||
|
||||
$qrDefaults = $record->getQrOptions();
|
||||
$isGrad = ($qrDefaults['gradient_enabled'] ?? false) || (($qrDefaults['color_mode'] ?? '') === 'gradient');
|
||||
$dotStyle = $qrDefaults['dot_style'] ?? 'square';
|
||||
$fgColor = $qrDefaults['foreground_color'] ?? '#000000';
|
||||
$bgColor = ($qrDefaults['bg_transparent'] ?? false) ? 'rgba(0,0,0,0)' : ($qrDefaults['background_color'] ?? '#ffffff');
|
||||
|
||||
$dotsOptions = $isGrad ? [
|
||||
'type' => $dotStyle,
|
||||
'gradient' => [
|
||||
'type' => $qrDefaults['gradient_type'] ?? 'linear',
|
||||
'colorStops' => [
|
||||
['offset' => 0, 'color' => $qrDefaults['gradient_from'] ?? '#4f46e5'],
|
||||
['offset' => 1, 'color' => $qrDefaults['gradient_to'] ?? '#06b6d4'],
|
||||
],
|
||||
],
|
||||
] : [
|
||||
'type' => $dotStyle,
|
||||
'color' => $fgColor,
|
||||
];
|
||||
|
||||
$mainColor = $isGrad ? ($qrDefaults['gradient_from'] ?? '#4f46e5') : $fgColor;
|
||||
|
||||
$eyeConfigEnabled = $qrDefaults['eye_config_enabled'] ?? false;
|
||||
$eyeSquareStyle = $qrDefaults['eye_square_style'] ?? ($dotStyle === 'dots' ? 'dot' : 'square');
|
||||
$eyeDotStyle = $qrDefaults['eye_dot_style'] ?? ($dotStyle === 'dots' ? 'dot' : 'square');
|
||||
$eyeColor = $qrDefaults['eye_color'] ?? $mainColor;
|
||||
|
||||
$cornersSquareOptions = $eyeConfigEnabled ? [
|
||||
'type' => $eyeSquareStyle,
|
||||
'color' => $eyeColor,
|
||||
] : [
|
||||
'type' => $dotStyle === 'dots' ? 'dot' : 'square',
|
||||
'color' => $mainColor,
|
||||
];
|
||||
|
||||
$cornersDotOptions = $eyeConfigEnabled ? [
|
||||
'type' => $eyeDotStyle,
|
||||
'color' => $eyeColor,
|
||||
] : [
|
||||
'type' => $dotStyle === 'dots' ? 'dot' : 'square',
|
||||
'color' => $mainColor,
|
||||
];
|
||||
|
||||
$logo = $qrDefaults['logo'] ?? null;
|
||||
$logoSize = $qrDefaults['logo_size'] ?? 0.3;
|
||||
$logoMargin = $qrDefaults['logo_margin'] ?? 9;
|
||||
$logoHideBackground = $qrDefaults['logo_hide_background'] ?? true;
|
||||
$logoShape = $qrDefaults['logo_shape'] ?? 'square';
|
||||
|
||||
$qrOptionsJson = json_encode([
|
||||
'type' => 'svg',
|
||||
'width' => 200,
|
||||
'height' => 200,
|
||||
'margin' => $qrDefaults['margin'] ?? 1,
|
||||
'dotsOptions' => $dotsOptions,
|
||||
'backgroundOptions' => ['color' => $bgColor],
|
||||
'cornersSquareOptions' => $cornersSquareOptions,
|
||||
'cornersDotOptions' => $cornersDotOptions,
|
||||
'image' => $logo ?: null,
|
||||
'imageOptions' => [
|
||||
'crossOrigin' => 'anonymous',
|
||||
'hideBackgroundDots' => $logoHideBackground,
|
||||
'imageSize' => $logoSize,
|
||||
'margin' => $logoMargin,
|
||||
'logoShape' => $logoShape,
|
||||
],
|
||||
'qrOptions' => ['errorCorrectionLevel' => $logo ? 'H' : 'M'],
|
||||
]);
|
||||
|
||||
$escapedQrOptions = e($qrOptionsJson);
|
||||
|
||||
$html = <<<HTML
|
||||
<div data-qr-options="{$escapedQrOptions}" x-data="{
|
||||
shortUrl: '{$shortUrl}',
|
||||
qrTargetUrl: '{$qrTargetUrl}',
|
||||
urlKey: '{$urlKey}',
|
||||
eid: '{$eid}',
|
||||
init() {
|
||||
this.\$nextTick(() => {
|
||||
this.loadScript().then(() => {
|
||||
const canvas = document.getElementById(this.eid + '_qr_canvas');
|
||||
if (canvas && !canvas.innerHTML) {
|
||||
const opts = JSON.parse(this.\$el.getAttribute('data-qr-options'));
|
||||
opts.data = this.qrTargetUrl;
|
||||
|
||||
const fixSvg = () => {
|
||||
const svg = canvas.querySelector('svg');
|
||||
if (svg) {
|
||||
const w = svg.getAttribute('width') || opts.width || 200;
|
||||
const h = svg.getAttribute('height') || opts.height || 200;
|
||||
svg.setAttribute('viewBox', '0 0 ' + w + ' ' + h);
|
||||
svg.style.width = '100%';
|
||||
svg.style.height = '100%';
|
||||
}
|
||||
};
|
||||
|
||||
if (opts.image && opts.imageOptions) {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = 1200;
|
||||
cv.height = 1200;
|
||||
const cx = cv.getContext('2d');
|
||||
cx.imageSmoothingEnabled = true;
|
||||
cx.imageSmoothingQuality = 'high';
|
||||
|
||||
const isCircle = opts.imageOptions.logoShape === 'circle';
|
||||
const targetDim = 1200 - (parseFloat(opts.imageOptions.margin || 0) * 20);
|
||||
|
||||
cx.save();
|
||||
if (isCircle) {
|
||||
cx.beginPath();
|
||||
cx.arc(600, 600, targetDim / 2, 0, 2 * Math.PI);
|
||||
cx.clip();
|
||||
|
||||
const scale = Math.max(targetDim / img.width, targetDim / img.height);
|
||||
const w = img.width * scale;
|
||||
const h = img.height * scale;
|
||||
const x = (1200 - w) / 2;
|
||||
const y = (1200 - h) / 2;
|
||||
|
||||
cx.drawImage(img, x, y, w, h);
|
||||
} else {
|
||||
cx.beginPath();
|
||||
const offset = (1200 - targetDim) / 2;
|
||||
const radius = 144 * (targetDim / 1200);
|
||||
|
||||
if (typeof cx.roundRect === 'function') {
|
||||
cx.roundRect(offset, offset, targetDim, targetDim, radius);
|
||||
} else {
|
||||
cx.moveTo(offset + radius, offset);
|
||||
cx.lineTo(offset + targetDim - radius, offset);
|
||||
cx.quadraticCurveTo(offset + targetDim, offset, offset + targetDim, offset + radius);
|
||||
cx.lineTo(offset + targetDim, offset + targetDim - radius);
|
||||
cx.quadraticCurveTo(offset + targetDim, offset + targetDim, offset + targetDim - radius, offset + targetDim);
|
||||
cx.lineTo(offset + radius, offset + targetDim);
|
||||
cx.quadraticCurveTo(offset, offset + targetDim, offset, offset + targetDim - radius);
|
||||
cx.lineTo(offset, offset + radius);
|
||||
cx.quadraticCurveTo(offset, offset, offset + radius, offset);
|
||||
cx.closePath();
|
||||
}
|
||||
cx.clip();
|
||||
|
||||
const scale = Math.max(targetDim / img.width, targetDim / img.height);
|
||||
const w = img.width * scale;
|
||||
const h = img.height * scale;
|
||||
const x = (1200 - w) / 2;
|
||||
const y = (1200 - h) / 2;
|
||||
|
||||
cx.drawImage(img, x, y, w, h);
|
||||
}
|
||||
cx.restore();
|
||||
|
||||
opts.image = cv.toDataURL('image/png');
|
||||
opts.imageOptions.margin = 0;
|
||||
|
||||
|
||||
window['qr_' + this.eid] = new window.QRCodeStyling(opts);
|
||||
window['qr_' + this.eid].append(canvas);
|
||||
fixSvg();
|
||||
};
|
||||
img.src = opts.image;
|
||||
} else {
|
||||
window['qr_' + this.eid] = new window.QRCodeStyling(opts);
|
||||
window['qr_' + this.eid].append(canvas);
|
||||
fixSvg();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
loadScript() {
|
||||
return new Promise((resolve) => {
|
||||
if (window.QRCodeStyling) return resolve();
|
||||
const s = document.createElement('script');
|
||||
s.src = 'https://unpkg.com/qr-code-styling@1.6.0-rc.1/lib/qr-code-styling.js';
|
||||
s.onload = () => resolve();
|
||||
s.onerror = () => {
|
||||
const s2 = document.createElement('script');
|
||||
s2.src = 'https://cdn.jsdelivr.net/npm/qr-code-styling@1.6.0-rc.1/lib/qr-code-styling.js';
|
||||
s2.onload = () => resolve();
|
||||
document.head.appendChild(s2);
|
||||
};
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
}">
|
||||
|
||||
<!-- Close Button (x) -->
|
||||
<button type="button" x-on:click="event.preventDefault(); event.stopPropagation(); const f=\$el.closest('.fi-modal-window'); const m=f?(f.getAttribute('x-on:keydown.window.escape')||'').match(/'(fi-[^']*)'/):null; if(m){\$dispatch('close-modal',{id:m[1]})}else{\$wire.unmountAction()}"
|
||||
style="position:absolute;top:16px;right:16px;z-index:50;width:28px;height:28px;border-radius:50%;border:none;background:#f3f4f6;cursor:pointer;display:flex;align-items:center;justify-content:center;color:#6b7280;transition:color .15s,background-color .15s"
|
||||
onmouseover="this.style.color='#374151';this.style.backgroundColor='#e5e7eb'"
|
||||
onmouseout="this.style.color='#6b7280';this.style.backgroundColor='#f3f4f6'"
|
||||
title="{$closeButtonText}">
|
||||
<svg style="width:16px;height:16px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div style="text-align:center;padding:4px 0 8px">
|
||||
<p style="font-size:13px;color:#9ca3af;margin:0">{$qrHelperText}</p>
|
||||
</div>
|
||||
|
||||
<!-- URL pill -->
|
||||
<div style="display:flex;align-items:center;gap:10px;background:#EFF6FF;border-radius:999px;padding:10px 14px;margin:18px 0 0">
|
||||
<div style="width:30px;height:30px;border-radius:50%;background:#fff;display:flex;align-items:center;justify-content:center;flex-shrink:0;border:1px solid #e5e7eb">
|
||||
<img src="https://icons.duckduckgo.com/ip2/{$destHost}.ico" style="width:16px;height:16px;object-fit:contain" onerror="this.style.display='none'">
|
||||
</div>
|
||||
<span style="flex:1;font-size:14px;font-weight:600;color:#1d4ed8;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{$shortUrl}</span>
|
||||
<div style="display:flex;align-items:center;gap:4px;flex-shrink:0">
|
||||
<button id="{$eid}_copy" type="button"
|
||||
onclick="
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const u='{$shortUrl}';
|
||||
if(navigator.clipboard&&window.isSecureContext){navigator.clipboard.writeText(u);}
|
||||
else{const t=document.createElement('textarea');t.value=u;t.style.cssText='position:fixed;left:-9999px';document.body.appendChild(t);t.select();document.execCommand('copy');t.remove();}
|
||||
const b=document.getElementById('{$eid}_copy');
|
||||
const prev=b.innerHTML;
|
||||
b.innerHTML='<svg style=\'width:16px;height:16px;color:#16a34a\' fill=\'none\' viewBox=\'0 0 24 24\' stroke=\'currentColor\' stroke-width=\'2\'><path stroke-linecap=\'round\' stroke-linejoin=\'round\' d=\'M5 13l4 4L19 7\'/></svg>';
|
||||
setTimeout(()=>b.innerHTML=prev,1800);
|
||||
"
|
||||
style="width:30px;height:30px;border-radius:7px;border:1px solid #e5e7eb;background:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .15s"
|
||||
onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'" title="{$copyLinkText}">
|
||||
<svg style="width:16px;height:16px;color:#6b7280" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path stroke-linecap="round" stroke-linejoin="round" d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
|
||||
</button>
|
||||
<a href="{$shortUrl}" target="_blank" rel="noopener noreferrer"
|
||||
style="width:30px;height:30px;border-radius:7px;border:1px solid #e5e7eb;background:#fff;display:flex;align-items:center;justify-content:center;text-decoration:none;transition:background .15s"
|
||||
onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'" title="{$openLinkText}">
|
||||
<svg style="width:15px;height:15px;color:#6b7280" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QR Code Container (Visible immediately) -->
|
||||
<div id="{$eid}_qr_container" style="display:block;margin:16px 0 0;text-align:center;background:#f9fafb;border:1px solid #e5e7eb;border-radius:12px;padding:16px">
|
||||
<div style="display:flex;justify-content:center;margin-bottom:12px">
|
||||
<div id="{$eid}_qr_canvas" style="background:#fff;padding:8px;border-radius:8px;box-shadow:0 1px 3px rgba(0,0,0,0.05)"></div>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:center;gap:8px">
|
||||
<button type="button" onclick="event.preventDefault();event.stopPropagation();window['qr_{$eid}']?.download({ name: '{$urlKey}-qr', extension: 'svg' })" style="font-size:12px;font-weight:600;color:#374151;border:1.5px solid #e5e7eb;background:#fff;padding:6px 12px;border-radius:8px;cursor:pointer;transition:background .15s" onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'">{$downloadSvgText}</button>
|
||||
<button type="button" onclick="event.preventDefault();event.stopPropagation();window['qr_{$eid}']?.download({ name: '{$urlKey}-qr', extension: 'png' })" style="font-size:12px;font-weight:600;color:#374151;border:1.5px solid #e5e7eb;background:#fff;padding:6px 12px;border-radius:8px;cursor:pointer;transition:background .15s" onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='#fff'">{$downloadPngText}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
HTML;
|
||||
|
||||
return [
|
||||
Forms\Components\Placeholder::make('qr_modal_content')
|
||||
->hiddenLabel()
|
||||
->content(new HtmlString($html)),
|
||||
];
|
||||
}),
|
||||
|
||||
Action::make('stats')
|
||||
->label(__('filament-short-url::default.action_stats'))
|
||||
->icon('heroicon-o-chart-bar')
|
||||
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,16 @@ class ShortUrlVisitsRightBreakdown extends Widget
|
||||
*/
|
||||
protected function getViewData(): array
|
||||
{
|
||||
$languageNames = __('filament-short-url::languages');
|
||||
if (! is_array($languageNames)) {
|
||||
$languageNames = [];
|
||||
}
|
||||
|
||||
if (! $this->record) {
|
||||
return [
|
||||
'visitsByCountry' => [],
|
||||
'visitsByLanguage' => [],
|
||||
'languageNames' => $languageNames,
|
||||
'totalVisits' => 0,
|
||||
];
|
||||
}
|
||||
@@ -40,7 +45,66 @@ 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));
|
||||
|
||||
// 1. Try loading from languages translation file (languages.php)
|
||||
$translatedLanguages = __('filament-short-url::languages');
|
||||
if (is_array($translatedLanguages) && isset($translatedLanguages[$langCode])) {
|
||||
return $translatedLanguages[$langCode];
|
||||
}
|
||||
|
||||
// 2. Try PHP Locale extension
|
||||
if (class_exists(\Locale::class)) {
|
||||
try {
|
||||
$name = \Locale::getDisplayLanguage($langCode, app()->getLocale());
|
||||
if ($name && $name !== $langCode) {
|
||||
return mb_convert_case($name, MB_CASE_TITLE, 'UTF-8');
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Fallback
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Simple fallback
|
||||
return strtoupper($langCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamically translate an English country name to the current application locale.
|
||||
*/
|
||||
public static function getCountryTranslation(string $englishName): string
|
||||
{
|
||||
$englishName = trim($englishName);
|
||||
|
||||
try {
|
||||
$enCountries = trans('filament-short-url::countries', [], 'en');
|
||||
if (is_array($enCountries)) {
|
||||
$flipped = array_change_key_case(array_flip($enCountries), CASE_LOWER);
|
||||
$lookupKey = strtolower($englishName);
|
||||
$code = $flipped[$lookupKey] ?? null;
|
||||
|
||||
if ($code) {
|
||||
$translated = __('filament-short-url::countries.'.strtoupper($code));
|
||||
if ($translated && $translated !== 'filament-short-url::countries.'.strtoupper($code)) {
|
||||
return $translated;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Fallback
|
||||
}
|
||||
|
||||
return $englishName;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,11 +89,22 @@ class ShortUrlWorldMapWidget extends Widget
|
||||
$normalized[$code] = round(($count / $max) * 100);
|
||||
}
|
||||
|
||||
// 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';
|
||||
$svgContent = '';
|
||||
if (file_exists($svgPath)) {
|
||||
$svgContent = file_get_contents($svgPath);
|
||||
// Remove XML declaration and DOCTYPE tags
|
||||
$svgContent = preg_replace('/<\?xml[^>]*\?>/i', '', $svgContent);
|
||||
$svgContent = preg_replace('/<!DOCTYPE[^>]*>/i', '', $svgContent);
|
||||
}
|
||||
|
||||
return [
|
||||
'countryData' => $countryData,
|
||||
'maxCount' => $max,
|
||||
'totalClicks' => $total,
|
||||
'normalized' => $normalized,
|
||||
'svgContent' => $svgContent,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlPixelResource;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
|
||||
use Filament\Contracts\Plugin;
|
||||
use Filament\Panel;
|
||||
@@ -18,6 +19,8 @@ class FilamentShortUrlPlugin implements Plugin
|
||||
|
||||
protected ?string $routePrefix = null;
|
||||
|
||||
protected ?\Closure $authorizeSettingsUsing = null;
|
||||
|
||||
// ─── Factory ─────────────────────────────────────────────────────────────
|
||||
|
||||
public static function make(): static
|
||||
@@ -42,6 +45,7 @@ class FilamentShortUrlPlugin implements Plugin
|
||||
$panel
|
||||
->resources([
|
||||
ShortUrlResource::class,
|
||||
ShortUrlPixelResource::class,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -119,4 +123,21 @@ class FilamentShortUrlPlugin implements Plugin
|
||||
{
|
||||
return $this->navigationIcon;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a custom callback to authorize access to the Short URL settings page.
|
||||
*
|
||||
* @example FilamentShortUrlPlugin::make()->authorizeSettingsUsing(fn () => auth()->user()->hasRole('admin'))
|
||||
*/
|
||||
public function authorizeSettingsUsing(\Closure $callback): static
|
||||
{
|
||||
$this->authorizeSettingsUsing = $callback;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAuthorizeSettingsUsing(): ?\Closure
|
||||
{
|
||||
return $this->authorizeSettingsUsing;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -66,9 +71,7 @@ class FilamentShortUrlServiceProvider extends PackageServiceProvider
|
||||
$this->app->booted(function (): void {
|
||||
$schedule = $this->app->make(Schedule::class);
|
||||
|
||||
if (config('filament-short-url.pruning.enabled', true)) {
|
||||
$schedule->command('short-url:aggregate-and-prune')->dailyAt('02:00');
|
||||
}
|
||||
$schedule->command('short-url:aggregate-and-prune')->dailyAt('02:00');
|
||||
|
||||
if (config('filament-short-url.counter_buffering.enabled', false)) {
|
||||
$schedule->command('short-url:sync-counters')->everyMinute();
|
||||
|
||||
@@ -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
|
||||
{
|
||||
@@ -53,14 +57,65 @@ class ShortUrlApiController extends Controller
|
||||
'expiration_redirect_url' => 'nullable|url|max:2048',
|
||||
'activated_at' => 'nullable|date',
|
||||
'expires_at' => 'nullable|date',
|
||||
'webhook_url' => 'nullable|url|max:2048',
|
||||
'targeting_rules' => 'nullable|array',
|
||||
'password' => 'nullable|string|max:255',
|
||||
'show_warning_page' => 'nullable|boolean',
|
||||
'track_visits' => 'nullable|boolean',
|
||||
'track_browser_language' => 'nullable|boolean',
|
||||
'pixels' => 'nullable|array',
|
||||
'pixels.*' => 'integer|exists:short_url_pixels,id',
|
||||
// Keep for backward compatibility
|
||||
'pixel_meta_id' => 'nullable|string|max:100',
|
||||
'pixel_google_id' => 'nullable|string|max:100',
|
||||
'pixel_linkedin_id' => 'nullable|string|max:100',
|
||||
'webhook_url' => 'nullable|url|max:2048',
|
||||
]);
|
||||
|
||||
$pixelIds = $validated['pixels'] ?? [];
|
||||
|
||||
// Backward compatibility support for old pixel columns
|
||||
if (! empty($validated['pixel_meta_id'])) {
|
||||
$metaPixelId = \Bjanczak\FilamentShortUrl\Models\ShortUrlPixel::firstOrCreate([
|
||||
'type' => 'meta',
|
||||
'pixel_id' => $validated['pixel_meta_id'],
|
||||
], [
|
||||
'name' => 'Meta Pixel (' . $validated['pixel_meta_id'] . ')',
|
||||
'is_active' => true,
|
||||
])->id;
|
||||
$pixelIds[] = $metaPixelId;
|
||||
}
|
||||
|
||||
if (! empty($validated['pixel_google_id'])) {
|
||||
$googlePixelId = \Bjanczak\FilamentShortUrl\Models\ShortUrlPixel::firstOrCreate([
|
||||
'type' => 'google',
|
||||
'pixel_id' => $validated['pixel_google_id'],
|
||||
], [
|
||||
'name' => 'Google Tag (' . $validated['pixel_google_id'] . ')',
|
||||
'is_active' => true,
|
||||
])->id;
|
||||
$pixelIds[] = $googlePixelId;
|
||||
}
|
||||
|
||||
if (! empty($validated['pixel_linkedin_id'])) {
|
||||
$linkedinPixelId = \Bjanczak\FilamentShortUrl\Models\ShortUrlPixel::firstOrCreate([
|
||||
'type' => 'linkedin',
|
||||
'pixel_id' => $validated['pixel_linkedin_id'],
|
||||
], [
|
||||
'name' => 'LinkedIn Insight (' . $validated['pixel_linkedin_id'] . ')',
|
||||
'is_active' => true,
|
||||
])->id;
|
||||
$pixelIds[] = $linkedinPixelId;
|
||||
}
|
||||
|
||||
// Clean up parameters that shouldn't be mass assigned
|
||||
unset($validated['pixel_meta_id'], $validated['pixel_google_id'], $validated['pixel_linkedin_id'], $validated['pixels']);
|
||||
|
||||
$shortUrl = $this->service->create($validated);
|
||||
|
||||
if (! empty($pixelIds)) {
|
||||
$shortUrl->pixels()->sync($pixelIds);
|
||||
}
|
||||
|
||||
// Fire 'created' webhook if active
|
||||
$this->dispatchCreatedWebhook($shortUrl);
|
||||
|
||||
@@ -88,6 +143,8 @@ class ShortUrlApiController extends Controller
|
||||
*/
|
||||
private function transformLink(ShortUrl $link): array
|
||||
{
|
||||
$pixels = $link->relationLoaded('pixels') ? $link->pixels : $link->pixels()->get();
|
||||
|
||||
return [
|
||||
'id' => $link->id,
|
||||
'destination_url' => $link->destination_url,
|
||||
@@ -100,10 +157,22 @@ class ShortUrlApiController extends Controller
|
||||
'max_visits' => $link->max_visits ? (int) $link->max_visits : null,
|
||||
'activated_at' => $link->activated_at ? $link->activated_at->toIso8601String() : null,
|
||||
'expires_at' => $link->expires_at ? $link->expires_at->toIso8601String() : null,
|
||||
'pixel_meta_id' => $link->pixel_meta_id,
|
||||
'pixel_google_id' => $link->pixel_google_id,
|
||||
'pixel_linkedin_id' => $link->pixel_linkedin_id,
|
||||
'pixel_meta_id' => $pixels->where('type', 'meta')->first()?->pixel_id,
|
||||
'pixel_google_id' => $pixels->where('type', 'google')->first()?->pixel_id,
|
||||
'pixel_linkedin_id' => $pixels->where('type', 'linkedin')->first()?->pixel_id,
|
||||
'webhook_url' => $link->webhook_url,
|
||||
'targeting_rules' => $link->targeting_rules,
|
||||
'password' => $link->password,
|
||||
'show_warning_page' => (bool) $link->show_warning_page,
|
||||
'track_visits' => (bool) $link->track_visits,
|
||||
'track_browser_language' => (bool) $link->track_browser_language,
|
||||
'pixels' => $pixels->map(fn ($p) => [
|
||||
'id' => $p->id,
|
||||
'name' => $p->name,
|
||||
'type' => $p->type,
|
||||
'pixel_id' => $p->pixel_id,
|
||||
'is_active' => (bool) $p->is_active,
|
||||
])->toArray(),
|
||||
'notes' => $link->notes,
|
||||
'created_at' => $link->created_at->toIso8601String(),
|
||||
];
|
||||
@@ -142,4 +211,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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
@@ -88,20 +89,8 @@ class ShortUrlRedirectController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Resolve Destination URL (evaluating targeting rules)
|
||||
$destination = $shortUrl->resolveDestinationUrl($request);
|
||||
|
||||
// Forward query parameters if configured
|
||||
if ($shortUrl->forward_query_params) {
|
||||
$queryParams = $request->query();
|
||||
// Remove routing/auth parameters so they don't leak to destination
|
||||
unset($queryParams['confirmed'], $queryParams['password']);
|
||||
|
||||
if (! empty($queryParams)) {
|
||||
$separator = str_contains($destination, '?') ? '&' : '?';
|
||||
$destination .= $separator.http_build_query($queryParams);
|
||||
}
|
||||
}
|
||||
// 4. Resolve Destination URL (evaluating targeting rules and forwarding query parameters)
|
||||
$destination = $this->service->resolveRedirectUrl($shortUrl, $request);
|
||||
|
||||
// 5. Warning / Intermediate Page Check
|
||||
if ($shortUrl->show_warning_page && ! $request->has('confirmed')) {
|
||||
@@ -117,6 +106,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 +129,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) {
|
||||
@@ -160,12 +162,12 @@ class ShortUrlRedirectController extends Controller
|
||||
cache()->forget("filament-short-url:{$shortUrl->url_key}");
|
||||
}
|
||||
|
||||
if (! empty($shortUrl->pixel_meta_id) || ! empty($shortUrl->pixel_google_id) || ! empty($shortUrl->pixel_linkedin_id)) {
|
||||
$activePixels = $shortUrl->pixels()->where('is_active', true)->get();
|
||||
|
||||
if ($activePixels->isNotEmpty()) {
|
||||
return response(view('filament-short-url::pixel-loading', [
|
||||
'destination' => $destination,
|
||||
'pixelMetaId' => $shortUrl->pixel_meta_id,
|
||||
'pixelGoogleId' => $shortUrl->pixel_google_id,
|
||||
'pixelLinkedinId' => $shortUrl->pixel_linkedin_id,
|
||||
'pixels' => $activePixels,
|
||||
]))->header('Content-Type', 'text/html');
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -95,52 +99,63 @@ class TrackShortUrlVisitJob implements ShouldQueue
|
||||
$globalUrl = config('filament-short-url.global_webhook_url');
|
||||
$events = config('filament-short-url.webhook_events', []);
|
||||
|
||||
if (empty($targetUrl) && ! empty($globalUrl) && in_array('visited', $events)) {
|
||||
$targetUrl = $globalUrl;
|
||||
$webhooksToDispatch = [];
|
||||
if (! empty($targetUrl)) {
|
||||
$webhooksToDispatch[] = $targetUrl;
|
||||
}
|
||||
if (! empty($globalUrl) && in_array('visited', $events)) {
|
||||
$webhooksToDispatch[] = $globalUrl;
|
||||
}
|
||||
|
||||
if (! empty($targetUrl)) {
|
||||
try {
|
||||
dispatch(new SendWebhookJob(
|
||||
url: $targetUrl,
|
||||
event: 'visited',
|
||||
payload: [
|
||||
'event' => 'visited',
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
'short_url' => [
|
||||
'id' => $shortUrl->id,
|
||||
'destination_url' => $shortUrl->destination_url,
|
||||
'url_key' => $shortUrl->url_key,
|
||||
'short_url' => $shortUrl->getShortUrl(),
|
||||
'total_visits' => (int) $shortUrl->getRealTimeTotalVisits(),
|
||||
'unique_visits' => (int) $shortUrl->unique_visits,
|
||||
],
|
||||
'visit' => [
|
||||
'id' => $visit->id,
|
||||
'visited_at' => $visit->visited_at->toIso8601String(),
|
||||
'device_type' => $visit->device_type,
|
||||
'browser' => $visit->browser,
|
||||
'browser_version' => $visit->browser_version,
|
||||
'operating_system' => $visit->operating_system,
|
||||
'operating_system_version' => $visit->operating_system_version,
|
||||
'country' => $visit->country,
|
||||
'country_code' => $visit->country_code,
|
||||
'city' => $visit->city,
|
||||
'referer_url' => $visit->referer_url,
|
||||
'referer_host' => $visit->referer_host,
|
||||
'utm_source' => $visit->utm_source,
|
||||
'utm_medium' => $visit->utm_medium,
|
||||
'utm_campaign' => $visit->utm_campaign,
|
||||
'utm_term' => $visit->utm_term,
|
||||
'utm_content' => $visit->utm_content,
|
||||
],
|
||||
]
|
||||
)->onConnection($this->connection ?: 'sync'));
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('[FilamentShortUrl] Visited webhook dispatch failed', [
|
||||
if (! empty($webhooksToDispatch)) {
|
||||
$payload = [
|
||||
'event' => 'visited',
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
'short_url' => [
|
||||
'id' => $shortUrl->id,
|
||||
'destination_url' => $shortUrl->destination_url,
|
||||
'url_key' => $shortUrl->url_key,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
'short_url' => $shortUrl->getShortUrl(),
|
||||
'total_visits' => (int) $shortUrl->getRealTimeTotalVisits(),
|
||||
'unique_visits' => (int) $shortUrl->unique_visits,
|
||||
],
|
||||
'visit' => [
|
||||
'id' => $visit->id,
|
||||
'visited_at' => $visit->visited_at->toIso8601String(),
|
||||
'device_type' => $visit->device_type,
|
||||
'browser' => $visit->browser,
|
||||
'browser_version' => $visit->browser_version,
|
||||
'operating_system' => $visit->operating_system,
|
||||
'operating_system_version' => $visit->operating_system_version,
|
||||
'country' => $visit->country,
|
||||
'country_code' => $visit->country_code,
|
||||
'city' => $visit->city,
|
||||
'referer_url' => $visit->referer_url,
|
||||
'referer_host' => $visit->referer_host,
|
||||
'utm_source' => $visit->utm_source,
|
||||
'utm_medium' => $visit->utm_medium,
|
||||
'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,
|
||||
],
|
||||
];
|
||||
|
||||
foreach (array_unique($webhooksToDispatch) as $url) {
|
||||
try {
|
||||
dispatch(new SendWebhookJob(
|
||||
url: $url,
|
||||
event: 'visited',
|
||||
payload: $payload
|
||||
)->onConnection($this->connection ?: 'sync'));
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('[FilamentShortUrl] Visited webhook dispatch failed', [
|
||||
'url' => $url,
|
||||
'url_key' => $shortUrl->url_key,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,11 +13,13 @@ use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
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 +73,7 @@ class ShortUrl extends Model
|
||||
'track_operating_system_version',
|
||||
'track_device_type',
|
||||
'track_referer_url',
|
||||
'track_browser_language',
|
||||
'qr_options',
|
||||
'ga_tracking_id',
|
||||
'password',
|
||||
@@ -80,10 +83,9 @@ class ShortUrl extends Model
|
||||
'unique_visits',
|
||||
'max_visits',
|
||||
'expiration_redirect_url',
|
||||
'pixel_meta_id',
|
||||
'pixel_google_id',
|
||||
'pixel_linkedin_id',
|
||||
'webhook_url',
|
||||
'qr_logo',
|
||||
'qr_scans',
|
||||
];
|
||||
|
||||
/** @var array<string, string> */
|
||||
@@ -99,6 +101,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 +109,7 @@ class ShortUrl extends Model
|
||||
'activated_at' => 'datetime',
|
||||
'deactivated_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
'qr_scans' => 'integer',
|
||||
];
|
||||
|
||||
// ─── Relations ───────────────────────────────────────────────────────────
|
||||
@@ -120,6 +124,11 @@ class ShortUrl extends Model
|
||||
return $this->hasMany(ShortUrlDailyStats::class, 'short_url_id');
|
||||
}
|
||||
|
||||
public function pixels(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(ShortUrlPixel::class, 'short_url_pixel', 'short_url_id', 'pixel_id');
|
||||
}
|
||||
|
||||
// ─── Scopes ──────────────────────────────────────────────────────────────
|
||||
|
||||
public function scopeEnabled(Builder $query): Builder
|
||||
@@ -185,24 +194,39 @@ class ShortUrl extends Model
|
||||
static::saving(function (self $m) {
|
||||
if ($m->single_use) {
|
||||
$m->max_visits = null;
|
||||
$m->redirect_status_code = 302; // Force temporary redirect to prevent browser caching of single-use URLs
|
||||
}
|
||||
|
||||
if ($m->activated_at === null && $m->expires_at === null) {
|
||||
$m->expiration_redirect_url = null;
|
||||
}
|
||||
|
||||
if (empty($m->pixel_meta_id)) {
|
||||
$m->pixel_meta_id = null;
|
||||
}
|
||||
if (empty($m->pixel_google_id)) {
|
||||
$m->pixel_google_id = null;
|
||||
}
|
||||
if (empty($m->pixel_linkedin_id)) {
|
||||
$m->pixel_linkedin_id = null;
|
||||
}
|
||||
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) {
|
||||
@@ -215,11 +239,17 @@ class ShortUrl extends Model
|
||||
}
|
||||
}
|
||||
});
|
||||
static::deleted(fn (self $m) => cache()->forget("filament-short-url:{$m->url_key}"));
|
||||
static::deleted(function (self $m) {
|
||||
cache()->forget("filament-short-url:{$m->url_key}");
|
||||
cache()->forget(ShortUrlGlobalOverview::LINKS_CACHE_KEY);
|
||||
|
||||
if (! empty($m->qr_logo)) {
|
||||
Storage::disk('public')->delete($m->qr_logo);
|
||||
}
|
||||
});
|
||||
|
||||
// Bust the forever-cached link counts displayed in the global overview widget.
|
||||
static::created(fn () => cache()->forget(ShortUrlGlobalOverview::LINKS_CACHE_KEY));
|
||||
static::deleted(fn () => cache()->forget(ShortUrlGlobalOverview::LINKS_CACHE_KEY));
|
||||
}
|
||||
|
||||
/** @return Collection<int, static> */
|
||||
@@ -240,14 +270,21 @@ class ShortUrl extends Model
|
||||
|
||||
public function getRealTimeTotalVisits(): int
|
||||
{
|
||||
$total = $this->total_visits;
|
||||
|
||||
$buffered = 0;
|
||||
if (config('filament-short-url.counter_buffering.enabled', false)) {
|
||||
$prefix = config('filament-short-url.counter_buffering.cache_key_prefix', 'filament-short-url:buffer:');
|
||||
$total += (int) cache()->get("{$prefix}total:{$this->id}", 0);
|
||||
$buffered = (int) cache()->get("{$prefix}total:{$this->id}", 0);
|
||||
}
|
||||
|
||||
return $total;
|
||||
if ($this->max_visits !== null) {
|
||||
$dbVal = (int) DB::table($this->table)
|
||||
->where('id', $this->id)
|
||||
->value('total_visits');
|
||||
|
||||
return $dbVal + $buffered;
|
||||
}
|
||||
|
||||
return $this->total_visits + $buffered;
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
@@ -256,6 +293,13 @@ class ShortUrl extends Model
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->single_use) {
|
||||
$realEnabled = DB::table($this->table)->where('id', $this->id)->value('is_enabled');
|
||||
if (! $realEnabled) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Not yet active
|
||||
if ($this->activated_at && $this->activated_at->isFuture()) {
|
||||
return false;
|
||||
@@ -314,23 +358,34 @@ class ShortUrl extends Model
|
||||
'operating_system_version' => $this->track_operating_system_version,
|
||||
'device_type' => $this->track_device_type,
|
||||
'referer_url' => $this->track_referer_url,
|
||||
'browser_language' => $this->track_browser_language,
|
||||
]));
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function getQrOptions(): array
|
||||
{
|
||||
return array_merge(
|
||||
$opts = array_merge(
|
||||
config('filament-short-url.qr_defaults', []),
|
||||
$this->qr_options ?? []
|
||||
);
|
||||
|
||||
if (! empty($this->qr_logo)) {
|
||||
$opts['logo'] = route('short-url.logo', ['filename' => basename($this->qr_logo)]);
|
||||
}
|
||||
|
||||
return $opts;
|
||||
}
|
||||
|
||||
public function getShortUrl(): string
|
||||
{
|
||||
$prefix = config('filament-short-url.route_prefix', 's');
|
||||
$prefix = config('filament-short-url.route_prefix');
|
||||
|
||||
return rtrim(config('app.url'), '/')."/{$prefix}/{$this->url_key}";
|
||||
if (! empty($prefix)) {
|
||||
return rtrim(config('app.url'), '/').'/'.trim($prefix, '/').'/'.$this->url_key;
|
||||
}
|
||||
|
||||
return rtrim(config('app.url'), '/').'/'.$this->url_key;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -379,6 +434,43 @@ class ShortUrl extends Model
|
||||
}
|
||||
}
|
||||
|
||||
if ($type === 'language') {
|
||||
$acceptedLanguages = $request->getLanguages();
|
||||
|
||||
// Pass 1: Exact match (e.g. "en-us" matches "en-us" rule, or "pl" matches "pl" rule)
|
||||
foreach ($acceptedLanguages as $acceptedLanguage) {
|
||||
$acceptedLanguage = strtolower(trim(str_replace('_', '-', $acceptedLanguage)));
|
||||
if (empty($acceptedLanguage)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($rules['language'] ?? [] as $rule) {
|
||||
$ruleLang = strtolower(trim(str_replace('_', '-', $rule['language_code'] ?? '')));
|
||||
if ($ruleLang === $acceptedLanguage) {
|
||||
return $rule['url'] ?? $this->destination_url;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: Primary language fallback match (e.g. "en-us" matches general "en" rule)
|
||||
foreach ($acceptedLanguages as $acceptedLanguage) {
|
||||
$acceptedLanguage = strtolower(trim(str_replace('_', '-', $acceptedLanguage)));
|
||||
if (empty($acceptedLanguage)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parts = explode('-', $acceptedLanguage);
|
||||
$primaryLang = strtolower(trim($parts[0]));
|
||||
|
||||
foreach ($rules['language'] ?? [] as $rule) {
|
||||
$ruleLang = strtolower(trim(str_replace('_', '-', $rule['language_code'] ?? '')));
|
||||
if ($ruleLang === $primaryLang) {
|
||||
return $rule['url'] ?? $this->destination_url;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($type === 'rotation') {
|
||||
$items = $rules['rotation'] ?? [];
|
||||
if (! empty($items)) {
|
||||
@@ -403,36 +495,63 @@ 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)) {
|
||||
if (cache()->getDefaultDriver() === 'redis' && class_exists(Redis::class)) {
|
||||
$prefix = config('filament-short-url.counter_buffering.cache_key_prefix', 'filament-short-url:buffer:');
|
||||
try {
|
||||
cache()->increment("{$prefix}total:{$this->id}");
|
||||
$prefix = config('filament-short-url.counter_buffering.cache_key_prefix', 'filament-short-url:buffer:');
|
||||
try {
|
||||
// Increment atomically in cache (works on Redis, Memcached, Database, File, etc.)
|
||||
cache()->increment("{$prefix}total:{$this->id}");
|
||||
|
||||
if ($isUnique) {
|
||||
cache()->increment("{$prefix}unique:{$this->id}");
|
||||
}
|
||||
|
||||
Redis::sadd("{$prefix}dirty_ids", $this->id);
|
||||
|
||||
return;
|
||||
} catch (\Throwable) {
|
||||
// Fallback to queue job below
|
||||
if ($isUnique) {
|
||||
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);
|
||||
} else {
|
||||
// Safe, concurrent-proof fallback for non-Redis stores using Cache locks
|
||||
$lock = cache()->lock("{$prefix}dirty_ids_lock", 2);
|
||||
$lock->get(function () use ($prefix) {
|
||||
$dirtyIds = cache()->get("{$prefix}dirty_ids", []);
|
||||
if (! is_array($dirtyIds)) {
|
||||
$dirtyIds = [];
|
||||
}
|
||||
if (! in_array($this->id, $dirtyIds)) {
|
||||
$dirtyIds[] = $this->id;
|
||||
cache()->forever("{$prefix}dirty_ids", $dirtyIds);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
} catch (\Throwable $e) {
|
||||
// Log and fall back to queue job below if caching backend fails
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -490,6 +609,7 @@ class ShortUrl extends Model
|
||||
$visitsToday = count($rawVisits);
|
||||
$visitsThisWeek = 0;
|
||||
$visitsThisMonth = 0;
|
||||
$qrScans = 0;
|
||||
|
||||
$visitsByCountry = [];
|
||||
$visitsByCity = [];
|
||||
@@ -500,6 +620,7 @@ class ShortUrl extends Model
|
||||
$utmSources = [];
|
||||
$utmMediums = [];
|
||||
$utmCampaigns = [];
|
||||
$visitsByLanguage = [];
|
||||
|
||||
// Sum up daily stats
|
||||
$startOfWeek = now()->startOfWeek()->toDateString();
|
||||
@@ -508,6 +629,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) {
|
||||
@@ -526,6 +648,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
|
||||
@@ -537,6 +660,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;
|
||||
}
|
||||
@@ -613,6 +742,7 @@ class ShortUrl extends Model
|
||||
arsort($utmSources);
|
||||
arsort($utmMediums);
|
||||
arsort($utmCampaigns);
|
||||
arsort($visitsByLanguage);
|
||||
|
||||
return [
|
||||
'totalVisits' => $totalVisits,
|
||||
@@ -630,6 +760,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),
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -641,6 +773,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.
|
||||
@@ -653,6 +787,7 @@ class ShortUrl extends Model
|
||||
|
||||
static::$bufferedTotalVisits = [];
|
||||
static::$bufferedUniqueVisits = [];
|
||||
static::$bufferedQrScans = [];
|
||||
|
||||
if (! config('filament-short-url.counter_buffering.enabled', false)) {
|
||||
return;
|
||||
@@ -682,15 +817,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);
|
||||
@@ -698,6 +836,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
|
||||
}
|
||||
@@ -740,4 +881,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ class ShortUrlDailyStats extends Model
|
||||
'utm_source_stats',
|
||||
'utm_medium_stats',
|
||||
'utm_campaign_stats',
|
||||
'qr_visits_count',
|
||||
'language_stats',
|
||||
];
|
||||
|
||||
/** @var array<string, string> */
|
||||
@@ -58,6 +60,8 @@ class ShortUrlDailyStats extends Model
|
||||
'utm_source_stats' => 'array',
|
||||
'utm_medium_stats' => 'array',
|
||||
'utm_campaign_stats' => 'array',
|
||||
'qr_visits_count' => 'integer',
|
||||
'language_stats' => 'array',
|
||||
];
|
||||
|
||||
public function shortUrl(): BelongsTo
|
||||
|
||||
28
src/Models/ShortUrlPixel.php
Normal file
28
src/Models/ShortUrlPixel.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
class ShortUrlPixel extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'type',
|
||||
'pixel_id',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the short URLs associated with this pixel.
|
||||
*/
|
||||
public function shortUrls(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(ShortUrl::class, 'short_url_pixel', 'pixel_id', 'short_url_id');
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,8 @@ class ShortUrlVisit extends Model
|
||||
'visited_at',
|
||||
'is_bot',
|
||||
'is_proxy',
|
||||
'is_qr_scan',
|
||||
'browser_language',
|
||||
];
|
||||
|
||||
/** @var array<string, string> */
|
||||
@@ -56,6 +58,7 @@ class ShortUrlVisit extends Model
|
||||
'visited_at' => 'datetime',
|
||||
'is_bot' => 'boolean',
|
||||
'is_proxy' => 'boolean',
|
||||
'is_qr_scan' => 'boolean',
|
||||
];
|
||||
|
||||
public function shortUrl(): BelongsTo
|
||||
|
||||
@@ -91,7 +91,7 @@ class GeoIpService
|
||||
|
||||
if (class_exists(\Locale::class)) {
|
||||
try {
|
||||
$name = \Locale::getDisplayRegion('-'.$code, 'en');
|
||||
$name = \Locale::getDisplayRegion('en-'.$code, 'en');
|
||||
if ($name && $name !== $code) {
|
||||
return $name;
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
@@ -78,6 +85,7 @@ class ShortUrlSettingsManager
|
||||
'qr_gradient_type' => config('filament-short-url.qr_defaults.gradient_type', 'linear'),
|
||||
'global_webhook_url' => null,
|
||||
'webhook_events' => ['visited'],
|
||||
'global_webhook_enabled' => false,
|
||||
'api_keys' => [],
|
||||
'api_enabled' => false,
|
||||
'site_name' => config('filament-short-url.site_name'),
|
||||
@@ -145,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',
|
||||
@@ -156,6 +165,7 @@ class ShortUrlSettingsManager
|
||||
'qr_gradient_type',
|
||||
'global_webhook_url',
|
||||
'webhook_events',
|
||||
'global_webhook_enabled',
|
||||
'api_keys',
|
||||
'api_enabled',
|
||||
'site_name',
|
||||
@@ -239,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'])) {
|
||||
@@ -251,6 +264,10 @@ class ShortUrlSettingsManager
|
||||
$filtered['qr_gradient_enabled'] = (bool) $filtered['qr_gradient_enabled'];
|
||||
}
|
||||
|
||||
if (isset($filtered['global_webhook_enabled'])) {
|
||||
$filtered['global_webhook_enabled'] = (bool) $filtered['global_webhook_enabled'];
|
||||
}
|
||||
|
||||
// Security v2.0 casts
|
||||
if (isset($filtered['vpn_detection_enabled'])) {
|
||||
$filtered['vpn_detection_enabled'] = (bool) $filtered['vpn_detection_enabled'];
|
||||
@@ -260,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
|
||||
@@ -315,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'],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -37,6 +37,8 @@ class UserAgentParser
|
||||
// Order matters — check specific first, generic last
|
||||
$patterns = [
|
||||
'Edg/' => 'Edge',
|
||||
'EdgiOS' => 'Edge',
|
||||
'EdgA' => 'Edge',
|
||||
'OPR/' => 'Opera',
|
||||
'Opera' => 'Opera',
|
||||
'SamsungBrowser' => 'Samsung Browser',
|
||||
@@ -67,6 +69,8 @@ class UserAgentParser
|
||||
{
|
||||
$patterns = [
|
||||
'/Edg\/([0-9.]+)/',
|
||||
'/EdgiOS\/([0-9.]+)/',
|
||||
'/EdgA\/([0-9.]+)/',
|
||||
'/OPR\/([0-9.]+)/',
|
||||
'/SamsungBrowser\/([0-9.]+)/',
|
||||
'/CriOS\/([0-9.]+)/',
|
||||
|
||||
125
tests/Feature/ShortUrlApiTest.php
Normal file
125
tests/Feature/ShortUrlApiTest.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Jobs\SendWebhookJob;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlSettingsManager;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
beforeEach(function () {
|
||||
// Enable REST API and inject mock keys
|
||||
app(ShortUrlSettingsManager::class)->set([
|
||||
'api_enabled' => true,
|
||||
'api_keys' => [
|
||||
['name' => 'Active Test Token', 'key' => 'sh_key_active_token', 'is_active' => true],
|
||||
['name' => 'Inactive Test Token', 'key' => 'sh_key_inactive_token', 'is_active' => false],
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects API requests without a key', function () {
|
||||
$response = $this->getJson('/api/short-url/links');
|
||||
|
||||
$response->assertStatus(401)
|
||||
->assertJsonFragment(['error' => 'Unauthorized. API Key is missing.']);
|
||||
});
|
||||
|
||||
it('returns 503 when the REST API is disabled in settings', function () {
|
||||
app(ShortUrlSettingsManager::class)->set(['api_enabled' => false]);
|
||||
|
||||
$response = $this->getJson('/api/short-url/links', [
|
||||
'X-Api-Key' => 'sh_key_active_token',
|
||||
]);
|
||||
|
||||
$response->assertStatus(503)
|
||||
->assertJsonPath('error', fn ($v) => str_contains($v, 'disabled'));
|
||||
});
|
||||
|
||||
it('rejects API requests with an invalid key', function () {
|
||||
$response = $this->getJson('/api/short-url/links', [
|
||||
'X-Api-Key' => 'sh_key_invalid_value',
|
||||
]);
|
||||
|
||||
$response->assertStatus(401)
|
||||
->assertJsonFragment(['error' => 'Unauthorized. Invalid or inactive API Key.']);
|
||||
});
|
||||
|
||||
it('rejects API requests with an inactive key', function () {
|
||||
$response = $this->getJson('/api/short-url/links', [
|
||||
'X-Api-Key' => 'sh_key_inactive_token',
|
||||
]);
|
||||
|
||||
$response->assertStatus(401)
|
||||
->assertJsonFragment(['error' => 'Unauthorized. Invalid or inactive API Key.']);
|
||||
});
|
||||
|
||||
it('allows API requests with a valid key', function () {
|
||||
// Seed at least one short URL
|
||||
ShortUrl::create([
|
||||
'destination_url' => 'https://example.com/api-test',
|
||||
'url_key' => 'apitest',
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/short-url/links', [
|
||||
'X-Api-Key' => 'sh_key_active_token',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonStructure([
|
||||
'data',
|
||||
'meta' => ['current_page', 'last_page', 'per_page', 'total'],
|
||||
])
|
||||
->assertJsonFragment(['url_key' => 'apitest']);
|
||||
});
|
||||
|
||||
it('allows creating a short link programmatically via POST', function () {
|
||||
Queue::fake([SendWebhookJob::class]);
|
||||
|
||||
$response = $this->postJson('/api/short-url/links', [
|
||||
'destination_url' => 'https://google.com',
|
||||
'url_key' => 'googleapi',
|
||||
'notes' => 'API Generated link',
|
||||
'single_use' => true,
|
||||
'pixel_meta_id' => '12345',
|
||||
'webhook_url' => 'https://webhook.site/test',
|
||||
], [
|
||||
'X-Api-Key' => 'sh_key_active_token',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201)
|
||||
->assertJsonFragment(['url_key' => 'googleapi'])
|
||||
->assertJsonFragment(['notes' => 'API Generated link']);
|
||||
|
||||
$this->assertDatabaseHas('short_urls', [
|
||||
'url_key' => 'googleapi',
|
||||
'single_use' => true,
|
||||
'webhook_url' => 'https://webhook.site/test',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('short_url_pixels', [
|
||||
'type' => 'meta',
|
||||
'pixel_id' => '12345',
|
||||
]);
|
||||
|
||||
// SendWebhookJob should be dispatched for 'created' event since custom webhook_url is set
|
||||
Queue::assertPushed(SendWebhookJob::class, function ($job) {
|
||||
return $job->url === 'https://webhook.site/test' && $job->event === 'created';
|
||||
});
|
||||
});
|
||||
|
||||
it('allows deleting a short link via DELETE', function () {
|
||||
$link = ShortUrl::create([
|
||||
'destination_url' => 'https://delete-me.com',
|
||||
'url_key' => 'delkey',
|
||||
]);
|
||||
|
||||
$response = $this->deleteJson("/api/short-url/links/{$link->id}", [], [
|
||||
'X-Api-Key' => 'sh_key_active_token',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonFragment(['message' => 'Short URL deleted successfully.']);
|
||||
|
||||
$this->assertDatabaseMissing('short_urls', [
|
||||
'id' => $link->id,
|
||||
]);
|
||||
});
|
||||
126
tests/Feature/ShortUrlPixelTest.php
Normal file
126
tests/Feature/ShortUrlPixelTest.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlPixel;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('serves the pixel-loading interstitial view when pixels are set', function () {
|
||||
$link = ShortUrl::create([
|
||||
'destination_url' => 'https://example.com/target',
|
||||
'url_key' => 'pixelkey',
|
||||
]);
|
||||
|
||||
$metaPixel = ShortUrlPixel::create([
|
||||
'name' => 'My Meta Pixel',
|
||||
'type' => 'meta',
|
||||
'pixel_id' => 'META-12345',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$googlePixel = ShortUrlPixel::create([
|
||||
'name' => 'My Google Tag',
|
||||
'type' => 'google',
|
||||
'pixel_id' => 'G-GA54321',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$linkedinPixel = ShortUrlPixel::create([
|
||||
'name' => 'My LinkedIn Tag',
|
||||
'type' => 'linkedin',
|
||||
'pixel_id' => 'LNK-98765',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$tiktokPixel = ShortUrlPixel::create([
|
||||
'name' => 'My TikTok Tag',
|
||||
'type' => 'tiktok',
|
||||
'pixel_id' => 'TT-65432',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$pinterestPixel = ShortUrlPixel::create([
|
||||
'name' => 'My Pinterest Tag',
|
||||
'type' => 'pinterest',
|
||||
'pixel_id' => 'PIN-78901',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$link->pixels()->sync([
|
||||
$metaPixel->id,
|
||||
$googlePixel->id,
|
||||
$linkedinPixel->id,
|
||||
$tiktokPixel->id,
|
||||
$pinterestPixel->id,
|
||||
]);
|
||||
|
||||
$response = $this->get('/s/pixelkey');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertViewIs('filament-short-url::pixel-loading');
|
||||
|
||||
// Assert Meta Pixel is rendered
|
||||
$response->assertSee("fbq('init', 'META-12345')", false);
|
||||
|
||||
// Assert Google Analytics is rendered
|
||||
$response->assertSee('https://www.googletagmanager.com/gtag/js?id=G-GA54321', false);
|
||||
$response->assertSee("gtag('config', 'G-GA54321')", false);
|
||||
|
||||
// Assert LinkedIn Insight is rendered
|
||||
$response->assertSee('window._linkedin_data_partner_ids.push("LNK-98765")', false);
|
||||
|
||||
// Assert TikTok Pixel is rendered
|
||||
$response->assertSee("ttq.load('TT-65432')", false);
|
||||
|
||||
// Assert Pinterest Tag is rendered
|
||||
$response->assertSee("pintrk('load', 'PIN-78901')", false);
|
||||
});
|
||||
|
||||
it('bypasses interstitial loading when no pixels are set', function () {
|
||||
$link = ShortUrl::create([
|
||||
'destination_url' => 'https://example.com/direct',
|
||||
'url_key' => 'directkey',
|
||||
]);
|
||||
|
||||
$response = $this->get('/s/directkey');
|
||||
|
||||
$response->assertRedirect('https://example.com/direct');
|
||||
});
|
||||
|
||||
it('does not load inactive pixels from registry', function () {
|
||||
$link = ShortUrl::create([
|
||||
'destination_url' => 'https://example.com/inactive-target',
|
||||
'url_key' => 'inactivekey',
|
||||
]);
|
||||
|
||||
$activePixel = ShortUrlPixel::create([
|
||||
'name' => 'Active Meta Pixel',
|
||||
'type' => 'meta',
|
||||
'pixel_id' => 'META-ACTIVE',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$inactivePixel = ShortUrlPixel::create([
|
||||
'name' => 'Inactive Google Tag',
|
||||
'type' => 'google',
|
||||
'pixel_id' => 'G-INACTIVE',
|
||||
'is_active' => false,
|
||||
]);
|
||||
|
||||
$link->pixels()->sync([
|
||||
$activePixel->id,
|
||||
$inactivePixel->id,
|
||||
]);
|
||||
|
||||
$response = $this->get('/s/inactivekey');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertViewIs('filament-short-url::pixel-loading');
|
||||
|
||||
// Assert active is rendered
|
||||
$response->assertSee("fbq('init', 'META-ACTIVE')", false);
|
||||
|
||||
// Assert inactive is NOT rendered
|
||||
$response->assertDontSee('G-INACTIVE', false);
|
||||
});
|
||||
677
tests/Feature/ShortUrlRedirectTest.php
Normal file
677
tests/Feature/ShortUrlRedirectTest.php
Normal file
@@ -0,0 +1,677 @@
|
||||
<?php
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\ViewShortUrlStats;
|
||||
use Bjanczak\FilamentShortUrl\Jobs\TrackShortUrlVisitJob;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlDailyStats;
|
||||
use Bjanczak\FilamentShortUrl\Services\GeoIpService;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
if (! function_exists('createShortUrl')) {
|
||||
function createShortUrl(array $attrs = []): ShortUrl
|
||||
{
|
||||
return app(ShortUrlService::class)->create(array_merge([
|
||||
'destination_url' => 'https://example.com',
|
||||
], $attrs));
|
||||
}
|
||||
}
|
||||
|
||||
it('redirects to destination url', function () {
|
||||
$shortUrl = createShortUrl(['url_key' => 'abc123', 'track_visits' => false]);
|
||||
|
||||
$this->get('/s/abc123')
|
||||
->assertRedirect('https://example.com');
|
||||
});
|
||||
|
||||
it('returns 404 for unknown key', function () {
|
||||
$this->get('/s/doesnotexist')->assertStatus(404);
|
||||
});
|
||||
|
||||
it('returns 410 for disabled url', function () {
|
||||
createShortUrl(['url_key' => 'disabled1', 'is_enabled' => false, 'track_visits' => false]);
|
||||
|
||||
$this->get('/s/disabled1')->assertStatus(410);
|
||||
});
|
||||
|
||||
it('returns 410 for expired url', function () {
|
||||
createShortUrl([
|
||||
'url_key' => 'expired1',
|
||||
'expires_at' => now()->subDay(),
|
||||
'track_visits' => false,
|
||||
]);
|
||||
|
||||
$this->get('/s/expired1')->assertStatus(410);
|
||||
});
|
||||
|
||||
it('redirects to expiration fallback url when deactivated or expired', function () {
|
||||
createShortUrl([
|
||||
'url_key' => 'fallback-expired',
|
||||
'expires_at' => now()->subDay(),
|
||||
'expiration_redirect_url' => 'https://fallback.example.com',
|
||||
'track_visits' => false,
|
||||
]);
|
||||
|
||||
$this->get('/s/fallback-expired')
|
||||
->assertRedirect('https://fallback.example.com');
|
||||
});
|
||||
|
||||
it('redirects to expiration fallback url when deactivated_at is past', function () {
|
||||
createShortUrl([
|
||||
'url_key' => 'fallback-deactivated',
|
||||
'activated_at' => now()->subDays(2),
|
||||
'deactivated_at' => now()->subDay(),
|
||||
'expiration_redirect_url' => 'https://fallback.example.com',
|
||||
'track_visits' => false,
|
||||
]);
|
||||
|
||||
$this->get('/s/fallback-deactivated')
|
||||
->assertRedirect('https://fallback.example.com');
|
||||
});
|
||||
|
||||
it('redirects to expiration fallback url when activated_at is in future', function () {
|
||||
createShortUrl([
|
||||
'url_key' => 'fallback-not-yet-active',
|
||||
'activated_at' => now()->addDay(),
|
||||
'expiration_redirect_url' => 'https://fallback.example.com',
|
||||
'track_visits' => false,
|
||||
]);
|
||||
|
||||
$this->get('/s/fallback-not-yet-active')
|
||||
->assertRedirect('https://fallback.example.com');
|
||||
});
|
||||
|
||||
it('redirects to destination when activated_at is in past and deactivated_at is in future', function () {
|
||||
createShortUrl([
|
||||
'url_key' => 'active-range',
|
||||
'activated_at' => now()->subDay(),
|
||||
'deactivated_at' => now()->addDay(),
|
||||
'track_visits' => false,
|
||||
]);
|
||||
|
||||
$this->get('/s/active-range')
|
||||
->assertRedirect('https://example.com');
|
||||
});
|
||||
|
||||
it('redirects to expiration fallback when max visits is reached', function () {
|
||||
$shortUrl = createShortUrl([
|
||||
'url_key' => 'fallback-max-visits',
|
||||
'activated_at' => now()->subDay(),
|
||||
'max_visits' => 2,
|
||||
'expiration_redirect_url' => 'https://fallback.example.com',
|
||||
'track_visits' => false,
|
||||
]);
|
||||
|
||||
$shortUrl->total_visits = 2;
|
||||
$shortUrl->save();
|
||||
|
||||
$this->get('/s/fallback-max-visits')
|
||||
->assertRedirect('https://fallback.example.com');
|
||||
});
|
||||
|
||||
it('redirects to destination when max visits is not reached', function () {
|
||||
$shortUrl = createShortUrl([
|
||||
'url_key' => 'under-max-visits',
|
||||
'max_visits' => 2,
|
||||
'expiration_redirect_url' => 'https://fallback.example.com',
|
||||
'track_visits' => false,
|
||||
]);
|
||||
|
||||
$shortUrl->total_visits = 1;
|
||||
$shortUrl->save();
|
||||
|
||||
$this->get('/s/under-max-visits')
|
||||
->assertRedirect('https://example.com');
|
||||
});
|
||||
|
||||
it('disables single-use url after first visit', function () {
|
||||
createShortUrl(['url_key' => 'single1', 'single_use' => true, 'track_visits' => false]);
|
||||
|
||||
$this->get('/s/single1')->assertRedirect();
|
||||
|
||||
$shortUrl = ShortUrl::where('url_key', 'single1')->first();
|
||||
expect($shortUrl->is_enabled)->toBeFalse();
|
||||
});
|
||||
|
||||
it('uses 302 redirect by default', function () {
|
||||
createShortUrl(['url_key' => 'temp302', 'redirect_status_code' => 302, 'track_visits' => false]);
|
||||
|
||||
$this->get('/s/temp302')->assertStatus(302);
|
||||
});
|
||||
|
||||
it('records visit data when tracking is enabled', function () {
|
||||
Queue::fake();
|
||||
|
||||
createShortUrl(['url_key' => 'track1', 'track_visits' => true]);
|
||||
|
||||
$this->get('/s/track1', ['User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0']);
|
||||
|
||||
Queue::assertPushed(
|
||||
TrackShortUrlVisitJob::class
|
||||
);
|
||||
});
|
||||
|
||||
it('does not record visit when tracking is disabled', function () {
|
||||
Queue::fake();
|
||||
|
||||
createShortUrl(['url_key' => 'notrack1', 'track_visits' => false]);
|
||||
|
||||
$this->get('/s/notrack1');
|
||||
|
||||
Queue::assertNotPushed(
|
||||
TrackShortUrlVisitJob::class
|
||||
);
|
||||
});
|
||||
|
||||
it('extracts proxy-resistant client IP address', function () {
|
||||
config(['filament-short-url.queue_connection' => 'sync']);
|
||||
config(['filament-short-url.geo_ip.enabled' => false]);
|
||||
config(['filament-short-url.trust_cdn_headers' => true]);
|
||||
|
||||
$shortUrl = createShortUrl(['url_key' => 'proxyip', 'track_visits' => true]);
|
||||
|
||||
$this->get('/s/proxyip', [
|
||||
'CF-Connecting-IP' => '1.2.3.4',
|
||||
'X-Real-IP' => '5.6.7.8',
|
||||
'X-Forwarded-For' => '9.10.11.12, 13.14.15.16',
|
||||
]);
|
||||
|
||||
$visit = $shortUrl->visits()->first();
|
||||
expect($visit)->not->toBeNull()
|
||||
->and($visit->ip_address)->toBe('1.2.3.4');
|
||||
|
||||
$shortUrl2 = createShortUrl(['url_key' => 'proxyip2', 'track_visits' => true]);
|
||||
$this->get('/s/proxyip2', [
|
||||
'X-Forwarded-For' => '9.10.11.12, 13.14.15.16',
|
||||
]);
|
||||
|
||||
$visit2 = $shortUrl2->visits()->first();
|
||||
expect($visit2->ip_address)->toBe('9.10.11.12');
|
||||
});
|
||||
|
||||
it('resolves country from edge CDN headers offline', function () {
|
||||
config(['filament-short-url.queue_connection' => 'sync']);
|
||||
config(['filament-short-url.geo_ip.enabled' => true]);
|
||||
config(['filament-short-url.geo_ip.driver' => 'headers']);
|
||||
config(['filament-short-url.trust_cdn_headers' => true]);
|
||||
|
||||
$shortUrl = createShortUrl(['url_key' => 'cdngeo', 'track_visits' => true]);
|
||||
|
||||
$this->get('/s/cdngeo', [
|
||||
'CF-IPCountry' => 'PL',
|
||||
]);
|
||||
|
||||
$visit = $shortUrl->visits()->first();
|
||||
expect($visit)->not->toBeNull()
|
||||
->and($visit->country_code)->toBe('PL')
|
||||
->and($visit->country)->toBe('Poland');
|
||||
});
|
||||
|
||||
it('caches stats page calculations', function () {
|
||||
$shortUrl = createShortUrl(['url_key' => 'cachestats']);
|
||||
|
||||
$page = new ViewShortUrlStats;
|
||||
$page->record = $shortUrl;
|
||||
|
||||
// First mount: should be 0 since no visits exist
|
||||
$page->mount($shortUrl);
|
||||
expect($page->totalVisits)->toBe(0);
|
||||
|
||||
// Create a visit in the database (cache is not cleared automatically for this manual action)
|
||||
$shortUrl->visits()->create([
|
||||
'ip_address' => '1.2.3.4',
|
||||
'visited_at' => now(),
|
||||
]);
|
||||
|
||||
// Second mount: should still be 0 because stats are cached
|
||||
$page->mount($shortUrl);
|
||||
expect($page->totalVisits)->toBe(0);
|
||||
|
||||
// Clear the specific date-filtered cache key
|
||||
$dateFrom = now()->subDays(29)->format('Y-m-d');
|
||||
$dateTo = now()->format('Y-m-d');
|
||||
Cache::forget("short_url_stats_{$shortUrl->id}_{$dateFrom}_{$dateTo}");
|
||||
|
||||
// Third mount: should now be 1 because cache is cleared and recalculated
|
||||
$page->mount($shortUrl);
|
||||
expect($page->totalVisits)->toBe(1);
|
||||
});
|
||||
|
||||
it('records visit data with UTM parameters and resolved referer host', function () {
|
||||
config(['filament-short-url.queue_connection' => 'sync']);
|
||||
config(['filament-short-url.geo_ip.enabled' => false]);
|
||||
|
||||
$shortUrl = createShortUrl(['url_key' => 'utmtrack', 'track_visits' => true, 'track_referer_url' => true]);
|
||||
|
||||
$this->get('/s/utmtrack?utm_source=google&utm_medium=cpc&utm_campaign=summer_sale&utm_term=shoes&utm_content=banner', [
|
||||
'Referer' => 'https://m.facebook.com/some/path?query=string',
|
||||
]);
|
||||
|
||||
$visit = $shortUrl->visits()->first();
|
||||
expect($visit)->not->toBeNull();
|
||||
expect($visit->utm_source)->toBe('google');
|
||||
expect($visit->utm_medium)->toBe('cpc');
|
||||
expect($visit->utm_campaign)->toBe('summer_sale');
|
||||
expect($visit->utm_term)->toBe('shoes');
|
||||
expect($visit->utm_content)->toBe('banner');
|
||||
expect($visit->referer_url)->toBe('https://m.facebook.com/some/path?query=string');
|
||||
expect($visit->referer_host)->toBe('facebook.com');
|
||||
});
|
||||
|
||||
it('resolves city from edge CDN headers offline', function () {
|
||||
config(['filament-short-url.queue_connection' => 'sync']);
|
||||
config(['filament-short-url.geo_ip.enabled' => true]);
|
||||
config(['filament-short-url.geo_ip.driver' => 'headers']);
|
||||
config(['filament-short-url.trust_cdn_headers' => true]);
|
||||
|
||||
$shortUrl = createShortUrl(['url_key' => 'cdncity', 'track_visits' => true]);
|
||||
|
||||
$this->get('/s/cdncity', [
|
||||
'CF-IPCountry' => 'US',
|
||||
'CF-IPCity' => 'New York',
|
||||
]);
|
||||
|
||||
$visit = $shortUrl->visits()->first();
|
||||
expect($visit)->not->toBeNull()
|
||||
->and($visit->country_code)->toBe('US')
|
||||
->and($visit->country)->toBe('United States')
|
||||
->and($visit->city)->toBe('New York');
|
||||
});
|
||||
|
||||
it('resolves country and city from ip-api driver', function () {
|
||||
Http::fake([
|
||||
'http://ip-api.com/*' => Http::response([
|
||||
'status' => 'success',
|
||||
'country' => 'Germany',
|
||||
'countryCode' => 'DE',
|
||||
'city' => 'Berlin',
|
||||
], 200),
|
||||
]);
|
||||
|
||||
config(['filament-short-url.geo_ip.enabled' => true]);
|
||||
config(['filament-short-url.geo_ip.driver' => 'ip-api']);
|
||||
|
||||
$geoIpService = app(GeoIpService::class);
|
||||
$result = $geoIpService->resolve('8.8.8.8');
|
||||
|
||||
expect($result)->toBe([
|
||||
'country' => 'Germany',
|
||||
'country_code' => 'DE',
|
||||
'city' => 'Berlin',
|
||||
]);
|
||||
});
|
||||
|
||||
it('applies rate limiting on redirects when enabled', function () {
|
||||
config(['filament-short-url.rate_limiting.enabled' => true]);
|
||||
config(['filament-short-url.rate_limiting.max_attempts' => 2]);
|
||||
config(['filament-short-url.rate_limiting.decay_seconds' => 10]);
|
||||
|
||||
$shortUrl = createShortUrl(['url_key' => 'ratelimit1', 'track_visits' => false]);
|
||||
|
||||
// Attempt 1: Success
|
||||
$this->get('/s/ratelimit1')->assertRedirect('https://example.com');
|
||||
|
||||
// Attempt 2: Success
|
||||
$this->get('/s/ratelimit1')->assertRedirect('https://example.com');
|
||||
|
||||
// Attempt 3: Rate limited (429)
|
||||
$this->get('/s/ratelimit1')->assertStatus(429);
|
||||
});
|
||||
|
||||
it('requires password to redirect when protected', function () {
|
||||
$shortUrl = createShortUrl([
|
||||
'url_key' => 'password123',
|
||||
'password' => 'secret-key',
|
||||
'track_visits' => false,
|
||||
]);
|
||||
|
||||
// Unauthenticated request should render password prompt view
|
||||
$response = $this->get('/s/password123');
|
||||
$response->assertStatus(200);
|
||||
$response->assertSee('Password Required');
|
||||
|
||||
// Send incorrect password
|
||||
$response = $this->post('/s/password123', ['password' => 'wrong']);
|
||||
$response->assertStatus(200);
|
||||
$response->assertSee('Incorrect password');
|
||||
|
||||
// Send correct password
|
||||
$response = $this->post('/s/password123', ['password' => 'secret-key']);
|
||||
$response->assertRedirect('/s/password123');
|
||||
|
||||
// Following request should redirect to target
|
||||
$this->get('/s/password123')->assertRedirect('https://example.com');
|
||||
});
|
||||
|
||||
it('shows warning page before redirecting when enabled', function () {
|
||||
$shortUrl = createShortUrl([
|
||||
'url_key' => 'warn1',
|
||||
'show_warning_page' => true,
|
||||
'track_visits' => false,
|
||||
]);
|
||||
|
||||
// Unconfirmed visit should render warning page
|
||||
$response = $this->get('/s/warn1');
|
||||
$response->assertStatus(200);
|
||||
$response->assertSee('Security Redirect Warning');
|
||||
$response->assertSee('https://example.com');
|
||||
|
||||
// Confirmed visit should redirect
|
||||
$this->get('/s/warn1?confirmed=1')->assertRedirect('https://example.com');
|
||||
});
|
||||
|
||||
it('requires password first, then shows warning page when both are enabled', function () {
|
||||
$shortUrl = createShortUrl([
|
||||
'url_key' => 'both-secure',
|
||||
'password' => 'secret-combination',
|
||||
'show_warning_page' => true,
|
||||
'track_visits' => false,
|
||||
]);
|
||||
|
||||
// 1. Visit without password -> password prompt page, not warning page
|
||||
$response = $this->get('/s/both-secure');
|
||||
$response->assertStatus(200);
|
||||
$response->assertSee('Password Required');
|
||||
$response->assertDontSee('Security Redirect Warning');
|
||||
|
||||
// 2. Submit wrong password -> password prompt page with error
|
||||
$response = $this->post('/s/both-secure', ['password' => 'wrong']);
|
||||
$response->assertStatus(200);
|
||||
$response->assertSee('Incorrect password');
|
||||
$response->assertDontSee('Security Redirect Warning');
|
||||
|
||||
// 3. Submit correct password -> redirects back to the short URL path
|
||||
$response = $this->post('/s/both-secure', ['password' => 'secret-combination']);
|
||||
$response->assertRedirect('/s/both-secure');
|
||||
|
||||
// 4. Follow redirect (GET request with authenticated session, but without confirmed parameter)
|
||||
// -> shows redirect warning page, not direct redirect
|
||||
$response = $this->get('/s/both-secure');
|
||||
$response->assertStatus(200);
|
||||
$response->assertSee('Security Redirect Warning');
|
||||
$response->assertSee('https://example.com');
|
||||
|
||||
// 5. Follow the confirmed link (confirmed=1) -> redirects to destination
|
||||
$this->get('/s/both-secure?confirmed=1')
|
||||
->assertRedirect('https://example.com');
|
||||
});
|
||||
|
||||
it('targets redirect by device type', function () {
|
||||
$shortUrl = createShortUrl([
|
||||
'url_key' => 'device-target',
|
||||
'track_visits' => false,
|
||||
'targeting_rules' => [
|
||||
'type' => 'device',
|
||||
'device' => [
|
||||
'ios' => 'https://ios.example.com',
|
||||
'android' => 'https://android.example.com',
|
||||
'desktop' => 'https://desktop.example.com',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
// iOS
|
||||
$this->get('/s/device-target', ['User-Agent' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)'])
|
||||
->assertRedirect('https://ios.example.com');
|
||||
|
||||
// Android
|
||||
$this->get('/s/device-target', ['User-Agent' => 'Mozilla/5.0 (Linux; Android 10)'])
|
||||
->assertRedirect('https://android.example.com');
|
||||
|
||||
// Desktop
|
||||
$this->get('/s/device-target', ['User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'])
|
||||
->assertRedirect('https://desktop.example.com');
|
||||
});
|
||||
|
||||
it('targets redirect by country code', function () {
|
||||
config(['filament-short-url.trust_cdn_headers' => true]);
|
||||
|
||||
$shortUrl = createShortUrl([
|
||||
'url_key' => 'geo-target',
|
||||
'track_visits' => false,
|
||||
'targeting_rules' => [
|
||||
'type' => 'geo',
|
||||
'geo' => [
|
||||
['country_code' => 'PL', 'url' => 'https://pl.example.com'],
|
||||
['country_code' => 'US', 'url' => 'https://us.example.com'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
// PL
|
||||
$this->get('/s/geo-target', ['CF-IPCountry' => 'PL'])
|
||||
->assertRedirect('https://pl.example.com');
|
||||
|
||||
// US
|
||||
$this->get('/s/geo-target', ['CF-IPCountry' => 'US'])
|
||||
->assertRedirect('https://us.example.com');
|
||||
|
||||
// Other (fallback)
|
||||
$this->get('/s/geo-target', ['CF-IPCountry' => 'DE'])
|
||||
->assertRedirect('https://example.com');
|
||||
});
|
||||
|
||||
it('targets redirect by browser language', function () {
|
||||
$shortUrl = createShortUrl([
|
||||
'url_key' => 'lang-target',
|
||||
'track_visits' => false,
|
||||
'targeting_rules' => [
|
||||
'type' => 'language',
|
||||
'language' => [
|
||||
['language_code' => 'PL', 'url' => 'https://pl.example.com'],
|
||||
['language_code' => 'en-US', 'url' => 'https://enus.example.com'],
|
||||
['language_code' => 'de', 'url' => 'https://de.example.com'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
// PL (matching base pl)
|
||||
$this->get('/s/lang-target', ['Accept-Language' => 'pl-PL,pl;q=0.9'])
|
||||
->assertRedirect('https://pl.example.com');
|
||||
|
||||
// en-US (matching exact locale en-US)
|
||||
$this->get('/s/lang-target', ['Accept-Language' => 'en-US,en;q=0.8'])
|
||||
->assertRedirect('https://enus.example.com');
|
||||
|
||||
// de (matching base de from de-DE)
|
||||
$this->get('/s/lang-target', ['Accept-Language' => 'de-DE,de;q=0.8'])
|
||||
->assertRedirect('https://de.example.com');
|
||||
|
||||
// Other (fallback)
|
||||
$this->get('/s/lang-target', ['Accept-Language' => 'fr-FR,fr;q=0.9'])
|
||||
->assertRedirect('https://example.com');
|
||||
});
|
||||
|
||||
it('targets redirect by rotation rules', function () {
|
||||
$shortUrl = createShortUrl([
|
||||
'url_key' => 'rotation-target',
|
||||
'track_visits' => false,
|
||||
'targeting_rules' => [
|
||||
'type' => 'rotation',
|
||||
'rotation' => [
|
||||
['url' => 'https://a.example.com', 'weight' => 100],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->get('/s/rotation-target')->assertRedirect('https://a.example.com');
|
||||
});
|
||||
|
||||
it('aggregates old visits and prunes logs', function () {
|
||||
config(['filament-short-url.pruning.enabled' => true]);
|
||||
config(['filament-short-url.pruning.retention_days' => 7]);
|
||||
|
||||
$shortUrl = createShortUrl(['url_key' => 'agg1']);
|
||||
|
||||
// Create raw visit from yesterday
|
||||
$visitYesterday = $shortUrl->visits()->create([
|
||||
'ip_address' => '1.1.1.1',
|
||||
'ip_hash' => hash('sha256', '1.1.1.1'),
|
||||
'visited_at' => now()->subDay(),
|
||||
'device_type' => 'desktop',
|
||||
'browser' => 'Chrome',
|
||||
'operating_system' => 'Windows',
|
||||
'country_code' => 'PL',
|
||||
'country' => 'Poland',
|
||||
]);
|
||||
|
||||
// Create raw visit older than retention window (8 days ago)
|
||||
$visitOld = $shortUrl->visits()->create([
|
||||
'ip_address' => '2.2.2.2',
|
||||
'ip_hash' => hash('sha256', '2.2.2.2'),
|
||||
'visited_at' => now()->subDays(8),
|
||||
'device_type' => 'mobile',
|
||||
'browser' => 'Safari',
|
||||
'operating_system' => 'iOS',
|
||||
'country_code' => 'US',
|
||||
'country' => 'United States',
|
||||
]);
|
||||
|
||||
// Run aggregation command
|
||||
$this->artisan('short-url:aggregate-and-prune')->assertSuccessful();
|
||||
|
||||
// Verify stats were aggregated for yesterday (Eloquent-based to handle date cast differences across DBs)
|
||||
$statsYesterday = ShortUrlDailyStats::where('short_url_id', $shortUrl->id)
|
||||
->whereDate('date', now()->subDay()->toDateString())
|
||||
->first();
|
||||
|
||||
expect($statsYesterday)->not->toBeNull();
|
||||
expect($statsYesterday->visits_count)->toBe(1);
|
||||
expect($statsYesterday->unique_visits_count)->toBe(1);
|
||||
|
||||
// Verify stats were aggregated for 8 days ago
|
||||
$statsOld = ShortUrlDailyStats::where('short_url_id', $shortUrl->id)
|
||||
->whereDate('date', now()->subDays(8)->toDateString())
|
||||
->first();
|
||||
|
||||
expect($statsOld)->not->toBeNull();
|
||||
expect($statsOld->visits_count)->toBe(1);
|
||||
expect($statsOld->unique_visits_count)->toBe(1);
|
||||
|
||||
// Check pruning: visit from 8 days ago should be deleted, yesterday should remain
|
||||
$this->assertDatabaseMissing('short_url_visits', ['id' => $visitOld->id]);
|
||||
$this->assertDatabaseHas('short_url_visits', ['id' => $visitYesterday->id]);
|
||||
});
|
||||
|
||||
it('tracks QR code scan visits via query parameters', function () {
|
||||
config(['filament-short-url.queue_connection' => 'sync']);
|
||||
|
||||
$shortUrl = createShortUrl(['url_key' => 'qrtest1', 'track_visits' => true]);
|
||||
|
||||
// Test ?source=qr
|
||||
$this->get('/s/qrtest1?source=qr');
|
||||
$visit1 = $shortUrl->visits()->latest('id')->first();
|
||||
expect($visit1)->not->toBeNull()
|
||||
->and($visit1->is_qr_scan)->toBeTrue();
|
||||
|
||||
// Test ?qr=1
|
||||
$this->get('/s/qrtest1?qr=1');
|
||||
$visit2 = $shortUrl->visits()->latest('id')->first();
|
||||
expect($visit2)->not->toBeNull()
|
||||
->and($visit2->is_qr_scan)->toBeTrue();
|
||||
|
||||
// Test direct visit
|
||||
$this->get('/s/qrtest1');
|
||||
$visit3 = $shortUrl->visits()->latest('id')->first();
|
||||
expect($visit3)->not->toBeNull()
|
||||
->and($visit3->is_qr_scan)->toBeFalse();
|
||||
|
||||
$shortUrl->refresh();
|
||||
expect($shortUrl->qr_scans)->toBe(2);
|
||||
});
|
||||
|
||||
it('extracts and tracks visitor browser language from headers', function () {
|
||||
config(['filament-short-url.queue_connection' => 'sync']);
|
||||
|
||||
$shortUrl = createShortUrl(['url_key' => 'langtest1', 'track_visits' => true]);
|
||||
|
||||
$this->get('/s/langtest1', [
|
||||
'Accept-Language' => 'pl-PL,pl;q=0.9,en-US;q=0.8,en;q=0.7',
|
||||
]);
|
||||
|
||||
$visit = $shortUrl->visits()->first();
|
||||
expect($visit)->not->toBeNull()
|
||||
->and($visit->browser_language)->toBe('pl');
|
||||
});
|
||||
|
||||
it('does not track browser language if track_browser_language is disabled', function () {
|
||||
config(['filament-short-url.queue_connection' => 'sync']);
|
||||
|
||||
$shortUrl = createShortUrl([
|
||||
'url_key' => 'langtest2',
|
||||
'track_visits' => true,
|
||||
'track_browser_language' => false,
|
||||
]);
|
||||
|
||||
$this->get('/s/langtest2', [
|
||||
'Accept-Language' => 'pl-PL,pl;q=0.9,en-US;q=0.8,en;q=0.7',
|
||||
]);
|
||||
|
||||
$visit = $shortUrl->visits()->first();
|
||||
expect($visit)->not->toBeNull()
|
||||
->and($visit->browser_language)->toBeNull();
|
||||
});
|
||||
|
||||
it('tracks browser language if track_browser_language is enabled', function () {
|
||||
config(['filament-short-url.queue_connection' => 'sync']);
|
||||
|
||||
$shortUrl = createShortUrl([
|
||||
'url_key' => 'langtest3',
|
||||
'track_visits' => true,
|
||||
'track_browser_language' => true,
|
||||
]);
|
||||
|
||||
$this->get('/s/langtest3', [
|
||||
'Accept-Language' => 'fr-FR,fr;q=0.9',
|
||||
]);
|
||||
|
||||
$visit = $shortUrl->visits()->first();
|
||||
expect($visit)->not->toBeNull()
|
||||
->and($visit->browser_language)->toBe('fr');
|
||||
});
|
||||
|
||||
it('enforces max_visits in real-time even when model caching is active', function () {
|
||||
config([
|
||||
'filament-short-url.cache_ttl' => 3600,
|
||||
'filament-short-url.queue_connection' => 'sync',
|
||||
]);
|
||||
|
||||
$shortUrl = createShortUrl([
|
||||
'url_key' => 'max-visits-cache',
|
||||
'max_visits' => 2,
|
||||
'track_visits' => true,
|
||||
]);
|
||||
|
||||
// First visit: gets cached, redirects
|
||||
$this->get('/s/max-visits-cache')->assertRedirect('https://example.com');
|
||||
|
||||
// Second visit: loaded from cache, redirects
|
||||
$this->get('/s/max-visits-cache')->assertRedirect('https://example.com');
|
||||
|
||||
// Third visit: should detect limit reached and return 410
|
||||
$this->get('/s/max-visits-cache')->assertStatus(410);
|
||||
});
|
||||
|
||||
it('enforces single_use in real-time even when model caching is active', function () {
|
||||
config([
|
||||
'filament-short-url.cache_ttl' => 3600,
|
||||
'filament-short-url.queue_connection' => 'sync',
|
||||
]);
|
||||
|
||||
$shortUrl = createShortUrl([
|
||||
'url_key' => 'single-use-cache',
|
||||
'single_use' => true,
|
||||
'track_visits' => true,
|
||||
]);
|
||||
|
||||
// First visit: redirects and disables the URL
|
||||
$this->get('/s/single-use-cache')->assertRedirect('https://example.com');
|
||||
|
||||
// Second visit: should return 410, even if the model exists in the cache as enabled
|
||||
$this->get('/s/single-use-cache')->assertStatus(410);
|
||||
});
|
||||
Reference in New Issue
Block a user