Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b9411808e | ||
|
|
3a9575f5b1 | ||
|
|
325a38bfe8 | ||
|
|
1cd85af0a1 | ||
|
|
137a81dcab | ||
|
|
276b75ad9f | ||
|
|
d8d6b1b22f | ||
|
|
0d4eab2016 | ||
|
|
0349825890 | ||
|
|
e1e6ded015 | ||
|
|
c5147ac170 | ||
|
|
0cdc4ad0db | ||
|
|
5e2b266793 | ||
|
|
26247fd94a | ||
|
|
f3623df340 | ||
|
|
48ecb8ed28 | ||
|
|
d67bd56f68 | ||
|
|
d075ee83b0 | ||
|
|
67ce293685 | ||
|
|
ce8062036a | ||
|
|
e8e9f26c9b | ||
|
|
da810db511 | ||
|
|
8de83e3973 | ||
|
|
a5421a2d30 | ||
|
|
d1eb322cb6 | ||
|
|
3d62dbcbb7 | ||
|
|
c650c1f205 |
30
CONTRIBUTING.md
Normal file
30
CONTRIBUTING.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Contributing to Filament Short URL
|
||||
|
||||
Thank you for considering contributing to the Filament Short URL plugin!
|
||||
|
||||
## Development Setup
|
||||
|
||||
If you are modifying the plugin source code and need to recompile its stylesheet after changing Blade, PHP, or configuration files, follow these steps:
|
||||
|
||||
### 1. Recompile Plugin CSS (Tailwind CSS v4)
|
||||
Run the Tailwind CSS CLI from the root folder of your host application to scan the plugin's views and compile the final stylesheet:
|
||||
|
||||
```bash
|
||||
npx @tailwindcss/cli -i ./packages/filament-short-url/resources/css/plugin.css \
|
||||
-o ./packages/filament-short-url/resources/dist/filament-short-url.css --minify
|
||||
```
|
||||
|
||||
### 2. Re-publish compiled assets
|
||||
After compiling, publish the updated CSS stylesheet to the public folder of your host application so the browser can load it:
|
||||
|
||||
```bash
|
||||
php artisan filament:assets
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
We enforce test coverage for all features. To run the tests from the root of your host application:
|
||||
|
||||
```bash
|
||||
php artisan test --filter=ShortUrl
|
||||
```
|
||||
33
LICENSE
33
LICENSE
@@ -1,21 +1,22 @@
|
||||
MIT License
|
||||
Custom Source-Available License
|
||||
|
||||
Copyright (c) 2026 Bartek Janczak
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
1. Grant of License
|
||||
Subject to the terms of this License, the Copyright Holder hereby grants you a worldwide, royalty-free, non-exclusive, non-sublicensable license to:
|
||||
- Use the Software for personal, internal, or commercial purposes.
|
||||
- Modify the Software for your own personal, internal, or commercial use.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
2. Restrictions
|
||||
You are strictly prohibited from:
|
||||
- Redistributing the Software, in whole or in part, or any modified versions thereof, in any public repository (including but not limited to public repositories on GitHub, GitLab, Bitbucket) or package registry (such as Packagist).
|
||||
- Selling, renting, leasing, or sublicensing the Software or any modified versions thereof as a standalone package or library.
|
||||
- Publishing, distributing, or sharing the Software under a different name or package name.
|
||||
|
||||
3. Copyright Notice
|
||||
The above copyright notice and this permission notice must be included in all copies or substantial portions of the Software.
|
||||
|
||||
4. Disclaimer of Warranty
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
418
README.md
418
README.md
@@ -6,26 +6,38 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="https://packagist.org/packages/janczakb/filament-short-url"><img src="https://img.shields.io/packagist/v/janczakb/filament-short-url.svg?style=flat-square" alt="Latest Version"></a>
|
||||
<a href="https://github.com/janczakb/filament-short-url/blob/main/LICENSE"><img src="https://img.shields.io/github/license/janczakb/filament-short-url.svg?style=flat-square" alt="License"></a>
|
||||
<a href="https://github.com/janczakb/filament-short-url/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-proprietary-7c3aed.svg?style=flat-square" alt="License"></a>
|
||||
<a href="https://packagist.org/packages/janczakb/filament-short-url"><img src="https://img.shields.io/packagist/dt/janczakb/filament-short-url.svg?style=flat-square" alt="Total Downloads"></a>
|
||||
<a href="https://github.com/janczakb/filament-short-url/stargazers"><img src="https://img.shields.io/github/stars/janczakb/filament-short-url.svg?style=flat-square" alt="GitHub Stars"></a>
|
||||
<a href="https://github.com/janczakb/filament-short-url/issues"><img src="https://img.shields.io/github/issues/janczakb/filament-short-url.svg?style=flat-square" alt="GitHub Issues"></a>
|
||||
<a href="https://github.com/janczakb/filament-short-url/actions"><img src="https://img.shields.io/badge/tests-passing-success.svg?style=flat-square" alt="Tests"></a>
|
||||
</p>
|
||||
|
||||
A professional, high-performance **Short URL Manager** plugin for [Filament v5](https://filamentphp.com). Built from scratch with cutting-edge practices, proxy resistance, offline Geo-IP engines, enterprise-grade smart targeting, and zero external shortening API dependencies.
|
||||
A professional, high-performance **Short URL Manager, Redirect Engine & QR Code Generator** plugin for [Filament v5](https://filamentphp.com).
|
||||
|
||||
Acting as a self-hosted, enterprise-grade alternative to Bitly and Rebrandly, this package provides advanced link shortening, mobile app deep linking (Universal Links & Android App Links), client-side retargeting pixels, offline Geo-IP country routing, real-time analytics, and webhooks—with zero external API dependencies.
|
||||
|
||||
### Why choose Filament Short URL?
|
||||
* **Save on SaaS Costs**: Replace expensive Bitly or Rebrandly subscriptions with a self-hosted solution that has zero click or creation limits.
|
||||
* **Server-Side GA4 Tracking**: Bypasses browser-side ad blockers completely to ensure 100% accurate traffic data.
|
||||
* **Retarget on External Sites**: Inject tracking pixels (Meta, Google Ads, LinkedIn, TikTok, Pinterest) on a beautiful glassmorphic redirect page before forwarding visitors.
|
||||
* **Seamless Mobile App Redirects**: Launch native mobile applications (Instagram, YouTube, Spotify, WhatsApp) automatically using deep links.
|
||||
* **GDPR-Friendly Geo-IP**: Resolve visitor countries offline using local MaxMind databases or trust your CDN headers (Cloudflare, CloudFront).
|
||||
|
||||
## Screenshots
|
||||
|
||||
<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>
|
||||
@@ -34,28 +46,33 @@ A professional, high-performance **Short URL Manager** plugin for [Filament v5](
|
||||
|
||||
## Features
|
||||
|
||||
- 🔗 **Short URL Generation** — Create custom links or let the system auto-generate collision-free Base62 keys.
|
||||
- 🌍 **Multiple Geo-IP Drivers** — High-speed offline detection using local MaxMind databases, edge-provided CDN headers (Cloudflare, CloudFront, generic), or fallback API integration.
|
||||
- 🗺️ **Visitor World Map Widget** — Interactive SVG world map on the link statistics dashboard showcasing the geographical distribution of visitor clicks with hover details.
|
||||
- 📈 **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.
|
||||
- ⚡ **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).
|
||||
- 🔒 **Link Validity Ranges & Expiry** — Set activation date ranges (From - To), custom visit limit counters (e.g. active for 3 clicks then expires), single-use restrictions, and custom fallback redirect URLs on expiration instead of static 410 Gone errors.
|
||||
- ➡️ **Query Parameter Forwarding** — Dynamically forward client query parameters (e.g. ad tokens, discount codes) to the destination URL.
|
||||
- 🛠️ **Dedicated Settings GUI** — Manage global configuration (routing, Geo-IP, GA4, cache, rate limiting, aggregation) directly inside the Filament panel without modifying files or `.env` files.
|
||||
- 💻 **Fluent Developer Builder** — Native model query builder pattern and robust programmatic generation APIs.
|
||||
- 🔑 **Password-Protected Links** — Require a password before redirecting visitors, with session-based unlock.
|
||||
- ⚠️ **Redirect Warning Pages** — Show an interstitial security page before redirecting to external URLs (phishing/NSFW protection).
|
||||
- 🎯 **Smart Link Targeting** — Route visitors to different destination URLs based on their device type, country, or via weighted A/B split rotation.
|
||||
- 🛡️ **Rate Limiting / Bot Protection** — Configurable per-IP rate limits on redirects with automatic `429 Too Many Requests` responses.
|
||||
- 📊 **Daily Stats Aggregation & Pruning** — Automatic daily summarization of raw visit logs into compact daily stats tables. Configurable retention window prevents unbounded database growth at scale.
|
||||
- 🎯 **Social Retargeting Pixels** *(new in v1.5)* — Inject Meta Pixel, Google Tag, and LinkedIn Insight tracking scripts client-side via a premium glassmorphic interstitial page. Build remarketing audiences even when redirecting to external domains.
|
||||
- 🔌 **Developer REST API** *(new in v1.5)* — Full REST API (`GET`, `POST`, `DELETE`) for external integrations. Secured with API Key authentication managed via the Settings panel.
|
||||
- 📡 **Webhooks** *(new in v1.5)* — Real-time HTTP POST event notifications on every click, link creation, or expiration. Configure per-link or globally, dispatched asynchronously via the queue.
|
||||
- 🔗 **Base62 Short Link Generation** — Create clean, custom short links or let the system auto-generate collision-free Base62 keys.
|
||||
- 🌍 **Multiple Geo-IP Drivers** — Route and analyze traffic with offline MaxMind detection, CDN edge headers (Cloudflare's `CF-IPCountry`, CloudFront), or fallback APIs.
|
||||
- 🗺️ **Interactive Visitor World Map** — Showcase geographic click distribution on a beautiful SVG world map widget with real-time hover details.
|
||||
- 📈 **Comprehensive Analytics Dashboard** — Monitor total/unique visits, referrers, operating systems, devices, browsers, and top browser languages in real-time.
|
||||
- 🛡️ **VPN, Proxy & Bot Filtering** — Exclude scrapers, crawlers, Tor exit nodes, and automated bot clicks to keep your analytics clean and accurate.
|
||||
- 🔍 **Google Safe Browsing** — Automatically scan target URLs on creation/edit to block phishing, malware, and social engineering links.
|
||||
- 🎨 **SVG QR Code Designer** — Customize dot styles, gradients, margins, and upload custom brand logos with auto-clear backing dots and high-quality SVG/PNG exports.
|
||||
- 📊 **Dedicated QR Code Tracking** — Differentiate physical QR scans from direct web clicks with automatically appended query parameters (`?source=qr`).
|
||||
- 🌐 **Browser Language Targeting** — Route visitors dynamically based on their browser language preferences (e.g., redirect Polish speakers to a Polish landing page).
|
||||
- ⚡ **Ultra-Fast Redirections** — Redirections resolve in milliseconds. Logging, GA4 payloads, and webhooks are processed asynchronously in the background.
|
||||
- 🎯 **Server-Side GA4 Integration** — Send server-side `short_url_visit` hits using the Google Analytics 4 Measurement Protocol to bypass ad-blockers.
|
||||
- ⚙️ **Dual-way UTM Builder** — Build campaign URLs with a real-time synchronized UTM builder directly in the link creation form.
|
||||
- 🔒 **Link Expiration & Fallbacks** — Set activation date ranges, click-limit caps, single-use restrictions, and custom redirection fallbacks on expiration.
|
||||
- ➡️ **Query Parameter Forwarding** — Automatically forward client query strings (e.g., ad tokens, UTM parameters, discount codes) to the destination.
|
||||
- 🛠️ **Central Settings Panel** — Manage routes, Geo-IP, GA4, caching, rate limiting, and retention directly inside your Filament panel.
|
||||
- 🔑 **Password-Protected Links** — Secure sensitive links with a customizable, session-based password entry interstitial.
|
||||
- ⚠️ **Redirect Warning Interstitials** — Show a security warning page to verify external links (phishing and NSFW protection).
|
||||
- 🎯 **Advanced Smart Targeting** — Redirect visitors dynamically based on device type (iOS, Android, Desktop), country (Geo-IP), or browser language.
|
||||
- ⚖️ **A/B Split Testing Rotation** — Distribute traffic randomly across multiple landing pages using custom weighted rotation rules.
|
||||
- 🛡️ **Throttling & Rate Limiting** — Protect your redirection routes from flood attacks with configurable per-IP rate limits.
|
||||
- 📊 **Log Aggregation & Pruning** — Compact millions of raw visit logs into daily summaries automatically to prevent database bloat.
|
||||
- 🎯 **Central Retargeting Pixel Registry (new in v3.0.0)** — Register Meta Pixel, Google Tag, LinkedIn Insight, TikTok Pixel, and Pinterest Tag centrally and associate them with links via checkboxes.
|
||||
- 🔌 **Developer REST API** — Full programmatical control with secure API Key authentication to create, list, and delete short links externally.
|
||||
- 📡 **Real-Time Webhooks** — Asynchronous HTTP POST notifications on `visited`, `created`, `expired`, and `limit_reached` events with a built-in retry policy.
|
||||
- 📱 **Mobile App Deep Linking (new in v3.0.0)** — Detect mobile visitors and open links directly in 24+ native apps (Instagram, YouTube, Spotify, TikTok, etc.) using custom URI schemes.
|
||||
- 🔗 **Universal Links & App Links (new in v3.0.0)** — Host iOS `apple-app-site-association` and Android `assetlinks.json` domain configuration files directly from your root domain.
|
||||
- 🎨 **Branded Expiry Pages (new in v3.0.0)** — Display a premium, dark-mode compatible custom expiry page when a link is deactivated or limit-reached, falling back to a clean site-name greeting instead of a generic browser error.
|
||||
|
||||
---
|
||||
|
||||
@@ -117,6 +134,13 @@ php artisan filament:assets
|
||||
That's all. The plugin's CSS will be served from `public/css/janczakb/filament-short-url/filament-short-url.css` and Filament registers it automatically.
|
||||
|
||||
> **You do not need to run Tailwind, Vite, or npm for the plugin styles.** The compiled file is included in the package.
|
||||
>
|
||||
> **Using a Custom Filament Theme (Tailwind CSS v4)?**
|
||||
> If you are compiling your own Filament panel theme stylesheet, you can optionally tell Tailwind to scan the plugin's views by adding the `@source` directive to your theme's CSS file (e.g., `resources/css/filament/admin/theme.css`):
|
||||
>
|
||||
> ```css
|
||||
> @source './vendor/janczakb/filament-short-url/resources/views/**/*.blade.php';
|
||||
> ```
|
||||
|
||||
**Tip — automate on every `composer install` / `composer update`:**
|
||||
|
||||
@@ -134,21 +158,6 @@ Add `filament:assets` to the `post-autoload-dump` scripts in your application's
|
||||
|
||||
---
|
||||
|
||||
> #### 🛠 For Plugin Developers Only
|
||||
>
|
||||
> If you are modifying the plugin source and need to recompile its stylesheet after changing Blade/PHP files:
|
||||
>
|
||||
> ```bash
|
||||
> # 1. Recompile the plugin CSS with Tailwind v4
|
||||
> npx @tailwindcss/cli -i ./packages/filament-short-url/resources/css/plugin.css \
|
||||
> -o ./packages/filament-short-url/resources/dist/filament-short-url.css --minify
|
||||
>
|
||||
> # 2. Re-publish the compiled asset to public/
|
||||
> php artisan filament:assets
|
||||
> ```
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
Register the plugin in your Filament Panel Provider (`app/Providers/Filament/AdminPanelProvider.php`):
|
||||
@@ -178,19 +187,67 @@ 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.
|
||||
|
||||
---
|
||||
|
||||
## Global Settings GUI
|
||||
|
||||
The package comes with a built-in admin settings dashboard. You can access it by clicking the **Settings** action button on the top-right header of the Short URLs resource.
|
||||
The package comes with a built-in admin settings dashboard. It is accessible directly from your sidebar menu under the same navigation group as your links.
|
||||
|
||||
Settings are stored dynamically in `storage/app/filament-short-url-settings.json` and immediately override config defaults.
|
||||
Settings are stored dynamically in the database (`short_url_settings` table), cached indefinitely, and immediately override config defaults. Legacy settings from `filament-short-url-settings.json` are automatically imported on first load.
|
||||
|
||||
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 +267,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.
|
||||
@@ -275,6 +322,18 @@ This feature is useful for NSFW links, external partner links, or any URL that l
|
||||
|
||||
---
|
||||
|
||||
## Custom Branded Expiry Pages (new in v3.0.0)
|
||||
|
||||
When a short URL is expired, deactivated, or has reached its maximum visit limit, it needs to handle the redirect gracefully:
|
||||
* **Custom Fallback URL**: If configured, visitors are immediately redirected to the `expiration_redirect_url` target.
|
||||
* **Branded Expiry Page (Default)**: If no fallback URL is specified, the system displays a premium branded, fully localized, dark-mode compatible HTML page (`expired.blade.php`) instead of a generic browser `410 Gone` error.
|
||||
- Automatically displays your customized **Site Name** (and host application logo if the `setting('logo_path')` helper is defined).
|
||||
- Displays a clean visual alert state with details about the expired link.
|
||||
- Features a friendly back button pointing to your website's homepage.
|
||||
- Can be easily customized by publishing package views: `php artisan vendor:publish --tag=filament-short-url-views`.
|
||||
|
||||
---
|
||||
|
||||
## Smart Link Targeting (new in v1.2.0)
|
||||
|
||||
The **Targeting & Security** tab exposes a powerful rule engine that lets you route different visitors to different destinations — all from a single short URL.
|
||||
@@ -320,7 +379,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
|
||||
@@ -339,6 +414,78 @@ $shortUrl->update([
|
||||
|
||||
---
|
||||
|
||||
## Native App Linking & Deep Linking (new in v3.0.0)
|
||||
|
||||
This package supports two distinct levels of mobile app integration: **Per-Link App Linking** (client-side redirects using custom schemes) and **Global Deep Linking Files** (domain association files for OS-level native integration).
|
||||
|
||||
### 1. Per-Link App Linking (Mobile Auto-Open)
|
||||
|
||||
When creating or editing a short URL, the **App Linking** tab allows you to configure automatic redirects into native mobile applications.
|
||||
|
||||
* **How it works**: If a destination URL matches one of the 24+ pre-configured native applications (such as YouTube, TikTok, Instagram, Facebook, Spotify, WhatsApp, Messenger, etc.), the plugin can bypass standard web views for mobile visitors.
|
||||
* **The Interstitial Experience**: If **Auto open app on mobile** is enabled, mobile visitors are shown a premium glassmorphic redirect interstitial page that triggers the corresponding custom URL scheme (e.g. `whatsapp://`, `instagram://`, `youtube://`) to launch the native app directly, with fallback options to open in a web browser.
|
||||
* **Interactive Panel Preview**: Inside the Filament resource edit form, a live preview widget demonstrates if the URL was matched, showing:
|
||||
- The matched app with its official favicon.
|
||||
- The calculated deep link scheme.
|
||||
- An interactive grid showing all supported native applications.
|
||||
|
||||
Supported apps include: YouTube, TikTok, Instagram, X (Twitter), Spotify, Facebook, Reddit, Snapchat, WhatsApp, LinkedIn, Pinterest, Twitch, Netflix, Google Docs/Sheets/Slides/Maps, Messenger, Apple Music, Airbnb, TripAdvisor, Amazon, StockX, Booking, AliExpress.
|
||||
|
||||
---
|
||||
|
||||
### 2. Global Deep Linking Files (Universal Links & App Links)
|
||||
|
||||
To support seamless OS-level integrations without browser intermediaries—such as iOS Universal Links and Android App Links—you can serve domain association files directly from your application's root domain.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Disabled by default**: This feature is turned **off** by default. You can enable it and customize the association JSON in your settings panel.
|
||||
|
||||
#### Enabling and Configuration
|
||||
1. Open the **Settings** panel from your Filament sidebar.
|
||||
2. Navigate to the **Deep Linking** tab.
|
||||
3. Toggle **Enable Deep Linking Files** to ON.
|
||||
4. Fill in your configurations:
|
||||
* **apple-app-site-association (iOS)**: The JSON configuration representing your iOS application IDs and supported paths (e.g., `/s/*`). This will be served at `/.well-known/apple-app-site-association` and `/apple-app-site-association` with the `application/json` content-type header.
|
||||
* **assetlinks.json (Android)**: The Digital Asset Links JSON array representing your Android application package names and SHA-256 certificate fingerprints. This will be served at `/.well-known/assetlinks.json`.
|
||||
|
||||
#### Example Configurations
|
||||
##### iOS AASA Example:
|
||||
```json
|
||||
{
|
||||
"applinks": {
|
||||
"apps": [],
|
||||
"details": [
|
||||
{
|
||||
"appID": "YOUR_TEAM_ID.com.yourcompany.app",
|
||||
"paths": [
|
||||
"/s/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
##### Android AssetLinks Example:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"relation": [
|
||||
"delegate_permission/common.handle_all_urls"
|
||||
],
|
||||
"target": {
|
||||
"namespace": "android_app",
|
||||
"package_name": "com.yourcompany.app",
|
||||
"sha256_cert_fingerprints": [
|
||||
"14:6D:E9:57:3E:28:B6:58:91:..."
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## High-Traffic Optimizations (new in v1.2.0)
|
||||
|
||||
### Daily Stats Aggregation
|
||||
@@ -359,6 +506,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.
|
||||
|
||||
@@ -386,43 +535,50 @@ When Redis is not available and counter buffering is enabled, the `IncrementVisi
|
||||
|
||||
---
|
||||
|
||||
## Social Retargeting Pixels (new in v1.5.0)
|
||||
## Social Retargeting Pixels & Central Pixel Registry (new in v3.0.0)
|
||||
|
||||
The **Marketing & API** tab in the short URL form lets you attach client-side tracking pixels to any link. When a visitor clicks a link that has pixels configured, instead of an instant 302 redirect the plugin serves a lightweight, premium HTML interstitial page.
|
||||
Instead of manually copy-pasting tracking pixel IDs (Meta, Google Tag, LinkedIn, TikTok, Pinterest) every time you create a new link, the package features a centralized **Retargeting Pixel Registry** with a Many-to-Many relationship. You define your marketing pixels once in the new **Pixel Registry** resource, and then easily select them via checkbox/list options when creating or editing short links.
|
||||
|
||||
### The Interstitial Experience
|
||||
- **Premium Design**: Built using the exact same modern glassmorphic look as the password protection and warning interstitial pages. Supports dark mode automatically.
|
||||
- **Brand Customization**: Automatically renders the application logo and the **Site Name Override** (configured globally in Settings, falling back to `config('app.name')`) to ensure the transition page looks professional and trust-instilling.
|
||||
- **Brand Customization**: Automatically renders the application logo (if defined via the `setting('logo_path')` helper) and the **Site Name Override** (configured globally in Settings, falling back to `config('app.name')`) to ensure the transition page looks professional and trust-instilling.
|
||||
- **Micro-Animations & Smooth Redirect**: Displays a sleek animated loading spinner and a progress bar that smoothly fills from 0% to 100% in ~220-250ms. This short delay ensures browser execution time for the tracking scripts before performing a seamless `window.location.replace()` to the destination URL.
|
||||
|
||||
This unlocks remarketing to people who clicked your links **even when redirecting to external domains** (e.g. booking.com, amazon.com) where you cannot install your own tracking code.
|
||||
|
||||
### Supported Pixel Providers
|
||||
|
||||
| Field | Provider | Script loaded |
|
||||
| Type | Provider | Script loaded |
|
||||
|---|---|---|
|
||||
| **Meta Pixel ID** | Meta / Facebook Ads | `fbevents.js` via `fbq('init', ...)` |
|
||||
| **Google Tag / GA4 ID** | Google Ads, GA4 | `gtag.js` via Google Tag Manager |
|
||||
| **LinkedIn Partner ID** | LinkedIn Insight Tag | `insight.min.js` via LinkedIn |
|
||||
| `meta` | Meta / Facebook Ads | `fbevents.js` via `fbq('init', ...)` |
|
||||
| `google` | Google Ads / GA4 | `gtag.js` via Google Tag Manager |
|
||||
| `linkedin` | LinkedIn Insight Tag | `insight.min.js` via LinkedIn |
|
||||
| `tiktok` | TikTok Pixel | TikTok Analytics pixel script |
|
||||
| `pinterest` | Pinterest Tag | Pinterest Tag pixel script |
|
||||
|
||||
> **Note:** These pixels fire **client-side** in the visitor's browser — completely separate from the server-side GA4 Measurement Protocol integration. Both systems work in parallel and do not interfere with each other.
|
||||
|
||||
### How to use
|
||||
|
||||
1. Open any short URL for editing.
|
||||
2. Navigate to the **Marketing & API** tab.
|
||||
3. Enter your pixel IDs in the **Retargeting Pixels** section.
|
||||
4. Save. Done — every click will now trigger the configured tracking scripts.
|
||||
1. Open **Pixel Registry** in your panel sidebar and register your pixels.
|
||||
2. Open any short URL for editing.
|
||||
3. Navigate to the **Marketing & API** tab.
|
||||
4. Select the configured pixels from the list under **Retargeting Pixels**.
|
||||
5. Save. Done — every click will now trigger the configured tracking scripts.
|
||||
|
||||
#### Programmatic Association
|
||||
|
||||
To associate centrally registered pixels programmatically, use standard Eloquent relationship syncing:
|
||||
|
||||
```php
|
||||
// Programmatically via model attributes
|
||||
$shortUrl->update([
|
||||
'pixel_meta_id' => '1234567890',
|
||||
'pixel_google_id' => 'G-XXXXXXXXXX',
|
||||
'pixel_linkedin_id' => '1234567',
|
||||
]);
|
||||
$shortUrl = ShortUrl::find($id);
|
||||
|
||||
// Sync with specific pixel registry IDs
|
||||
$shortUrl->pixels()->sync([$pixelId1, $pixelId2]);
|
||||
```
|
||||
|
||||
|
||||
|
||||
> **Privacy/GDPR Note:** You are responsible for ensuring that firing these pixels complies with applicable privacy regulations and your cookie consent mechanism.
|
||||
|
||||
---
|
||||
@@ -449,6 +605,8 @@ Authorization: Bearer sh_key_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
**Managing API Keys:** Go to **Settings → API & Webhooks → Developer API Keys** and add named keys. Each key can be individually activated or deactivated without deleting it.
|
||||
|
||||
For security, new API keys are hashed using SHA-256 and stored securely in the database. The plain key is displayed only once during generation via a persistent warning notification in the Filament UI. All keys are authenticated using constant-time string comparisons (`hash_equals()`) to prevent timing attacks.
|
||||
|
||||
> If the API is disabled globally, all endpoints return `503 Service Unavailable` regardless of the key provided.
|
||||
|
||||
### Endpoints
|
||||
@@ -477,10 +635,13 @@ curl https://yourdomain.com/api/short-url/links \
|
||||
"max_visits": null,
|
||||
"activated_at": null,
|
||||
"expires_at": null,
|
||||
"pixel_meta_id": null,
|
||||
"pixel_google_id": null,
|
||||
"pixel_linkedin_id": null,
|
||||
"webhook_url": null,
|
||||
"targeting_rules": null,
|
||||
"password": null,
|
||||
"show_warning_page": false,
|
||||
"track_visits": true,
|
||||
"track_browser_language": true,
|
||||
"pixels": [],
|
||||
"notes": null,
|
||||
"created_at": "2026-06-01T12:00:00+00:00"
|
||||
}
|
||||
@@ -507,7 +668,7 @@ curl -X POST https://yourdomain.com/api/short-url/links \
|
||||
"notes": "Summer campaign",
|
||||
"single_use": false,
|
||||
"max_visits": 1000,
|
||||
"pixel_meta_id": "1234567890",
|
||||
"pixels": [1, 2],
|
||||
"webhook_url": "https://api.mycrm.com/clicks"
|
||||
}'
|
||||
```
|
||||
@@ -527,12 +688,57 @@ curl -X POST https://yourdomain.com/api/short-url/links \
|
||||
| `expiration_redirect_url` | string (URL) | ❌ | Fallback URL on expiry |
|
||||
| `activated_at` | datetime | ❌ | Activation timestamp |
|
||||
| `expires_at` | datetime | ❌ | Expiration timestamp |
|
||||
| `pixel_meta_id` | string | ❌ | Meta Pixel ID |
|
||||
| `pixel_google_id` | string | ❌ | Google Tag / GA4 ID |
|
||||
| `pixel_linkedin_id` | string | ❌ | LinkedIn Partner ID |
|
||||
| `pixels` | array of integers | ❌ | List of pixel registry IDs to associate with the link |
|
||||
| `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.
|
||||
**Response:** `201 Created` with a wrapper message and the created link data:
|
||||
```json
|
||||
{
|
||||
"message": "Short URL created successfully.",
|
||||
"data": {
|
||||
"id": 2,
|
||||
"destination_url": "https://example.com/product",
|
||||
"url_key": "promo26",
|
||||
"short_url": "https://yourdomain.com/s/promo26",
|
||||
"is_enabled": true,
|
||||
"redirect_status_code": 302,
|
||||
"total_visits": 0,
|
||||
"unique_visits": 0,
|
||||
"max_visits": 1000,
|
||||
"activated_at": null,
|
||||
"expires_at": null,
|
||||
"webhook_url": "https://api.mycrm.com/clicks",
|
||||
"targeting_rules": null,
|
||||
"password": null,
|
||||
"show_warning_page": false,
|
||||
"track_visits": true,
|
||||
"track_browser_language": true,
|
||||
"pixels": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Meta Pixel (1234567890)",
|
||||
"type": "meta",
|
||||
"pixel_id": "1234567890",
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Google Tag (G-XXXXXXXXXX)",
|
||||
"type": "google",
|
||||
"pixel_id": "G-XXXXXXXXXX",
|
||||
"is_active": true
|
||||
}
|
||||
],
|
||||
"notes": "Summer campaign",
|
||||
"created_at": "2026-06-04T12:00:00+00:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### `DELETE /api/short-url/links/{id}`
|
||||
Permanently delete a short URL by its ID.
|
||||
@@ -585,6 +791,13 @@ Webhooks can be configured at two levels:
|
||||
|
||||
Select which events to monitor in **Settings → API & Webhooks → Monitored Webhook Events**.
|
||||
|
||||
### Webhook Signature Verification
|
||||
|
||||
Outgoing webhooks can be cryptographically signed using an HMAC-SHA256 signature to verify that the request originated from your system:
|
||||
1. Configure a **Webhook Signing Secret** in your Settings panel under **API & Webhooks**.
|
||||
2. When configured, outgoing HTTP POST payloads will include the signature in the `X-ShortUrl-Signature` header, which is calculated as `hash_hmac('sha256', $payloadJson, $secret)`.
|
||||
3. The receiver can verify the signature by re-calculating the HMAC of the raw request payload using the shared secret and comparing it using `hash_equals()`.
|
||||
|
||||
### Payload Format
|
||||
|
||||
All webhook requests are HTTP POST with `Content-Type: application/json` and the following payload structure:
|
||||
@@ -802,6 +1015,37 @@ All migrations are compatible with **SQLite**, **MySQL**, and **PostgreSQL**:
|
||||
|
||||
## Changelog
|
||||
|
||||
### v3.1.0
|
||||
- **Database-Backed & Cached Settings** — Relocated user configuration from local JSON files to the database (`short_url_settings` table) with automatic caching and zero-downtime migration of legacy settings.
|
||||
- **High-Performance Aggregations** — Completely refactored the statistics aggregator command to run optimized GROUP BY queries directly in the database, reducing memory usage (OOM protection) to near zero.
|
||||
- **Secure Hashed API Keys** — API keys are now stored securely as SHA-256 hashes, verified using constant-time comparisons (`hash_equals()`), and displayed only once upon generation.
|
||||
- **HMAC Signed Webhooks** — Webhook payloads are now optionally signed with a configured secret key and verified via the `X-ShortUrl-Signature` header.
|
||||
- **Privacy-Safe GA4 Client IDs** — Replaced random UUIDs with deterministic client IDs based on hashed visitor IP and User Agent, ensuring session integrity in Google Analytics without storing raw visitor data.
|
||||
- **Browser Cache Prevention for Limited Links** — Force temporary `302` redirects automatically for single-use, max-visit, or expiring links to prevent browsers from caching redirects and bypassing tracking logic.
|
||||
- **Proxy Detection Optimization** — Implemented an aggressive 800ms timeout for external proxy checkers and reduced cache time for transient rate-limit failures to 60 seconds.
|
||||
|
||||
### v3.0.0
|
||||
- **Native App Linking (Mobile Auto-Open)** — Automatically match and redirect mobile visitors directly inside 24+ native mobile apps (such as WhatsApp, YouTube, TikTok, Instagram, Spotify, etc.) using custom schemes, complete with a glassmorphic redirect page and a live interactive matching preview widget.
|
||||
- **Global Deep Linking (Universal Links & App Links)** — Easily serve iOS `apple-app-site-association` and Android `.well-known/assetlinks.json` configuration files directly from your root domain to support OS-level native integration (disabled by default, managed via Settings).
|
||||
- **Central Retargeting Pixel Registry** — Introduced a premium Many-to-Many pixel management registry. Define pixels centrally (Meta Pixel, Google Tag, LinkedIn Insight, TikTok Pixel, Pinterest Tag) and easily associate them with short links via the Filament panel or the REST API.
|
||||
- **Standalone Settings Page** — Relocated the Settings interface from a resource header sub-action to a standalone sidebar navigation page under the default plugin group.
|
||||
- **Retargeting Pixel API** — The REST API now fully exposes the `pixels` relationship array parameter for registering and linking retargeting pixels programmatically.
|
||||
- **Enhanced Browser Language Redirection** — Robust double-pass language targeting logic matching exact locales first (e.g. `en-US`, `zh-CN`) and falling back to base language codes (e.g. `en`, `zh`).
|
||||
- **Full Localization & WhatsApp Favicon Fix** — Added friendly translation strings in English and Polish across the entire app-linking preview and redirect interfaces, and adjusted domains order to restore the WhatsApp favicon.
|
||||
- **Custom Branded Expiry Pages** — Replaced raw 410 HTTP errors with a beautiful, fully localized, dark-mode compatible HTML expiry page displaying the Site Name, expired link details, and a homepage button.
|
||||
|
||||
### 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.
|
||||
@@ -847,4 +1091,4 @@ All migrations are compatible with **SQLite**, **MySQL**, and **PostgreSQL**:
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
Custom Source-Available License. Please see the [LICENSE](LICENSE) file for the full text.
|
||||
|
||||
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 |
@@ -4,11 +4,11 @@
|
||||
"description": "A professional Short URL manager plugin for Filament v5 with QR code design, visit tracking, geo-IP detection, and analytics.",
|
||||
"keywords": ["filament", "short-url", "qr-code", "analytics", "tracking", "laravel"],
|
||||
"homepage": "https://github.com/janczakb/filament-short-url",
|
||||
"license": "MIT",
|
||||
"license": "proprietary",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Bartek Janczak",
|
||||
"email": "hello@bjanczak.dev",
|
||||
"email": "barek122@gmail.com",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
|
||||
12190
composer.lock
generated
Normal file
12190
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -78,6 +78,7 @@ return [
|
||||
'operating_system_version' => true,
|
||||
'referer_url' => true,
|
||||
'device_type' => true,
|
||||
'browser_language' => true,
|
||||
],
|
||||
],
|
||||
|
||||
@@ -216,4 +217,46 @@ return [
|
||||
'throttle:120,1',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Deep Linking v2.1
|
||||
|--------------------------------------------------------------------------
|
||||
| Universal Links (iOS) and App Links (Android) configuration.
|
||||
|
|
||||
*/
|
||||
'deep_linking' => [
|
||||
'enabled' => env('SHORT_URL_DEEP_LINKING_ENABLED', false),
|
||||
'aasa_json' => env('SHORT_URL_AASA_JSON', <<<'JSON'
|
||||
{
|
||||
"applinks": {
|
||||
"apps": [],
|
||||
"details": [
|
||||
{
|
||||
"appID": "YOUR_TEAM_ID.com.yourcompany.app",
|
||||
"paths": [
|
||||
"/s/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
JSON),
|
||||
'assetlinks_json' => env('SHORT_URL_ASSETLINKS_JSON', <<<'JSON'
|
||||
[
|
||||
{
|
||||
"relation": [
|
||||
"delegate_permission/common.handle_all_urls"
|
||||
],
|
||||
"target": {
|
||||
"namespace": "android_app",
|
||||
"package_name": "com.yourcompany.app",
|
||||
"sha256_cert_fingerprints": [
|
||||
"14:6D:E9:..."
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
JSON),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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) {
|
||||
$table->boolean('auto_open_app_mobile')->default(false)->after('forward_query_params');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('short_urls', function (Blueprint $table) {
|
||||
$table->dropColumn('auto_open_app_mobile');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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::create('short_url_settings', function (Blueprint $table): void {
|
||||
$table->string('key')->primary();
|
||||
$table->longText('value')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('short_url_settings');
|
||||
}
|
||||
};
|
||||
2
resources/dist/filament-short-url.css
vendored
2
resources/dist/filament-short-url.css
vendored
File diff suppressed because one or more lines are too long
@@ -9,6 +9,16 @@ return [
|
||||
'tab_link' => 'Link Details',
|
||||
'tab_tracking' => 'Tracking Settings',
|
||||
'tab_qr_design' => 'QR Code Design',
|
||||
'tab_app_linking' => 'App Linking',
|
||||
|
||||
// App Linking
|
||||
'form_section_app_linking' => 'App Linking / Deep Links',
|
||||
'auto_open_app_mobile' => 'Auto open app on mobile',
|
||||
'auto_open_app_mobile_helper' => 'Enable this if you want your link to automatically open as an app when accessed on mobile.',
|
||||
'supported_operating_systems' => 'Supported Operating Systems',
|
||||
'supported_apps' => 'Supported Apps',
|
||||
'supported_apps_helper' => 'If you do not find your app here, you can use the Targeting feature to redirect the user to your app with your custom deep link.',
|
||||
'matched' => 'Matched',
|
||||
|
||||
// Link Form Fields
|
||||
'destination_url' => 'Destination URL',
|
||||
@@ -58,6 +68,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 +109,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 +149,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 +166,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 +209,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 +226,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 +250,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 +309,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 +347,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 +363,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 +409,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 +426,89 @@ 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',
|
||||
'settings_tab_deep_linking' => 'Deep Linking',
|
||||
'settings_section_deep_linking' => 'Universal Links & App Links Association Files',
|
||||
'settings_deep_linking_enabled' => 'Enable Deep Linking Files',
|
||||
'settings_deep_linking_enabled_helper' => 'When enabled, the plugin serves AASA and AssetLinks configuration files from your root domain.',
|
||||
'settings_aasa_json' => 'apple-app-site-association (iOS)',
|
||||
'settings_aasa_json_helper' => 'Specify the valid JSON configuration for Apple App Site Association. Do not include wrapping script tags.',
|
||||
'settings_assetlinks_json' => 'assetlinks.json (Android)',
|
||||
'settings_assetlinks_json_helper' => 'Specify the valid JSON array configuration for Android Digital Asset Links.',
|
||||
'validation_invalid_json' => 'The entered text is not a valid JSON string.',
|
||||
|
||||
// App Linking Preview
|
||||
'app_linking_supported_os' => 'Supported Operating Systems:',
|
||||
'app_linking_supported_apps' => 'Supported Apps',
|
||||
'app_linking_supported_apps_helper' => 'If you do not find your app here, you can use the Targeting feature to redirect the user to your app with your custom deep link.',
|
||||
'app_linking_matched' => 'Matched',
|
||||
'app_linking_preconfigured_count' => ':count apps pre-configured for automatic redirection.',
|
||||
'app_linking_redirect_active' => 'Opens directly inside :app',
|
||||
'app_linking_auto_open' => 'Auto-Open',
|
||||
'app_linking_matched_description' => 'When clicked on a mobile device, this link will open immediately inside the native <strong>:app</strong> application instead of the web browser.',
|
||||
'app_linking_deep_link_scheme' => 'Mobile app path (Deep Link): :scheme',
|
||||
'app_linking_standard_redirect' => 'This link will open in a standard mobile browser (no matching mobile app found).',
|
||||
|
||||
// App Redirect Page
|
||||
'app_redirect_title' => 'Opening App...',
|
||||
'app_redirect_opening_in' => 'Opening in :app',
|
||||
'app_redirect_waiting_text' => 'We are redirecting you to the native application. Please wait a moment...',
|
||||
'app_redirect_btn_open' => 'Open Native App',
|
||||
'app_redirect_btn_browser' => 'Open in Browser',
|
||||
'app_redirect_in_app_warning_title' => 'In-App Browser Detected',
|
||||
'app_redirect_in_app_warning_desc' => 'Redirections can be restricted inside Instagram or Facebook. Tap the three dots (•••) in the top-right corner and select "Open in Safari" or "Open in Chrome" to launch the app.',
|
||||
|
||||
// Link Expired Page
|
||||
'expired_title' => 'Link Inactive or Expired',
|
||||
'expired_description' => 'This link is no longer active. It may have reached its click limit, expiration date, or has been deactivated.',
|
||||
'expired_btn_home' => 'Go to Homepage',
|
||||
];
|
||||
|
||||
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',
|
||||
];
|
||||
@@ -9,6 +9,12 @@ return [
|
||||
'tab_link' => 'Szczegóły linku',
|
||||
'tab_tracking' => 'Ustawienia śledzenia',
|
||||
'tab_qr_design' => 'Wygląd kodu QR',
|
||||
'tab_app_linking' => 'Autootwieranie aplikacji',
|
||||
|
||||
// App Linking
|
||||
'form_section_app_linking' => 'Autootwieranie aplikacji / Głębokie linkowanie',
|
||||
'auto_open_app_mobile' => 'Automatycznie otwieraj aplikację na urządzeniach mobilnych',
|
||||
'auto_open_app_mobile_helper' => 'Włącz tę opcję, jeśli chcesz, aby po kliknięciu linku na telefonie automatycznie otwierała się dedykowana aplikacja mobilna zamiast przeglądarki.',
|
||||
|
||||
// Link Form Fields
|
||||
'destination_url' => 'Docelowy URL',
|
||||
@@ -58,6 +64,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 +105,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 +145,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 +162,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 +206,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 +222,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 +247,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 +306,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 +344,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 +360,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 +406,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 +422,90 @@ 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',
|
||||
'settings_tab_deep_linking' => 'Głębokie linkowanie',
|
||||
'settings_section_deep_linking' => 'Pliki powiązań Universal Links & App Links',
|
||||
'settings_deep_linking_enabled' => 'Włącz serwowanie plików powiązań',
|
||||
'settings_deep_linking_enabled_helper' => 'Po włączeniu wtyczka będzie serwować pliki konfiguracyjne AASA (iOS) oraz AssetLinks (Android) z poziomu domeny głównej.',
|
||||
'settings_aasa_json' => 'apple-app-site-association (iOS)',
|
||||
'settings_aasa_json_helper' => 'Wprowadź prawidłowy kod JSON dla Apple App Site Association. Nie dołączaj znaczników HTML/JavaScript.',
|
||||
'settings_assetlinks_json' => 'assetlinks.json (Android)',
|
||||
'settings_assetlinks_json_helper' => 'Wprowadź prawidłową tablicę JSON dla Android Digital Asset Links.',
|
||||
'validation_invalid_json' => 'Wprowadzony tekst nie jest poprawnym ciągiem JSON.',
|
||||
|
||||
// App Linking Preview
|
||||
'app_linking_supported_os' => 'Wspierane systemy operacyjne:',
|
||||
'app_linking_supported_apps' => 'Wspierane aplikacje',
|
||||
'app_linking_supported_apps_helper' => 'Jeśli nie znajdziesz swojej aplikacji na liście, możesz użyć funkcji Targetowania, aby przekierować użytkownika za pomocą własnego głębokiego linku (Deep Link).',
|
||||
'app_linking_matched' => 'Dopasowano',
|
||||
'app_linking_preconfigured_count' => ':count gotowych aplikacji do automatycznego przekierowania.',
|
||||
'app_linking_redirect_active' => 'Otwiera się bezpośrednio w :app',
|
||||
'app_linking_auto_open' => 'Autootwieranie',
|
||||
'app_linking_matched_description' => 'Po kliknięciu na telefonie lub tablecie, ten link otworzy się od razu w dedykowanej aplikacji <strong>:app</strong> zamiast w zwykłej przeglądarce.',
|
||||
'app_linking_deep_link_scheme' => 'Używany schemat (Deep Link): :scheme',
|
||||
'app_linking_standard_redirect' => 'Ten link otworzy się w zwykłej przeglądarce internetowej (brak dopasowanej aplikacji mobilnej).',
|
||||
|
||||
// App Redirect Page
|
||||
'app_redirect_title' => 'Otwieranie aplikacji...',
|
||||
'app_redirect_opening_in' => 'Otwieranie w: :app',
|
||||
'app_redirect_waiting_text' => 'Przekierowujemy Cię do natywnej aplikacji. Proszę czekać...',
|
||||
'app_redirect_btn_open' => 'Otwórz aplikację',
|
||||
'app_redirect_btn_browser' => 'Otwórz w przeglądarce',
|
||||
'app_redirect_in_app_warning_title' => 'Wykryto przeglądarkę wewnątrz aplikacji',
|
||||
'app_redirect_in_app_warning_desc' => 'Przekierowania mogą być blokowane wewnątrz Instagrama lub Facebooka. Kliknij ikonę trzech kropek (•••) w prawym górnym rogu i wybierz „Otwórz w Safari” lub „Otwórz w Chrome”, aby uruchomić aplikację.',
|
||||
|
||||
// Link Expired Page
|
||||
'expired_title' => 'Link nieaktywny lub wygasł',
|
||||
'expired_description' => 'Ten link jest już nieaktywny. Mógł osiągnąć limit kliknięć, minął jego termin ważności lub został wyłączony.',
|
||||
'expired_btn_home' => 'Przejdź do strony głównej',
|
||||
];
|
||||
|
||||
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',
|
||||
];
|
||||
105
resources/views/app-linking-preview.blade.php
Normal file
105
resources/views/app-linking-preview.blade.php
Normal file
@@ -0,0 +1,105 @@
|
||||
@php
|
||||
$matchedAppId = \Bjanczak\FilamentShortUrl\Services\AppLinkingEngine::matchApp($destinationUrl);
|
||||
$apps = \Bjanczak\FilamentShortUrl\Services\AppLinkingEngine::getSupportedApps();
|
||||
@endphp
|
||||
|
||||
<div class="mt-4 space-y-4">
|
||||
<!-- macOS style main block -->
|
||||
<div class="p-4 rounded-xl border border-gray-200 dark:border-gray-800 bg-gray-50/50 dark:bg-gray-900/30 shadow-none">
|
||||
|
||||
<!-- Header Info -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-2 pb-3 mb-4 border-b border-gray-200 dark:border-gray-800">
|
||||
<div>
|
||||
<h4 class="text-xs font-bold text-gray-850 dark:text-white uppercase tracking-wider">
|
||||
{{ __('filament-short-url::default.app_linking_supported_apps') }}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 text-[10px]">
|
||||
<span class="text-gray-500 dark:text-gray-450 font-medium">
|
||||
{{ __('filament-short-url::default.app_linking_supported_os') }}
|
||||
</span>
|
||||
<span class="px-2 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-gray-650 dark:text-gray-300 font-semibold">iOS</span>
|
||||
<span class="px-2 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-gray-650 dark:text-gray-300 font-semibold">Android</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Matched App Banner -->
|
||||
@if ($matchedAppId && isset($apps[$matchedAppId]))
|
||||
@php
|
||||
$matchedApp = $apps[$matchedAppId];
|
||||
$matchedDomain = explode('/', $matchedApp['domains'][0])[0];
|
||||
$matchedFavicon = "https://icons.duckduckgo.com/ip2/{$matchedDomain}.ico";
|
||||
$deepLink = \Bjanczak\FilamentShortUrl\Services\AppLinkingEngine::convertToScheme($destinationUrl, $matchedAppId);
|
||||
@endphp
|
||||
<div class="mb-4 p-4 rounded-xl bg-emerald-500/5 dark:bg-emerald-500/5 border border-emerald-500 dark:border-emerald-600 text-xs shadow-none">
|
||||
<div class="flex items-start gap-4">
|
||||
<!-- Squircle Favicon Box -->
|
||||
<div class="w-10 h-10 rounded-lg bg-white dark:bg-gray-800 flex items-center justify-center p-2 flex-shrink-0">
|
||||
<img src="{{ $matchedFavicon }}" alt="{{ $matchedApp['name'] }}" class="w-6 h-6 object-contain" onerror="this.src='https://icons.duckduckgo.com/ip2/google.com.ico'">
|
||||
</div>
|
||||
|
||||
<div class="flex-grow min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h5 class="text-xs font-bold text-emerald-850 dark:text-emerald-450">
|
||||
{{ __('filament-short-url::default.app_linking_redirect_active', ['app' => $matchedApp['name']]) }}
|
||||
</h5>
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded-full text-[9px] font-bold bg-emerald-500 text-white uppercase tracking-wider">
|
||||
{{ __('filament-short-url::default.app_linking_auto_open') }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-[11px] text-emerald-700 dark:text-emerald-450 mt-1 leading-relaxed">
|
||||
{!! __('filament-short-url::default.app_linking_matched_description', ['app' => e($matchedApp['name'])]) !!}
|
||||
</p>
|
||||
<div class="mt-2 text-[10px] font-mono text-emerald-600 dark:text-emerald-400 break-all select-all">
|
||||
{{ __('filament-short-url::default.app_linking_deep_link_scheme', ['scheme' => $deepLink]) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<!-- Standard Web Redirect banner -->
|
||||
<div class="mb-4 p-3.5 rounded-xl bg-gray-100/50 dark:bg-gray-800/10 border border-gray-200 dark:border-gray-800 text-xs flex items-center gap-3 shadow-none">
|
||||
<div class="w-7 h-7 rounded-[6px] bg-white dark:bg-gray-800 flex items-center justify-center p-1.5 flex-shrink-0">
|
||||
<svg class="w-4 h-4 text-gray-450" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-gray-500 dark:text-gray-450 leading-relaxed text-[11px]">
|
||||
{{ __('filament-short-url::default.app_linking_standard_redirect') }}
|
||||
</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Flat iOS-like Grid without Borders -->
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2 mb-4">
|
||||
@foreach ($apps as $appId => $app)
|
||||
@php
|
||||
$isMatched = ($appId === $matchedAppId);
|
||||
$appDomain = explode('/', $app['domains'][0])[0];
|
||||
$appFavicon = "https://icons.duckduckgo.com/ip2/{$appDomain}.ico";
|
||||
@endphp
|
||||
<div class="flex items-center gap-2.5 p-1.5 rounded-lg transition {{ $isMatched ? 'bg-emerald-500/10 text-emerald-800 dark:text-emerald-400 font-bold' : 'hover:bg-gray-100/60 dark:hover:bg-gray-800/50 text-gray-700 dark:text-gray-300' }}">
|
||||
<!-- Favicon Squircle -->
|
||||
<div class="w-7 h-7 rounded-[6px] bg-white dark:bg-gray-800 flex items-center justify-center p-1.5 flex-shrink-0">
|
||||
<img src="{{ $appFavicon }}" alt="{{ $app['name'] }}" class="w-4 h-4 object-contain" onerror="this.src='https://icons.duckduckgo.com/ip2/google.com.ico'">
|
||||
</div>
|
||||
<span class="text-xs font-semibold truncate">
|
||||
{{ $app['name'] }}
|
||||
</span>
|
||||
@if ($isMatched)
|
||||
<span class="ml-auto flex h-3.5 w-3.5 items-center justify-center rounded-full bg-emerald-500 text-white text-[8px] font-bold shrink-0">
|
||||
✓
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<!-- Help footer -->
|
||||
<p class="text-[11px] text-gray-450 dark:text-gray-500 border-t border-gray-200 dark:border-gray-800 pt-3 leading-relaxed">
|
||||
{{ __('filament-short-url::default.app_linking_supported_apps_helper') }}
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
274
resources/views/app-redirect.blade.php
Normal file
274
resources/views/app-redirect.blade.php
Normal file
@@ -0,0 +1,274 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="h-full">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ __('filament-short-url::default.app_redirect_title') ?? 'Opening App...' }}</title>
|
||||
|
||||
<!-- Premium Google Fonts: Bricolage Grotesque -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,200..800&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Self-contained Tailwind CSS CDN for maximum plug-and-play reliability -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Bricolage Grotesque', 'sans-serif'],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detect system dark mode preferences
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
</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($pixelMetaIds->isNotEmpty())
|
||||
<script>
|
||||
!function(f,b,e,v,n,t,s)
|
||||
{if(f.fbq)return;n=f.fbq=function(){n.callMethod?
|
||||
n.callMethod.apply(n,arguments):n.queue.push(arguments)};
|
||||
if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';
|
||||
n.queue=[];t=b.createElement(e);t.async=!0;
|
||||
t.src=v;s=b.getElementsByTagName(e)[0];
|
||||
s.parentNode.insertBefore(t,s)}(window, document,'script',
|
||||
'https://connect.facebook.net/en_US/fbevents.js');
|
||||
@foreach($pixelMetaIds as $id)
|
||||
fbq('init', '{{ $id }}');
|
||||
fbq('track', 'PageView');
|
||||
@endforeach
|
||||
</script>
|
||||
@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($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());
|
||||
@foreach($pixelGoogleIds as $id)
|
||||
gtag('config', '{{ $id }}');
|
||||
@endforeach
|
||||
</script>
|
||||
@endif
|
||||
|
||||
<!-- LinkedIn Insight -->
|
||||
@if($pixelLinkedinIds->isNotEmpty())
|
||||
<script type="text/javascript">
|
||||
window._linkedin_data_partner_ids = window._linkedin_data_partner_ids || [];
|
||||
@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=[]}
|
||||
var s = document.getElementsByTagName("script")[0];
|
||||
var b = document.createElement("script");
|
||||
b.type = "text/javascript";b.async = true;
|
||||
b.src = "https://snap.licdn.com/li.lms-analytics/insight.min.js";
|
||||
s.parentNode.insertBefore(b, s);})(window.lintrk);
|
||||
</script>
|
||||
@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">
|
||||
@php
|
||||
$siteName = config('filament-short-url.site_name') ?: config('app.name', 'Laravel');
|
||||
|
||||
// Retrieve app metadata for premium visual hints
|
||||
$apps = \Bjanczak\FilamentShortUrl\Services\AppLinkingEngine::getSupportedApps();
|
||||
$matchedApp = $apps[$appId] ?? null;
|
||||
$appColor = $matchedApp['color'] ?? '#3b82f6';
|
||||
$appName = $matchedApp['name'] ?? 'App';
|
||||
@endphp
|
||||
|
||||
{{-- Main Container --}}
|
||||
<div class="w-full max-w-[360px] flex flex-col items-center gap-6 my-auto">
|
||||
<div class="flex flex-col items-center text-center pb-2 select-none w-full">
|
||||
<span class="text-2xl font-extrabold tracking-tight text-neutral-900 dark:text-white mb-6 select-none">{{ $siteName }}</span>
|
||||
|
||||
<!-- Premium Dynamic Loader with App Theme Highlight -->
|
||||
<div class="relative flex items-center justify-center w-20 h-20 mb-6">
|
||||
<!-- Outer Pulse ring -->
|
||||
<div class="absolute inset-0 rounded-full opacity-20 animate-ping" style="background-color: {{ $appColor }};"></div>
|
||||
<!-- Inner Spinning Border -->
|
||||
<div class="absolute inset-0 rounded-full border-4 border-neutral-100 dark:border-neutral-900"></div>
|
||||
<div class="absolute inset-0 rounded-full border-4 border-transparent border-t-current animate-spin" style="color: {{ $appColor }};"></div>
|
||||
<!-- Center App Accent -->
|
||||
<div class="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold shadow-md" style="background-color: {{ $appColor }};">
|
||||
{{ substr($appName, 0, 1) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xl font-bold text-neutral-900 dark:text-white mt-2">
|
||||
{{ __('filament-short-url::default.app_redirect_opening_in', ['app' => $appName]) ?? "Opening in {$appName}" }}
|
||||
</p>
|
||||
<p class="text-sm text-neutral-400 dark:text-neutral-500 mt-2 max-w-[280px]">
|
||||
{{ __('filament-short-url::default.app_redirect_waiting_text') ?? 'We are redirecting you to the native application. Please wait a moment...' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="w-full flex flex-col gap-4">
|
||||
{{-- Action Buttons --}}
|
||||
<div class="flex flex-col gap-3 w-full mt-2">
|
||||
<!-- Manual trigger if redirect didn't catch -->
|
||||
<a href="{{ $deepLink }}"
|
||||
class="w-full py-3.5 rounded-xl text-white font-semibold text-sm transition duration-200 shadow-md flex justify-center items-center gap-2 text-center"
|
||||
style="background-color: {{ $appColor }}; filter: brightness(0.95);"
|
||||
onmouseover="this.style.filter='brightness(1.05)';"
|
||||
onmouseout="this.style.filter='brightness(0.95)';"
|
||||
id="deep-link-btn">
|
||||
<span>{{ __('filament-short-url::default.app_redirect_btn_open') ?? 'Open Native App' }}</span>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<!-- Fallback Button -->
|
||||
<a href="{{ $destination }}"
|
||||
class="w-full py-3 rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 hover:bg-neutral-50 dark:hover:bg-neutral-800 text-neutral-500 dark:text-neutral-400 font-semibold text-sm transition duration-200 flex justify-center items-center gap-2 shadow-sm"
|
||||
id="fallback-btn">
|
||||
<span>{{ __('filament-short-url::default.app_redirect_btn_browser') ?? 'Open in Browser' }}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{{-- In-App Browser Alert Banner (Hidden by default, shown via JS) --}}
|
||||
<div id="in-app-alert" class="hidden mt-4 p-3.5 rounded-xl border border-amber-200 bg-amber-50 dark:border-amber-950/20 dark:bg-amber-950/10 text-amber-800 dark:text-amber-400 text-xs text-center flex flex-col gap-1 select-none">
|
||||
<p class="font-bold flex items-center justify-center gap-1.5 text-amber-900 dark:text-amber-300">
|
||||
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span>{{ __('filament-short-url::default.app_redirect_in_app_warning_title') ?? 'In-App Browser Detected' }}</span>
|
||||
</p>
|
||||
<p class="text-[11px] leading-relaxed opacity-95">
|
||||
{{ __('filament-short-url::default.app_redirect_in_app_warning_desc') ?? 'Redirections can be restricted inside Instagram or Facebook. Tap the three dots (•••) in the top-right corner and select "Open in Safari" or "Open in Chrome" to launch the app.' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Footer section --}}
|
||||
<div class="flex flex-col items-center gap-2 mt-auto select-none">
|
||||
<span class="text-xs font-medium text-neutral-400 dark:text-neutral-600">© {{ date('Y') }} {{ $siteName }}. All rights reserved.</span>
|
||||
</div>
|
||||
|
||||
<!-- Automatic JS Deep Linking Engine -->
|
||||
<script>
|
||||
(function() {
|
||||
var deepLink = @json($deepLink);
|
||||
var fallbackUrl = @json($destination);
|
||||
var hasPixels = @json(!empty($pixels) && $pixels->isNotEmpty());
|
||||
|
||||
// Detect in-app webviews
|
||||
var ua = navigator.userAgent || navigator.vendor || window.opera;
|
||||
var isInstagram = ua.indexOf('Instagram') > -1;
|
||||
var isFacebook = (ua.indexOf('FBAN') > -1) || (ua.indexOf('FBAV') > -1);
|
||||
var isMessenger = (ua.indexOf('Messenger') > -1) || (ua.indexOf('FB_IAB') > -1);
|
||||
|
||||
if (isInstagram || isFacebook || isMessenger) {
|
||||
var alertBox = document.getElementById('in-app-alert');
|
||||
if (alertBox) {
|
||||
alertBox.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
var fallbackTimeout = null;
|
||||
|
||||
function launchApp() {
|
||||
// Try opening native app scheme
|
||||
window.location.replace(deepLink);
|
||||
|
||||
// Trigger fallback if not installed/unsupported after 1.8 seconds
|
||||
fallbackTimeout = setTimeout(function() {
|
||||
window.location.replace(fallbackUrl);
|
||||
}, 1800);
|
||||
|
||||
// Cancel fallback if user switches context (app successfully opened)
|
||||
window.addEventListener('pagehide', function() {
|
||||
if (fallbackTimeout) clearTimeout(fallbackTimeout);
|
||||
});
|
||||
window.addEventListener('visibilitychange', function() {
|
||||
if (document.hidden && fallbackTimeout) {
|
||||
clearTimeout(fallbackTimeout);
|
||||
}
|
||||
});
|
||||
window.addEventListener('blur', function() {
|
||||
if (fallbackTimeout) {
|
||||
clearTimeout(fallbackTimeout);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Also clear timeout if user clicks manual trigger button
|
||||
var deepLinkBtn = document.getElementById('deep-link-btn');
|
||||
if (deepLinkBtn) {
|
||||
deepLinkBtn.addEventListener('click', function() {
|
||||
if (fallbackTimeout) {
|
||||
clearTimeout(fallbackTimeout);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (hasPixels) {
|
||||
// Wait 250ms for pixels to fire before triggering redirect
|
||||
setTimeout(launchApp, 250);
|
||||
} else {
|
||||
launchApp();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
90
resources/views/expired.blade.php
Normal file
90
resources/views/expired.blade.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="h-full">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ __('filament-short-url::default.expired_title') ?? 'Link Inactive or Expired' }}</title>
|
||||
|
||||
<!-- Premium Google Fonts: Bricolage Grotesque -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,200..800&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Self-contained Tailwind CSS CDN for maximum plug-and-play reliability -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Bricolage Grotesque', 'sans-serif'],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detect system dark mode preferences
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
</script>
|
||||
</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">
|
||||
@php
|
||||
$siteName = config('filament-short-url.site_name') ?: config('app.name', 'Laravel');
|
||||
@endphp
|
||||
|
||||
{{-- Main Expiry Box --}}
|
||||
<div class="w-full max-w-[360px] flex flex-col items-center gap-6 my-auto">
|
||||
<div class="flex flex-col items-center text-center pb-2 select-none w-full">
|
||||
<span class="text-3xl font-extrabold tracking-tight text-neutral-900 dark:text-white mb-3">{{ $siteName }}</span>
|
||||
|
||||
<div class="w-16 h-16 rounded-full bg-amber-500/10 dark:bg-amber-500/20 flex items-center justify-center mt-4 mb-2 text-amber-500">
|
||||
<svg class="h-8 w-8" 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>
|
||||
</div>
|
||||
|
||||
<p class="text-xl font-medium text-neutral-900 dark:text-white mt-2">
|
||||
{{ __('filament-short-url::default.expired_title') ?? 'Link Inactive or Expired' }}
|
||||
</p>
|
||||
<p class="text-sm text-neutral-400 dark:text-neutral-500 mt-1">
|
||||
{{ __('filament-short-url::default.expired_description') ?? 'This link is no longer active. It may have reached its click limit, expiration date, or has been deactivated.' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="w-full flex flex-col gap-4">
|
||||
{{-- Short URL info box --}}
|
||||
@if ($shortUrl)
|
||||
<div class="relative flex items-center justify-center px-3 py-3.5 w-full rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 transition duration-200">
|
||||
<div class="flex flex-col justify-center text-center min-w-0">
|
||||
<span class="text-[10px] font-bold text-neutral-400 dark:text-neutral-500 uppercase tracking-wide select-none">
|
||||
Expired Link
|
||||
</span>
|
||||
<p class="break-all text-xs font-mono font-semibold text-rose-600 dark:text-rose-400 mt-1 select-all">
|
||||
{{ $shortUrl->getShortUrl() }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Action Button --}}
|
||||
<a href="{{ url('/') }}" class="w-full mt-2 py-3.5 rounded-xl bg-neutral-900 hover:bg-neutral-800 dark:bg-white dark:hover:bg-neutral-200 dark:text-neutral-900 text-white font-semibold text-sm transition duration-200 flex justify-center items-center gap-2 text-center">
|
||||
<span>{{ __('filament-short-url::default.expired_btn_home') ?? 'Go to Homepage' }}</span>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Footer section --}}
|
||||
<div class="flex flex-col items-center gap-2 mt-auto select-none">
|
||||
<span class="text-xs font-medium text-neutral-400 dark:text-neutral-600">© {{ date('Y') }} {{ $siteName }} Inc. All rights reserved.</span>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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,8 +3,17 @@
|
||||
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::get('/.well-known/apple-app-site-association', [ShortUrlRedirectController::class, 'serveAasa'])
|
||||
->middleware(['web']);
|
||||
Route::get('/apple-app-site-association', [ShortUrlRedirectController::class, 'serveAasa'])
|
||||
->middleware(['web']);
|
||||
Route::get('/.well-known/assetlinks.json', [ShortUrlRedirectController::class, 'serveAssetLinks'])
|
||||
->middleware(['web']);
|
||||
|
||||
Route::match(
|
||||
['GET', 'POST'],
|
||||
config('filament-short-url.route_prefix', 's').'/{key}',
|
||||
@@ -21,3 +30,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,118 @@ 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 {
|
||||
$nextDate = Carbon::parse($date)->addDay()->toDateString();
|
||||
$start = $date.' 00:00:00';
|
||||
$end = $nextDate.' 00:00:00';
|
||||
|
||||
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;
|
||||
// 1. Get totals and uniques per short_url_id
|
||||
$totals = DB::table('short_url_visits')
|
||||
->where('visited_at', '>=', $start)
|
||||
->where('visited_at', '<', $end)
|
||||
->select([
|
||||
'short_url_id',
|
||||
DB::raw('count(*) as total'),
|
||||
DB::raw('count(distinct ip_hash) as uniques'),
|
||||
DB::raw("count(case when is_qr_scan = 1 or is_qr_scan = true or is_qr_scan = '1' then 1 end) as qr_scans"),
|
||||
])
|
||||
->groupBy('short_url_id')
|
||||
->get();
|
||||
|
||||
if (! isset($statsByUrl[$urlId])) {
|
||||
$statsByUrl[$urlId] = [
|
||||
'total' => 0,
|
||||
'ip_hashes' => [],
|
||||
'device_stats' => [],
|
||||
'browser_stats' => [],
|
||||
'os_stats' => [],
|
||||
'country_stats' => [],
|
||||
'city_stats' => [],
|
||||
'referer_stats' => [],
|
||||
'utm_source_stats' => [],
|
||||
'utm_medium_stats' => [],
|
||||
'utm_campaign_stats' => [],
|
||||
];
|
||||
}
|
||||
if ($totals->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$s = &$statsByUrl[$urlId];
|
||||
$s['total']++;
|
||||
$statsByUrl = [];
|
||||
foreach ($totals as $row) {
|
||||
$statsByUrl[$row->short_url_id] = [
|
||||
'total' => (int) $row->total,
|
||||
'uniques' => (int) $row->uniques,
|
||||
'qr_scans' => (int) $row->qr_scans,
|
||||
'device_stats' => [],
|
||||
'browser_stats' => [],
|
||||
'os_stats' => [],
|
||||
'country_stats' => [],
|
||||
'city_stats' => [],
|
||||
'referer_stats' => [],
|
||||
'utm_source_stats' => [],
|
||||
'utm_medium_stats' => [],
|
||||
'utm_campaign_stats' => [],
|
||||
'language_stats' => [],
|
||||
];
|
||||
}
|
||||
|
||||
if ($visit->ip_hash) {
|
||||
$s['ip_hashes'][$visit->ip_hash] = true;
|
||||
}
|
||||
// Helper to fetch and populate category stats
|
||||
$populateStats = function (string $column, string $statsKey) use ($start, $end, &$statsByUrl): void {
|
||||
$query = DB::table('short_url_visits')
|
||||
->where('visited_at', '>=', $start)
|
||||
->where('visited_at', '<', $end)
|
||||
->whereNotNull($column)
|
||||
->where($column, '<>', '');
|
||||
|
||||
$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');
|
||||
|
||||
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 ($statsKey === 'city_stats') {
|
||||
$query->select(['short_url_id', 'city', 'country_code', DB::raw('count(*) as count')])
|
||||
->groupBy(['short_url_id', 'city', 'country_code']);
|
||||
} else {
|
||||
$query->select(['short_url_id', $column, DB::raw('count(*) as count')])
|
||||
->groupBy(['short_url_id', $column]);
|
||||
}
|
||||
});
|
||||
|
||||
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'],
|
||||
]);
|
||||
}
|
||||
$rows = $query->get();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$urlId = $row->short_url_id;
|
||||
if (! isset($statsByUrl[$urlId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($statsKey === 'city_stats') {
|
||||
$cityVal = $row->city;
|
||||
$countryCode = $row->country_code;
|
||||
$val = $countryCode ? "{$cityVal} ({$countryCode})" : $cityVal;
|
||||
} else {
|
||||
$val = $row->$column;
|
||||
}
|
||||
|
||||
$statsByUrl[$urlId][$statsKey][$val] = (int) $row->count;
|
||||
}
|
||||
};
|
||||
|
||||
// Populate all categories via 10 quick indexed database aggregations
|
||||
$populateStats('device_type', 'device_stats');
|
||||
$populateStats('browser', 'browser_stats');
|
||||
$populateStats('operating_system', 'os_stats');
|
||||
$populateStats('country', 'country_stats');
|
||||
$populateStats('city', 'city_stats');
|
||||
$populateStats('referer_host', 'referer_stats');
|
||||
$populateStats('utm_source', 'utm_source_stats');
|
||||
$populateStats('utm_medium', 'utm_medium_stats');
|
||||
$populateStats('utm_campaign', 'utm_campaign_stats');
|
||||
$populateStats('browser_language', 'language_stats');
|
||||
|
||||
// Write aggregated stats to ShortUrlDailyStats
|
||||
foreach ($statsByUrl as $urlId => $s) {
|
||||
ShortUrlDailyStats::updateOrCreate([
|
||||
'short_url_id' => $urlId,
|
||||
'date' => $date,
|
||||
], [
|
||||
'visits_count' => $s['total'],
|
||||
'unique_visits_count' => $s['uniques'],
|
||||
'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 +167,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;
|
||||
|
||||
160
src/Filament/Resources/ShortUrlPixelResource.php
Normal file
160
src/Filament/Resources/ShortUrlPixelResource.php
Normal file
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlPixelResource\Pages\ListShortUrlPixels;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlPixel;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
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()
|
||||
->icon('heroicon-o-pencil-square')
|
||||
->color('gray')
|
||||
->iconButton()
|
||||
->tooltip(__('filament-short-url::default.action_edit'))
|
||||
->modalWidth('md'),
|
||||
DeleteAction::make()
|
||||
->icon('heroicon-o-trash')
|
||||
->color('gray')
|
||||
->iconButton()
|
||||
->tooltip(__('filament-short-url::default.action_delete')),
|
||||
])
|
||||
->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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\ListShortUrls;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\ShortUrlSettingsPage;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\ViewShortUrlLogs;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\ViewShortUrlStats;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\ShortUrlForm;
|
||||
@@ -113,7 +112,6 @@ class ShortUrlResource extends Resource
|
||||
'index' => ListShortUrls::route('/'),
|
||||
'stats' => ViewShortUrlStats::route('/{record}/stats'),
|
||||
'stats.logs' => ViewShortUrlLogs::route('/{record}/stats/logs'),
|
||||
'settings' => ShortUrlSettingsPage::route('/settings'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
@@ -16,25 +19,369 @@ class ListShortUrls extends ManageRecords
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('settings')
|
||||
->label(__('filament-short-url::default.settings_nav_label'))
|
||||
->icon('heroicon-o-adjustments-horizontal')
|
||||
->color('gray')
|
||||
->size('sm')
|
||||
->url(static::getResource()::getUrl('settings')),
|
||||
|
||||
CreateAction::make()
|
||||
->icon('heroicon-o-plus')
|
||||
->size('sm')
|
||||
->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,40 +3,109 @@
|
||||
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\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Forms\Contracts\HasForms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\Page;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\Actions;
|
||||
use Filament\Schemas\Components\Section;
|
||||
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
|
||||
{
|
||||
use InteractsWithForms;
|
||||
|
||||
protected static string $resource = ShortUrlResource::class;
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-cog-6-tooth';
|
||||
|
||||
protected static ?string $slug = 'short-url-settings';
|
||||
|
||||
protected string $view = 'filament-short-url::settings';
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('filament-short-url::default.settings_nav_label') ?? 'Settings';
|
||||
}
|
||||
|
||||
public static function getNavigationGroup(): string|\UnitEnum|null
|
||||
{
|
||||
try {
|
||||
return ShortUrlResource::getNavigationGroup();
|
||||
} catch (\Throwable) {
|
||||
return __('filament-short-url::default.navigation_group');
|
||||
}
|
||||
}
|
||||
|
||||
public static function getNavigationSort(): ?int
|
||||
{
|
||||
try {
|
||||
return ShortUrlResource::getNavigationSort() + 2;
|
||||
} catch (\Throwable) {
|
||||
return 52;
|
||||
}
|
||||
}
|
||||
|
||||
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 ShortUrlResource::canViewAny();
|
||||
}
|
||||
|
||||
public ?array $data = [];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$mgr = app(ShortUrlSettingsManager::class);
|
||||
|
||||
$aasa = $mgr->get('aasa_json');
|
||||
if (! empty($aasa)) {
|
||||
$decoded = json_decode($aasa, true);
|
||||
if (json_last_error() === JSON_ERROR_NONE) {
|
||||
$aasa = json_encode($decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
}
|
||||
|
||||
$assetlinks = $mgr->get('assetlinks_json');
|
||||
if (! empty($assetlinks)) {
|
||||
$decoded = json_decode($assetlinks, true);
|
||||
if (json_last_error() === JSON_ERROR_NONE) {
|
||||
$assetlinks = json_encode($decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
}
|
||||
|
||||
$this->form->fill([
|
||||
'route_prefix' => $mgr->get('route_prefix', 's'),
|
||||
'redirect_status_code' => $mgr->get('redirect_status_code', 302),
|
||||
@@ -67,6 +136,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 +148,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'),
|
||||
@@ -88,6 +159,12 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
'vpn_block_action' => $mgr->get('vpn_block_action', 'flag_only'),
|
||||
'safe_browsing_enabled' => $mgr->get('safe_browsing_enabled', false),
|
||||
'google_safe_browsing_api_key' => $mgr->get('google_safe_browsing_api_key'),
|
||||
// Deep Linking v2.1
|
||||
'deep_linking_enabled' => $mgr->get('deep_linking_enabled', false),
|
||||
'aasa_json' => $aasa,
|
||||
'assetlinks_json' => $assetlinks,
|
||||
// Webhook signing secret
|
||||
'webhook_signing_secret' => $mgr->get('webhook_signing_secret'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -116,12 +193,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 +210,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 +231,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 +259,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 +285,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 +326,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 +345,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 +357,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 +375,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 +703,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 +836,35 @@ 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']);
|
||||
$set('webhook_signing_secret', null);
|
||||
}
|
||||
}),
|
||||
|
||||
TextInput::make('global_webhook_url')
|
||||
->label(__('filament-short-url::default.settings_global_webhook_url'))
|
||||
->helperText(__('filament-short-url::default.settings_global_webhook_url_helper'))
|
||||
->url()
|
||||
->nullable()
|
||||
->columnSpanFull(),
|
||||
->columnSpanFull()
|
||||
->visible(fn (Get $get): bool => (bool) $get('global_webhook_enabled')),
|
||||
|
||||
TextInput::make('webhook_signing_secret')
|
||||
->label('Webhook Signing Secret')
|
||||
->helperText('If configured, outgoing webhook requests will include the HMAC signature in X-ShortUrl-Signature.')
|
||||
->password()
|
||||
->revealable()
|
||||
->placeholder('••••••••••••••••••••')
|
||||
->columnSpanFull()
|
||||
->visible(fn (Get $get): bool => (bool) $get('global_webhook_enabled')),
|
||||
|
||||
Select::make('webhook_events')
|
||||
->label(__('filament-short-url::default.settings_webhook_events'))
|
||||
@@ -709,7 +877,67 @@ 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')),
|
||||
]),
|
||||
]),
|
||||
|
||||
// ── Deep Linking ─────────────────────────────────────
|
||||
Tab::make(__('filament-short-url::default.settings_tab_deep_linking'))
|
||||
->icon('heroicon-o-device-phone-mobile')
|
||||
->schema([
|
||||
Section::make(__('filament-short-url::default.settings_section_deep_linking'))
|
||||
->schema([
|
||||
Toggle::make('deep_linking_enabled')
|
||||
->label(__('filament-short-url::default.settings_deep_linking_enabled'))
|
||||
->helperText(__('filament-short-url::default.settings_deep_linking_enabled_helper'))
|
||||
->default(false)
|
||||
->inline(false)
|
||||
->live(),
|
||||
|
||||
Textarea::make('aasa_json')
|
||||
->label(__('filament-short-url::default.settings_aasa_json'))
|
||||
->helperText(__('filament-short-url::default.settings_aasa_json_helper'))
|
||||
->nullable()
|
||||
->visible(fn (Get $get): bool => (bool) $get('deep_linking_enabled'))
|
||||
->columnSpanFull()
|
||||
->extraInputAttributes(['style' => 'font-family: monospace;'])
|
||||
->rows(8)
|
||||
->rules([
|
||||
function () {
|
||||
return function (string $attribute, $value, \Closure $fail) {
|
||||
if (empty($value)) {
|
||||
return;
|
||||
}
|
||||
json_decode($value);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
$fail(__('filament-short-url::default.validation_invalid_json'));
|
||||
}
|
||||
};
|
||||
},
|
||||
]),
|
||||
|
||||
Textarea::make('assetlinks_json')
|
||||
->label(__('filament-short-url::default.settings_assetlinks_json'))
|
||||
->helperText(__('filament-short-url::default.settings_assetlinks_json_helper'))
|
||||
->nullable()
|
||||
->visible(fn (Get $get): bool => (bool) $get('deep_linking_enabled'))
|
||||
->columnSpanFull()
|
||||
->extraInputAttributes(['style' => 'font-family: monospace;'])
|
||||
->rows(8)
|
||||
->rules([
|
||||
function () {
|
||||
return function (string $attribute, $value, \Closure $fail) {
|
||||
if (empty($value)) {
|
||||
return;
|
||||
}
|
||||
json_decode($value);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
$fail(__('filament-short-url::default.validation_invalid_json'));
|
||||
}
|
||||
};
|
||||
},
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
@@ -723,10 +951,28 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
|
||||
app(ShortUrlSettingsManager::class)->set($data);
|
||||
|
||||
// Clear deep linking cache when settings are saved!
|
||||
cache()->forget('fsu:deep-linking:aasa');
|
||||
cache()->forget('fsu:deep-linking:assetlinks');
|
||||
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_saved'))
|
||||
->success()
|
||||
->send();
|
||||
|
||||
if ($newKeys = session()->get('fsu_new_api_keys')) {
|
||||
foreach ($newKeys as $newKey) {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.api_key_generated') ?? 'API Key Generated')
|
||||
->body(new HtmlString('<strong>'.$newKey['name'].'</strong>: '.(__('filament-short-url::default.api_key_warning') ?? "Please copy this key now. You won't be able to see it again!")."<br/><code style='background:#e4e4e7;color:#18181b;padding:6px;border-radius:4px;user-select:all;display:block;margin-top:6px;font-family:monospace;font-weight:bold;'>".$newKey['plain'].'</code>'))
|
||||
->warning()
|
||||
->persistent()
|
||||
->send();
|
||||
}
|
||||
|
||||
// Re-mount settings form state to reflect the new masked key names in the UI
|
||||
$this->mount();
|
||||
}
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
@@ -737,7 +983,7 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
->icon('heroicon-o-arrow-left')
|
||||
->color('gray')
|
||||
->size('sm')
|
||||
->url(static::getResource()::getUrl()),
|
||||
->url(ShortUrlResource::getUrl()),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -26,6 +28,7 @@ class ShortUrlForm
|
||||
Tabs::make()->tabs([
|
||||
static::linkTab(),
|
||||
static::targetingTab(),
|
||||
static::appLinkingTab(),
|
||||
static::trackingTab(),
|
||||
static::marketingTab(),
|
||||
static::qrDesignTab(),
|
||||
@@ -47,7 +50,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 +299,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 +369,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 +433,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 +489,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 +548,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'))
|
||||
@@ -527,4 +570,29 @@ class ShortUrlForm
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
private static function appLinkingTab(): Tab
|
||||
{
|
||||
return Tab::make(__('filament-short-url::default.tab_app_linking') ?? 'App Linking')
|
||||
->icon('heroicon-o-device-phone-mobile')
|
||||
->schema([
|
||||
Section::make(__('filament-short-url::default.form_section_app_linking') ?? 'App Linking / Deep Links')
|
||||
->schema([
|
||||
Toggle::make('auto_open_app_mobile')
|
||||
->label(__('filament-short-url::default.auto_open_app_mobile') ?? 'Auto open app on mobile')
|
||||
->helperText(__('filament-short-url::default.auto_open_app_mobile_helper') ?? 'Enable this if you want your link to automatically open as an app when accessed on mobile.')
|
||||
->default(false)
|
||||
->inline(false)
|
||||
->live(),
|
||||
|
||||
ViewField::make('app_linking_preview')
|
||||
->view('filament-short-url::app-linking-preview')
|
||||
->viewData(fn (Get $get) => [
|
||||
'destinationUrl' => $get('destination_url'),
|
||||
])
|
||||
->columnSpanFull()
|
||||
->visible(fn (Get $get): bool => (bool) $get('auto_open_app_mobile')),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlPixelResource;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\ShortUrlSettingsPage;
|
||||
use Filament\Contracts\Plugin;
|
||||
use Filament\Panel;
|
||||
|
||||
@@ -18,6 +26,8 @@ class FilamentShortUrlPlugin implements Plugin
|
||||
|
||||
protected ?string $routePrefix = null;
|
||||
|
||||
protected ?\Closure $authorizeSettingsUsing = null;
|
||||
|
||||
// ─── Factory ─────────────────────────────────────────────────────────────
|
||||
|
||||
public static function make(): static
|
||||
@@ -42,6 +52,10 @@ class FilamentShortUrlPlugin implements Plugin
|
||||
$panel
|
||||
->resources([
|
||||
ShortUrlResource::class,
|
||||
ShortUrlPixelResource::class,
|
||||
])
|
||||
->pages([
|
||||
ShortUrlSettingsPage::class,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -119,4 +133,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
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 +42,12 @@ 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',
|
||||
'2026_06_03_150000_create_short_url_pixels_table',
|
||||
'2026_06_03_160000_add_auto_open_app_mobile_to_short_urls_table',
|
||||
'2026_06_04_000000_create_short_url_settings_table',
|
||||
])
|
||||
->hasCommands([
|
||||
SyncBufferedCountersCommand::class,
|
||||
@@ -52,8 +66,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 +80,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();
|
||||
|
||||
@@ -4,11 +4,16 @@ namespace Bjanczak\FilamentShortUrl\Http\Controllers;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Jobs\SendWebhookJob;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlPixel;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
|
||||
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 +58,27 @@ class ShortUrlApiController extends Controller
|
||||
'expiration_redirect_url' => 'nullable|url|max:2048',
|
||||
'activated_at' => 'nullable|date',
|
||||
'expires_at' => 'nullable|date',
|
||||
'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',
|
||||
'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',
|
||||
]);
|
||||
|
||||
$pixelIds = $validated['pixels'] ?? [];
|
||||
|
||||
// Clean up parameters that shouldn't be mass assigned
|
||||
unset($validated['pixels']);
|
||||
|
||||
$shortUrl = $this->service->create($validated);
|
||||
|
||||
if (! empty($pixelIds)) {
|
||||
$shortUrl->pixels()->sync($pixelIds);
|
||||
}
|
||||
|
||||
// Fire 'created' webhook if active
|
||||
$this->dispatchCreatedWebhook($shortUrl);
|
||||
|
||||
@@ -88,6 +106,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 +120,19 @@ 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,
|
||||
'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 +171,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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,11 @@ namespace Bjanczak\FilamentShortUrl\Http\Controllers;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Jobs\TrackShortUrlVisitJob;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Services\AppLinkingEngine;
|
||||
use Bjanczak\FilamentShortUrl\Services\ClientIpExtractor;
|
||||
use Bjanczak\FilamentShortUrl\Services\ProxyDetectionService;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
|
||||
use Bjanczak\FilamentShortUrl\Services\UserAgentParser;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -34,13 +37,15 @@ class ShortUrlRedirectController extends Controller
|
||||
return redirect()->away($shortUrl->expiration_redirect_url, 302);
|
||||
}
|
||||
|
||||
abort(410);
|
||||
return response(view('filament-short-url::expired', [
|
||||
'shortUrl' => $shortUrl,
|
||||
]), 410)->header('Content-Type', 'text/html');
|
||||
}
|
||||
|
||||
// 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.');
|
||||
@@ -68,13 +73,27 @@ class ShortUrlRedirectController extends Controller
|
||||
$sessionKey = "short-url-auth-{$shortUrl->id}";
|
||||
if (! session()->get($sessionKey)) {
|
||||
if ($request->isMethod('POST')) {
|
||||
// Password brute force protection
|
||||
$ipAddress = ClientIpExtractor::getIp($request);
|
||||
$passwordLimiterKey = "short_url_password_limit:{$key}:".$ipAddress;
|
||||
|
||||
if (RateLimiter::tooManyAttempts($passwordLimiterKey, 5)) { // Max 5 attempts
|
||||
$retryAfter = RateLimiter::availableIn($passwordLimiterKey);
|
||||
abort(429, 'Too many incorrect password attempts. Please try again in '.$retryAfter.' seconds.', [
|
||||
'Retry-After' => $retryAfter,
|
||||
]);
|
||||
}
|
||||
|
||||
$submitted = $request->input('password');
|
||||
if ($submitted === $shortUrl->password) {
|
||||
session()->put($sessionKey, true);
|
||||
RateLimiter::clear($passwordLimiterKey);
|
||||
|
||||
return redirect()->to($request->fullUrl());
|
||||
}
|
||||
|
||||
RateLimiter::hit($passwordLimiterKey, 60); // 1 minute decay
|
||||
|
||||
$errors = new MessageBag([
|
||||
'password' => __('filament-short-url::default.password_error') ?? 'Incorrect password.',
|
||||
]);
|
||||
@@ -88,18 +107,27 @@ class ShortUrlRedirectController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Resolve Destination URL (evaluating targeting rules)
|
||||
$destination = $shortUrl->resolveDestinationUrl($request);
|
||||
// 4. Resolve Destination URL (evaluating targeting rules and forwarding query parameters)
|
||||
$destination = $this->service->resolveRedirectUrl($shortUrl, $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']);
|
||||
// App Linking / Deep Links Auto-Open Check
|
||||
if ($shortUrl->auto_open_app_mobile) {
|
||||
$uaParser = app(UserAgentParser::class);
|
||||
$parsedUa = $uaParser->parse($request->userAgent() ?? '');
|
||||
|
||||
if (! empty($queryParams)) {
|
||||
$separator = str_contains($destination, '?') ? '&' : '?';
|
||||
$destination .= $separator.http_build_query($queryParams);
|
||||
if ($parsedUa['device_type'] === 'mobile' || $parsedUa['device_type'] === 'tablet') {
|
||||
$matchedApp = AppLinkingEngine::matchApp($destination);
|
||||
if ($matchedApp !== null) {
|
||||
$deepLink = AppLinkingEngine::convertToScheme($destination, $matchedApp);
|
||||
$activePixels = $shortUrl->pixels->where('is_active', true);
|
||||
|
||||
return response(view('filament-short-url::app-redirect', [
|
||||
'destination' => $destination,
|
||||
'deepLink' => $deepLink,
|
||||
'appId' => $matchedApp,
|
||||
'pixels' => $activePixels,
|
||||
]))->header('Content-Type', 'text/html');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +145,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 +168,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) {
|
||||
@@ -153,22 +194,90 @@ class ShortUrlRedirectController extends Controller
|
||||
|
||||
// Another request beat us to it — this visit should 410
|
||||
if ($affected === 0) {
|
||||
abort(410);
|
||||
return response(view('filament-short-url::expired', [
|
||||
'shortUrl' => $shortUrl,
|
||||
]), 410)->header('Content-Type', 'text/html');
|
||||
}
|
||||
|
||||
// Manually forget cache since DB-level update does not trigger Eloquent events
|
||||
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);
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
return redirect()->away($destination, $shortUrl->redirect_status_code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve the Apple App Site Association (AASA) file for iOS Universal Links.
|
||||
*/
|
||||
public function serveAasa(Request $request): Response
|
||||
{
|
||||
if (! config('filament-short-url.deep_linking.enabled', false)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$aasaJson = config('filament-short-url.deep_linking.aasa_json');
|
||||
|
||||
if (empty($aasaJson)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$minified = cache()->remember('fsu:deep-linking:aasa', 604800, function () use ($aasaJson): string {
|
||||
try {
|
||||
$decoded = json_decode($aasaJson, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
return json_encode($decoded, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
} catch (\Throwable) {
|
||||
return $aasaJson;
|
||||
}
|
||||
});
|
||||
|
||||
return response($minified, 200, [
|
||||
'Content-Type' => 'application/json; charset=utf-8',
|
||||
'Cache-Control' => 'public, max-age=604800, must-revalidate',
|
||||
'X-Content-Type-Options' => 'nosniff',
|
||||
'Access-Control-Allow-Origin' => '*',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve the Asset Links file for Android App Links.
|
||||
*/
|
||||
public function serveAssetLinks(Request $request): Response
|
||||
{
|
||||
if (! config('filament-short-url.deep_linking.enabled', false)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$assetlinksJson = config('filament-short-url.deep_linking.assetlinks_json');
|
||||
|
||||
if (empty($assetlinksJson)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$minified = cache()->remember('fsu:deep-linking:assetlinks', 604800, function () use ($assetlinksJson): string {
|
||||
try {
|
||||
$decoded = json_decode($assetlinksJson, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
return json_encode($decoded, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
} catch (\Throwable) {
|
||||
return $assetlinksJson;
|
||||
}
|
||||
});
|
||||
|
||||
return response($minified, 200, [
|
||||
'Content-Type' => 'application/json; charset=utf-8',
|
||||
'Cache-Control' => 'public, max-age=604800, must-revalidate',
|
||||
'X-Content-Type-Options' => 'nosniff',
|
||||
'Access-Control-Allow-Origin' => '*',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,10 +38,26 @@ class AuthenticateShortUrlApi
|
||||
$keys = $mgr->get('api_keys', []);
|
||||
|
||||
$valid = false;
|
||||
$hashedInput = hash('sha256', $apiKey);
|
||||
|
||||
foreach ($keys as $keyObj) {
|
||||
if (($keyObj['key'] ?? '') === $apiKey && (bool) ($keyObj['is_active'] ?? false)) {
|
||||
$valid = true;
|
||||
break;
|
||||
if (! (bool) ($keyObj['is_active'] ?? false)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check hashed key (new format) or fall back to plaintext key (legacy format)
|
||||
$storedHash = $keyObj['hashed_key'] ?? null;
|
||||
if ($storedHash) {
|
||||
if (hash_equals($storedHash, $hashedInput)) {
|
||||
$valid = true;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
$storedPlain = $keyObj['key'] ?? '';
|
||||
if ($storedPlain !== '' && hash_equals($storedPlain, $apiKey)) {
|
||||
$valid = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,11 +40,19 @@ class SendWebhookJob implements ShouldQueue
|
||||
public function handle(): void
|
||||
{
|
||||
try {
|
||||
$headers = [
|
||||
'Content-Type' => 'application/json',
|
||||
'User-Agent' => 'wYachts-ShortUrl-Webhook/1.5',
|
||||
];
|
||||
|
||||
$secret = config('filament-short-url.webhook_signing_secret');
|
||||
if (! empty($secret)) {
|
||||
$payloadJson = json_encode($this->payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
$headers['X-ShortUrl-Signature'] = hash_hmac('sha256', $payloadJson, $secret);
|
||||
}
|
||||
|
||||
$response = Http::timeout(10)
|
||||
->withHeaders([
|
||||
'Content-Type' => 'application/json',
|
||||
'User-Agent' => 'wYachts-ShortUrl-Webhook/1.5',
|
||||
])
|
||||
->withHeaders($headers)
|
||||
->post($this->url, $this->payload);
|
||||
|
||||
if ($response->failed()) {
|
||||
|
||||
@@ -14,7 +14,6 @@ use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Queued job for recording short URL visits.
|
||||
@@ -47,6 +46,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 +81,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 +98,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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,8 +169,17 @@ class TrackShortUrlVisitJob implements ShouldQueue
|
||||
$apiSecret = config('filament-short-url.ga4.api_secret');
|
||||
$firebaseAppId = config('filament-short-url.ga4.firebase_app_id');
|
||||
|
||||
// GA4 Measurement Protocol requires a client_id
|
||||
$clientId = Str::uuid()->toString();
|
||||
// GA4 Measurement Protocol requires a client_id. We generate a deterministic UUID
|
||||
// based on the visitor IP and User Agent to allow sessions/retention tracking
|
||||
// without violating privacy (the IP and UA are hashed and cannot be reversed).
|
||||
$hash = md5($this->ipAddress.'|'.$this->userAgent);
|
||||
$clientId = sprintf('%08s-%04s-%04s-%04s-%12s',
|
||||
substr($hash, 0, 8),
|
||||
substr($hash, 8, 4),
|
||||
substr($hash, 12, 4),
|
||||
substr($hash, 16, 4),
|
||||
substr($hash, 20, 12)
|
||||
);
|
||||
|
||||
$payload = [
|
||||
'client_id' => $clientId,
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Models;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlGlobalOverview;
|
||||
@@ -13,11 +19,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 +79,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 +89,10 @@ 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',
|
||||
'auto_open_app_mobile',
|
||||
];
|
||||
|
||||
/** @var array<string, string> */
|
||||
@@ -91,6 +100,7 @@ class ShortUrl extends Model
|
||||
'is_enabled' => 'boolean',
|
||||
'single_use' => 'boolean',
|
||||
'forward_query_params' => 'boolean',
|
||||
'auto_open_app_mobile' => 'boolean',
|
||||
'track_visits' => 'boolean',
|
||||
'track_ip_address' => 'boolean',
|
||||
'track_browser' => 'boolean',
|
||||
@@ -99,6 +109,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 +117,7 @@ class ShortUrl extends Model
|
||||
'activated_at' => 'datetime',
|
||||
'deactivated_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
'qr_scans' => 'integer',
|
||||
];
|
||||
|
||||
// ─── Relations ───────────────────────────────────────────────────────────
|
||||
@@ -120,6 +132,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
|
||||
@@ -165,13 +182,13 @@ class ShortUrl extends Model
|
||||
$ttl = config('filament-short-url.cache_ttl', 3600);
|
||||
|
||||
if ($ttl <= 0) {
|
||||
return static::where('url_key', $key)->first();
|
||||
return static::where('url_key', $key)->with('pixels')->first();
|
||||
}
|
||||
|
||||
return cache()->remember(
|
||||
"filament-short-url:{$key}",
|
||||
$ttl,
|
||||
fn () => static::where('url_key', $key)->first()
|
||||
fn () => static::where('url_key', $key)->with('pixels')->first()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -183,30 +200,48 @@ class ShortUrl extends Model
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saving(function (self $m) {
|
||||
if ($m->single_use) {
|
||||
$m->max_visits = null;
|
||||
if ($m->single_use || $m->max_visits !== null || $m->expires_at !== null) {
|
||||
if ($m->single_use) {
|
||||
$m->max_visits = null;
|
||||
}
|
||||
$m->redirect_status_code = 302; // Force temporary redirect to prevent browser caching of limited/expiring 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) {
|
||||
cache()->forget("filament-short-url:{$m->url_key}");
|
||||
cache()->forget("filament-short-url:visits:{$m->id}");
|
||||
|
||||
if ($m->wasChanged('url_key')) {
|
||||
$oldKey = $m->getOriginal('url_key');
|
||||
@@ -215,11 +250,18 @@ 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("filament-short-url:visits:{$m->id}");
|
||||
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 +282,17 @@ class ShortUrl extends Model
|
||||
|
||||
public function getRealTimeTotalVisits(): int
|
||||
{
|
||||
$total = $this->total_visits;
|
||||
|
||||
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 $this->total_visits + $buffered;
|
||||
}
|
||||
|
||||
return $total;
|
||||
// Use real-time visit count in cache to keep the cached model instance updated
|
||||
$cacheKey = "filament-short-url:visits:{$this->id}";
|
||||
|
||||
return (int) cache()->remember($cacheKey, 3600, fn () => $this->total_visits);
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
@@ -256,6 +301,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 +366,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 +442,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 +503,75 @@ 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);
|
||||
|
||||
// Keep the real-time cache count incremented
|
||||
$cacheKey = "filament-short-url:visits:{$this->id}";
|
||||
try {
|
||||
if (cache()->has($cacheKey)) {
|
||||
cache()->increment($cacheKey);
|
||||
} else {
|
||||
cache()->put($cacheKey, $this->total_visits + 1, 3600);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Ignore cache errors in increment to never disrupt redirection
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -490,6 +629,7 @@ class ShortUrl extends Model
|
||||
$visitsToday = count($rawVisits);
|
||||
$visitsThisWeek = 0;
|
||||
$visitsThisMonth = 0;
|
||||
$qrScans = 0;
|
||||
|
||||
$visitsByCountry = [];
|
||||
$visitsByCity = [];
|
||||
@@ -500,6 +640,7 @@ class ShortUrl extends Model
|
||||
$utmSources = [];
|
||||
$utmMediums = [];
|
||||
$utmCampaigns = [];
|
||||
$visitsByLanguage = [];
|
||||
|
||||
// Sum up daily stats
|
||||
$startOfWeek = now()->startOfWeek()->toDateString();
|
||||
@@ -508,6 +649,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 +668,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 +680,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 +762,7 @@ class ShortUrl extends Model
|
||||
arsort($utmSources);
|
||||
arsort($utmMediums);
|
||||
arsort($utmCampaigns);
|
||||
arsort($visitsByLanguage);
|
||||
|
||||
return [
|
||||
'totalVisits' => $totalVisits,
|
||||
@@ -630,6 +780,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 +793,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 +807,7 @@ class ShortUrl extends Model
|
||||
|
||||
static::$bufferedTotalVisits = [];
|
||||
static::$bufferedUniqueVisits = [];
|
||||
static::$bufferedQrScans = [];
|
||||
|
||||
if (! config('filament-short-url.counter_buffering.enabled', false)) {
|
||||
return;
|
||||
@@ -682,15 +837,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 +856,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 +901,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -42,6 +48,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 +66,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
|
||||
|
||||
34
src/Models/ShortUrlPixel.php
Normal file
34
src/Models/ShortUrlPixel.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -49,6 +55,8 @@ class ShortUrlVisit extends Model
|
||||
'visited_at',
|
||||
'is_bot',
|
||||
'is_proxy',
|
||||
'is_qr_scan',
|
||||
'browser_language',
|
||||
];
|
||||
|
||||
/** @var array<string, string> */
|
||||
@@ -56,6 +64,7 @@ class ShortUrlVisit extends Model
|
||||
'visited_at' => 'datetime',
|
||||
'is_bot' => 'boolean',
|
||||
'is_proxy' => 'boolean',
|
||||
'is_qr_scan' => 'boolean',
|
||||
];
|
||||
|
||||
public function shortUrl(): BelongsTo
|
||||
|
||||
248
src/Services/AppLinkingEngine.php
Normal file
248
src/Services/AppLinkingEngine.php
Normal file
@@ -0,0 +1,248 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Services;
|
||||
|
||||
class AppLinkingEngine
|
||||
{
|
||||
/**
|
||||
* List of supported apps with their metadata.
|
||||
*
|
||||
* @return array<string, array{name: string, color: string, icon: string, domains: string[]}>
|
||||
*/
|
||||
public static function getSupportedApps(): array
|
||||
{
|
||||
return [
|
||||
'youtube' => [
|
||||
'name' => 'YouTube',
|
||||
'color' => '#ff0000',
|
||||
'icon' => 'fab-youtube',
|
||||
'domains' => ['youtube.com', 'youtu.be', 'youtube-nocookie.com'],
|
||||
],
|
||||
'tiktok' => [
|
||||
'name' => 'TikTok',
|
||||
'color' => '#fc295d',
|
||||
'icon' => 'fab-tiktok',
|
||||
'domains' => ['tiktok.com'],
|
||||
],
|
||||
'instagram' => [
|
||||
'name' => 'Instagram',
|
||||
'color' => '#e1306c',
|
||||
'icon' => 'fab-instagram',
|
||||
'domains' => ['instagram.com', 'instagr.am'],
|
||||
],
|
||||
'x' => [
|
||||
'name' => 'X (Twitter)',
|
||||
'color' => '#1da1f2',
|
||||
'icon' => 'fab-x-twitter',
|
||||
'domains' => ['x.com', 'twitter.com'],
|
||||
],
|
||||
'spotify' => [
|
||||
'name' => 'Spotify',
|
||||
'color' => '#2cd85c',
|
||||
'icon' => 'fab-spotify',
|
||||
'domains' => ['spotify.com', 'open.spotify.com', 'spotify.link'],
|
||||
],
|
||||
'facebook' => [
|
||||
'name' => 'Facebook',
|
||||
'color' => '#1877f2',
|
||||
'icon' => 'fab-facebook',
|
||||
'domains' => ['facebook.com', 'fb.com', 'fb.me'],
|
||||
],
|
||||
'reddit' => [
|
||||
'name' => 'Reddit',
|
||||
'color' => '#fc3a06',
|
||||
'icon' => 'fab-reddit',
|
||||
'domains' => ['reddit.com', 'redd.it'],
|
||||
],
|
||||
'snapchat' => [
|
||||
'name' => 'Snapchat',
|
||||
'color' => '#ffb700',
|
||||
'icon' => 'fab-snapchat',
|
||||
'domains' => ['snapchat.com'],
|
||||
],
|
||||
'whatsapp' => [
|
||||
'name' => 'WhatsApp',
|
||||
'color' => '#128c7e',
|
||||
'icon' => 'fab-whatsapp',
|
||||
'domains' => ['whatsapp.com', 'wa.me', 'api.whatsapp.com'],
|
||||
],
|
||||
'linkedin' => [
|
||||
'name' => 'LinkedIn',
|
||||
'color' => '#0a66c2',
|
||||
'icon' => 'fab-linkedin',
|
||||
'domains' => ['linkedin.com'],
|
||||
],
|
||||
'pinterest' => [
|
||||
'name' => 'Pinterest',
|
||||
'color' => '#e60023',
|
||||
'icon' => 'fab-pinterest',
|
||||
'domains' => ['pinterest.com', 'pin.it'],
|
||||
],
|
||||
'twitch' => [
|
||||
'name' => 'Twitch',
|
||||
'color' => '#9146ff',
|
||||
'icon' => 'fab-twitch',
|
||||
'domains' => ['twitch.tv'],
|
||||
],
|
||||
'netflix' => [
|
||||
'name' => 'Netflix',
|
||||
'color' => '#e50914',
|
||||
'icon' => 'fas-film',
|
||||
'domains' => ['netflix.com'],
|
||||
],
|
||||
'google_sheets' => [
|
||||
'name' => 'Google Sheets',
|
||||
'color' => '#25a465',
|
||||
'icon' => 'fas-file',
|
||||
'domains' => ['docs.google.com/spreadsheets'],
|
||||
],
|
||||
'google_docs' => [
|
||||
'name' => 'Google Docs',
|
||||
'color' => '#2a7efc',
|
||||
'icon' => 'fas-file-word',
|
||||
'domains' => ['docs.google.com/document'],
|
||||
],
|
||||
'google_slides' => [
|
||||
'name' => 'Google Slides',
|
||||
'color' => '#fabe0b',
|
||||
'icon' => 'fas-image',
|
||||
'domains' => ['docs.google.com/presentation'],
|
||||
],
|
||||
'google_maps' => [
|
||||
'name' => 'Google Maps',
|
||||
'color' => '#4285f4',
|
||||
'icon' => 'fas-map-location-dot',
|
||||
'domains' => ['google.com/maps', 'maps.google.com', 'maps.app.goo.gl'],
|
||||
],
|
||||
'facebook_messenger' => [
|
||||
'name' => 'Facebook Messenger',
|
||||
'color' => '#0084ff',
|
||||
'icon' => 'fab-facebook-messenger',
|
||||
'domains' => ['messenger.com'],
|
||||
],
|
||||
'apple_music' => [
|
||||
'name' => 'Apple Music',
|
||||
'color' => '#f8506b',
|
||||
'icon' => 'fab-apple',
|
||||
'domains' => ['music.apple.com'],
|
||||
],
|
||||
'airbnb' => [
|
||||
'name' => 'Airbnb',
|
||||
'color' => '#ff5a5f',
|
||||
'icon' => 'fab-airbnb',
|
||||
'domains' => ['airbnb.com', 'airbnb.pl', 'airbnb.de', 'airbnb.fr'],
|
||||
],
|
||||
'tripadvisor' => [
|
||||
'name' => 'TripAdvisor',
|
||||
'color' => '#00af87',
|
||||
'icon' => 'fas-plane',
|
||||
'domains' => ['tripadvisor.com', 'tripadvisor.pl'],
|
||||
],
|
||||
'amazon' => [
|
||||
'name' => 'Amazon',
|
||||
'color' => '#ff9900',
|
||||
'icon' => 'fab-amazon',
|
||||
'domains' => ['amazon.com', 'amazon.co.uk', 'amazon.de', 'amazon.pl', 'amazon.fr', 'amazon.it', 'amazon.es'],
|
||||
],
|
||||
'stockx' => [
|
||||
'name' => 'StockX',
|
||||
'color' => '#006341',
|
||||
'icon' => 'fas-chart-line',
|
||||
'domains' => ['stockx.com'],
|
||||
],
|
||||
'booking' => [
|
||||
'name' => 'Booking.com',
|
||||
'color' => '#003580',
|
||||
'icon' => 'fas-hotel',
|
||||
'domains' => ['booking.com'],
|
||||
],
|
||||
'aliexpress' => [
|
||||
'name' => 'AliExpress',
|
||||
'color' => '#e62e04',
|
||||
'icon' => 'fas-cart-shopping',
|
||||
'domains' => ['aliexpress.com', 'aliexpress.ru'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify the app matching the destination URL.
|
||||
*/
|
||||
public static function matchApp(?string $url): ?string
|
||||
{
|
||||
if (empty($url)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$urlLower = strtolower($url);
|
||||
|
||||
foreach (static::getSupportedApps() as $appId => $app) {
|
||||
foreach ($app['domains'] as $domain) {
|
||||
if (str_contains($urlLower, $domain)) {
|
||||
return $appId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert standard HTTP URL to its native deep link scheme.
|
||||
*/
|
||||
public static function convertToScheme(string $url, string $appId): string
|
||||
{
|
||||
// Standard fallback: replace http(s) with scheme
|
||||
$scheme = match ($appId) {
|
||||
'youtube' => 'youtube://',
|
||||
'tiktok' => 'tiktok://',
|
||||
'instagram' => 'instagram://',
|
||||
'x' => 'twitter://',
|
||||
'spotify' => 'spotify://',
|
||||
'facebook' => 'fb://',
|
||||
'reddit' => 'reddit://',
|
||||
'snapchat' => 'snapchat://',
|
||||
'whatsapp' => 'whatsapp://',
|
||||
'linkedin' => 'linkedin://',
|
||||
'pinterest' => 'pinterest://',
|
||||
'twitch' => 'twitch://',
|
||||
'netflix' => 'netflix://',
|
||||
'facebook_messenger' => 'fb-messenger://',
|
||||
'apple_music' => 'music://',
|
||||
'airbnb' => 'airbnb://',
|
||||
'tripadvisor' => 'tripadvisor://',
|
||||
'amazon' => 'amazon://',
|
||||
'stockx' => 'stockx://',
|
||||
'booking' => 'booking://',
|
||||
'aliexpress' => 'aliexpress://',
|
||||
'google_sheets' => 'googlesheets://',
|
||||
'google_docs' => 'googledocs://',
|
||||
'google_slides' => 'googleslides://',
|
||||
'google_maps' => 'googlemaps://',
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($scheme === null) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
// Strip http:// or https://
|
||||
$urlWithoutProtocol = preg_replace('/^https?:\/\//i', '', $url);
|
||||
|
||||
// Spotify URI format (special case)
|
||||
if ($appId === 'spotify') {
|
||||
// open.spotify.com/track/123 -> spotify:track:123
|
||||
if (preg_match('/open\.spotify\.com\/(track|playlist|album|artist)\/([a-zA-Z0-9]+)/i', $url, $matches)) {
|
||||
return "spotify:{$matches[1]}:{$matches[2]}";
|
||||
}
|
||||
}
|
||||
|
||||
return $scheme.$urlWithoutProtocol;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Services;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Services;
|
||||
|
||||
use GeoIp2\Database\Reader;
|
||||
@@ -91,7 +97,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;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package janczakb/filament-short-url
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
@@ -25,25 +32,35 @@ class ProxyDetectionService
|
||||
}
|
||||
|
||||
$cacheKey = "short-url:proxy-check:{$ip}";
|
||||
$cacheTtl = (int) config('filament-short-url.vpn_detection.cache_ttl', 86400);
|
||||
|
||||
return Cache::remember($cacheKey, $cacheTtl, function () use ($ip) {
|
||||
$driver = config('filament-short-url.vpn_detection.driver', 'ip-api');
|
||||
$timeout = (int) config('filament-short-url.vpn_detection.timeout', 2);
|
||||
if (Cache::has($cacheKey)) {
|
||||
return Cache::get($cacheKey);
|
||||
}
|
||||
|
||||
try {
|
||||
if ($driver === 'vpnapi') {
|
||||
return $this->queryVpnApi($ip, $timeout);
|
||||
}
|
||||
$driver = config('filament-short-url.vpn_detection.driver', 'ip-api');
|
||||
// Aggressive 800ms timeout to avoid hanging the redirect thread on API lag
|
||||
$timeout = 0.8;
|
||||
|
||||
// Default to ip-api
|
||||
return $this->queryIpApi($ip, $timeout);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("Proxy detection failed for IP: {$ip}. Error: " . $e->getMessage());
|
||||
|
||||
return ['is_proxy' => false, 'is_bot' => false];
|
||||
try {
|
||||
if ($driver === 'vpnapi') {
|
||||
$result = $this->queryVpnApi($ip, $timeout);
|
||||
} else {
|
||||
$result = $this->queryIpApi($ip, $timeout);
|
||||
}
|
||||
});
|
||||
|
||||
$cacheTtl = (int) config('filament-short-url.vpn_detection.cache_ttl', 86400);
|
||||
Cache::put($cacheKey, $result, $cacheTtl);
|
||||
|
||||
return $result;
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("Proxy detection failed or timed out for IP: {$ip}. Error: ".$e->getMessage());
|
||||
|
||||
// Temporary cache for failure (60 seconds) to allow retry and prevent whitelisting IPs long-term
|
||||
$failResult = ['is_proxy' => false, 'is_bot' => false];
|
||||
Cache::put($cacheKey, $failResult, 60);
|
||||
|
||||
return $failResult;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,14 +68,18 @@ class ProxyDetectionService
|
||||
*
|
||||
* @return array{is_proxy: bool, is_bot: bool}
|
||||
*/
|
||||
private function queryIpApi(string $ip, int $timeout): array
|
||||
private function queryIpApi(string $ip, float|int $timeout): array
|
||||
{
|
||||
$url = "http://ip-api.com/json/{$ip}?fields=status,message,proxy,hosting";
|
||||
|
||||
$response = Http::timeout($timeout)->get($url);
|
||||
|
||||
if ($response->failed() || $response->json('status') === 'fail') {
|
||||
return ['is_proxy' => false, 'is_bot' => false];
|
||||
if ($response->failed()) {
|
||||
throw new \RuntimeException('HTTP request failed with status: '.$response->status());
|
||||
}
|
||||
|
||||
if ($response->json('status') === 'fail') {
|
||||
throw new \RuntimeException('API returned failure: '.$response->json('message'));
|
||||
}
|
||||
|
||||
$isProxy = (bool) $response->json('proxy', false);
|
||||
@@ -75,12 +96,12 @@ class ProxyDetectionService
|
||||
*
|
||||
* @return array{is_proxy: bool, is_bot: bool}
|
||||
*/
|
||||
private function queryVpnApi(string $ip, int $timeout): array
|
||||
private function queryVpnApi(string $ip, float|int $timeout): array
|
||||
{
|
||||
$key = config('filament-short-url.vpn_detection.vpnapi_key');
|
||||
|
||||
if (empty($key)) {
|
||||
return ['is_proxy' => false, 'is_bot' => false];
|
||||
throw new \RuntimeException('vpnapi.io API key is empty.');
|
||||
}
|
||||
|
||||
$url = "https://vpnapi.io/api/{$ip}?key={$key}";
|
||||
@@ -88,7 +109,7 @@ class ProxyDetectionService
|
||||
$response = Http::timeout($timeout)->get($url);
|
||||
|
||||
if ($response->failed()) {
|
||||
return ['is_proxy' => false, 'is_bot' => false];
|
||||
throw new \RuntimeException('HTTP request failed with status: '.$response->status());
|
||||
}
|
||||
|
||||
$security = $response->json('security', []);
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
@@ -61,7 +67,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 +77,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Services;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Services;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class ShortUrlSettingsManager
|
||||
{
|
||||
@@ -25,15 +33,62 @@ class ShortUrlSettingsManager
|
||||
return $this->cache;
|
||||
}
|
||||
|
||||
$path = $this->getSettingsPath();
|
||||
$stored = [];
|
||||
|
||||
if (File::exists($path)) {
|
||||
$readFromDb = function () {
|
||||
try {
|
||||
$stored = json_decode(File::get($path), true) ?: [];
|
||||
} catch (\Throwable) {
|
||||
// Fallback to empty if json is corrupt
|
||||
// Ensure table exists before querying to avoid exception on unmigrated db
|
||||
if (! Schema::hasTable('short_url_settings')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$settings = DB::table('short_url_settings')
|
||||
->pluck('value', 'key')
|
||||
->toArray();
|
||||
|
||||
$decoded = [];
|
||||
foreach ($settings as $key => $val) {
|
||||
$decoded[$key] = json_decode($val, true);
|
||||
}
|
||||
|
||||
// If DB is empty, check if we can import legacy settings from JSON file
|
||||
if (empty($decoded)) {
|
||||
$legacyPath = $this->getSettingsPath();
|
||||
if (File::exists($legacyPath)) {
|
||||
try {
|
||||
$legacyContent = File::get($legacyPath);
|
||||
$legacySettings = json_decode($legacyContent, true) ?: [];
|
||||
|
||||
// Import each legacy setting to database
|
||||
foreach ($legacySettings as $k => $v) {
|
||||
DB::table('short_url_settings')->updateOrInsert(
|
||||
['key' => $k],
|
||||
['value' => json_encode($v), 'updated_at' => now(), 'created_at' => now()]
|
||||
);
|
||||
}
|
||||
|
||||
// Rename the legacy file to avoid re-importing
|
||||
File::move($legacyPath, $legacyPath.'.bak');
|
||||
|
||||
return $legacySettings;
|
||||
} catch (\Throwable $e) {
|
||||
// Suppress import failures to prevent boot crash
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
} catch (\Throwable $e) {
|
||||
// Fallback to empty if DB query fails during boot/installation
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// Cache the settings indefinitely (31536000 seconds = 1 year) in multi-server environments
|
||||
$stored = app()->bound('cache')
|
||||
? cache()->remember('filament-short-url:settings', 31536000, $readFromDb)
|
||||
: $readFromDb();
|
||||
|
||||
if (! is_array($stored)) {
|
||||
$stored = [];
|
||||
}
|
||||
|
||||
// Merge stored settings with default values from config()
|
||||
@@ -67,6 +122,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 +134,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'),
|
||||
@@ -88,6 +145,12 @@ class ShortUrlSettingsManager
|
||||
'vpn_block_action' => config('filament-short-url.vpn_detection.block_action', 'flag_only'),
|
||||
'safe_browsing_enabled' => config('filament-short-url.safe_browsing.enabled', false),
|
||||
'google_safe_browsing_api_key' => config('filament-short-url.safe_browsing.api_key'),
|
||||
// Deep Linking v2.1
|
||||
'deep_linking_enabled' => config('filament-short-url.deep_linking.enabled', false),
|
||||
'aasa_json' => config('filament-short-url.deep_linking.aasa_json'),
|
||||
'assetlinks_json' => config('filament-short-url.deep_linking.assetlinks_json'),
|
||||
// Webhook signing secret
|
||||
'webhook_signing_secret' => null,
|
||||
], $stored);
|
||||
|
||||
return $this->cache;
|
||||
@@ -99,19 +162,12 @@ class ShortUrlSettingsManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist settings to JSON file.
|
||||
* Persist settings to the database.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function set(array $data): void
|
||||
{
|
||||
$path = $this->getSettingsPath();
|
||||
$dir = dirname($path);
|
||||
|
||||
if (! File::isDirectory($dir)) {
|
||||
File::makeDirectory($dir, 0755, true);
|
||||
}
|
||||
|
||||
$oldPrefix = $this->get('route_prefix');
|
||||
|
||||
// Keep only supported settings keys to prevent bloat
|
||||
@@ -145,6 +201,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 +213,7 @@ class ShortUrlSettingsManager
|
||||
'qr_gradient_type',
|
||||
'global_webhook_url',
|
||||
'webhook_events',
|
||||
'global_webhook_enabled',
|
||||
'api_keys',
|
||||
'api_enabled',
|
||||
'site_name',
|
||||
@@ -166,6 +224,12 @@ class ShortUrlSettingsManager
|
||||
'vpn_block_action',
|
||||
'safe_browsing_enabled',
|
||||
'google_safe_browsing_api_key',
|
||||
// Deep Linking v2.1
|
||||
'deep_linking_enabled',
|
||||
'aasa_json',
|
||||
'assetlinks_json',
|
||||
// Webhook signing secret
|
||||
'webhook_signing_secret',
|
||||
];
|
||||
|
||||
$filtered = array_intersect_key($data, array_flip($keys));
|
||||
@@ -239,6 +303,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 +318,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'];
|
||||
@@ -258,8 +329,57 @@ class ShortUrlSettingsManager
|
||||
if (isset($filtered['safe_browsing_enabled'])) {
|
||||
$filtered['safe_browsing_enabled'] = (bool) $filtered['safe_browsing_enabled'];
|
||||
}
|
||||
if (isset($filtered['deep_linking_enabled'])) {
|
||||
$filtered['deep_linking_enabled'] = (bool) $filtered['deep_linking_enabled'];
|
||||
}
|
||||
|
||||
File::put($path, json_encode($filtered, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
$newKeys = [];
|
||||
if (isset($filtered['api_keys']) && is_array($filtered['api_keys'])) {
|
||||
foreach ($filtered['api_keys'] as &$keyObj) {
|
||||
$key = $keyObj['key'] ?? '';
|
||||
if (str_starts_with($key, 'sh_key_')) {
|
||||
$plainKey = $key;
|
||||
$hashed = hash('sha256', $plainKey);
|
||||
$masked = substr($plainKey, 0, 11).'••••'.substr($plainKey, -4);
|
||||
|
||||
$keyObj['hashed_key'] = $hashed;
|
||||
$keyObj['key'] = $masked;
|
||||
|
||||
$newKeys[] = [
|
||||
'name' => $keyObj['name'] ?? 'API Key',
|
||||
'plain' => $plainKey,
|
||||
];
|
||||
}
|
||||
}
|
||||
if (! empty($newKeys)) {
|
||||
session()->flash('fsu_new_api_keys', $newKeys);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (Schema::hasTable('short_url_settings')) {
|
||||
DB::transaction(function () use ($filtered) {
|
||||
// Update or insert each key
|
||||
foreach ($filtered as $key => $val) {
|
||||
DB::table('short_url_settings')->updateOrInsert(
|
||||
['key' => $key],
|
||||
['value' => json_encode($val), 'updated_at' => now()]
|
||||
);
|
||||
}
|
||||
|
||||
// Delete settings from the database that are no longer in keys (e.g. removed features)
|
||||
DB::table('short_url_settings')
|
||||
->whereNotIn('key', array_keys($filtered))
|
||||
->delete();
|
||||
});
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Ignore / log database persist errors
|
||||
}
|
||||
|
||||
if (app()->bound('cache')) {
|
||||
cache()->forget('filament-short-url:settings');
|
||||
}
|
||||
$this->cache = null;
|
||||
|
||||
// Apply immediately to current request config
|
||||
@@ -315,6 +435,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'],
|
||||
@@ -337,6 +458,12 @@ class ShortUrlSettingsManager
|
||||
'filament-short-url.vpn_detection.timeout' => 2,
|
||||
'filament-short-url.safe_browsing.enabled' => (bool) ($settings['safe_browsing_enabled'] ?? false),
|
||||
'filament-short-url.safe_browsing.api_key' => $settings['google_safe_browsing_api_key'] ?? null,
|
||||
// Deep Linking v2.1
|
||||
'filament-short-url.deep_linking.enabled' => (bool) ($settings['deep_linking_enabled'] ?? false),
|
||||
'filament-short-url.deep_linking.aasa_json' => $settings['aasa_json'] ?? null,
|
||||
'filament-short-url.deep_linking.assetlinks_json' => $settings['assetlinks_json'] ?? null,
|
||||
// Webhook signing secret
|
||||
'filament-short-url.webhook_signing_secret' => $settings['webhook_signing_secret'] ?? null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Services;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
@@ -34,6 +40,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 +64,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 +94,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 +134,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;
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Services;
|
||||
|
||||
/**
|
||||
@@ -37,6 +43,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 +75,8 @@ class UserAgentParser
|
||||
{
|
||||
$patterns = [
|
||||
'/Edg\/([0-9.]+)/',
|
||||
'/EdgiOS\/([0-9.]+)/',
|
||||
'/EdgA\/([0-9.]+)/',
|
||||
'/OPR\/([0-9.]+)/',
|
||||
'/SamsungBrowser\/([0-9.]+)/',
|
||||
'/CriOS\/([0-9.]+)/',
|
||||
|
||||
133
tests/Feature/ShortUrlApiTest.php
Normal file
133
tests/Feature/ShortUrlApiTest.php
Normal file
@@ -0,0 +1,133 @@
|
||||
<?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]);
|
||||
|
||||
$pixel = \Bjanczak\FilamentShortUrl\Models\ShortUrlPixel::create([
|
||||
'name' => 'Meta Pixel Test',
|
||||
'type' => 'meta',
|
||||
'pixel_id' => '12345',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/short-url/links', [
|
||||
'destination_url' => 'https://google.com',
|
||||
'url_key' => 'googleapi',
|
||||
'notes' => 'API Generated link',
|
||||
'single_use' => true,
|
||||
'pixels' => [$pixel->id],
|
||||
'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',
|
||||
]);
|
||||
|
||||
$shortUrl = ShortUrl::where('url_key', 'googleapi')->first();
|
||||
$this->assertDatabaseHas('short_url_pixel', [
|
||||
'short_url_id' => $shortUrl->id,
|
||||
'pixel_id' => $pixel->id,
|
||||
]);
|
||||
|
||||
// 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,
|
||||
]);
|
||||
});
|
||||
225
tests/Feature/ShortUrlCoreOptimizationTest.php
Normal file
225
tests/Feature/ShortUrlCoreOptimizationTest.php
Normal file
@@ -0,0 +1,225 @@
|
||||
<?php
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Jobs\SendWebhookJob;
|
||||
use Bjanczak\FilamentShortUrl\Jobs\TrackShortUrlVisitJob;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlDailyStats;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlSettingsManager;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
beforeEach(function () {
|
||||
// Clean up any test configurations
|
||||
cache()->forget('filament-short-url:settings');
|
||||
app(ShortUrlSettingsManager::class)->set(['route_prefix' => 's']);
|
||||
});
|
||||
|
||||
it('migrates settings to database and caches them', function () {
|
||||
// 1. Verify schema has table
|
||||
expect(Schema::hasTable('short_url_settings'))->toBeTrue();
|
||||
|
||||
// 2. Write setting and verify it exists in db
|
||||
$mgr = app(ShortUrlSettingsManager::class);
|
||||
$mgr->set(['route_prefix' => 'custom-prefix']);
|
||||
|
||||
$this->assertDatabaseHas('short_url_settings', [
|
||||
'key' => 'route_prefix',
|
||||
'value' => json_encode('custom-prefix'),
|
||||
]);
|
||||
|
||||
// 3. Clear cache manually and verify it still reads correctly
|
||||
cache()->forget('filament-short-url:settings');
|
||||
expect($mgr->get('route_prefix'))->toBe('custom-prefix');
|
||||
});
|
||||
|
||||
it('forces 302 redirect status code for limited/expiring links', function () {
|
||||
// Case A: Link with expires_at
|
||||
$url1 = ShortUrl::create([
|
||||
'destination_url' => 'https://example.com/expiring',
|
||||
'url_key' => 'expiringlink',
|
||||
'expires_at' => now()->addDays(5),
|
||||
'redirect_status_code' => 301, // User requests 301
|
||||
]);
|
||||
expect($url1->redirect_status_code)->toBe(302); // Enforced to 302
|
||||
|
||||
// Case B: Link with max_visits
|
||||
$url2 = ShortUrl::create([
|
||||
'destination_url' => 'https://example.com/limited',
|
||||
'url_key' => 'limitedlink',
|
||||
'max_visits' => 100,
|
||||
'redirect_status_code' => 301,
|
||||
]);
|
||||
expect($url2->redirect_status_code)->toBe(302); // Enforced to 302
|
||||
});
|
||||
|
||||
it('generates a deterministic privacy-safe GA4 client ID', function () {
|
||||
$job1 = new TrackShortUrlVisitJob(
|
||||
shortUrl: new ShortUrl(['id' => 1]),
|
||||
ipAddress: '192.168.1.1',
|
||||
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)'
|
||||
);
|
||||
|
||||
$job2 = new TrackShortUrlVisitJob(
|
||||
shortUrl: new ShortUrl(['id' => 1]),
|
||||
ipAddress: '192.168.1.1',
|
||||
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)'
|
||||
);
|
||||
|
||||
$job3 = new TrackShortUrlVisitJob(
|
||||
shortUrl: new ShortUrl(['id' => 1]),
|
||||
ipAddress: '8.8.8.8',
|
||||
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X)'
|
||||
);
|
||||
|
||||
// Use reflection to access the private sendGa4Hit method or verify GA4 client ID determination
|
||||
$ref = new ReflectionClass(TrackShortUrlVisitJob::class);
|
||||
$method = $ref->getMethod('sendGa4Hit');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$shortUrl = ShortUrl::create([
|
||||
'destination_url' => 'https://example.com/ga4',
|
||||
'url_key' => 'ga4test',
|
||||
'ga_tracking_id' => 'G-12345',
|
||||
]);
|
||||
app(ShortUrlSettingsManager::class)->set([
|
||||
'ga4_api_secret' => 'secret_secret_123',
|
||||
]);
|
||||
|
||||
Http::fake();
|
||||
|
||||
$visit = new ShortUrlVisit([
|
||||
'device_type' => 'desktop',
|
||||
'country' => 'Poland',
|
||||
'browser' => 'Chrome',
|
||||
]);
|
||||
|
||||
// Send first hit
|
||||
$method->invoke($job1, $shortUrl, $visit);
|
||||
// Send second hit (same IP/UA)
|
||||
$method->invoke($job2, $shortUrl, $visit);
|
||||
// Send third hit (different IP/UA)
|
||||
$method->invoke($job3, $shortUrl, $visit);
|
||||
|
||||
$requests = Http::recorded();
|
||||
expect($requests)->toHaveCount(3);
|
||||
|
||||
$body1 = json_decode($requests[0][0]->body(), true);
|
||||
$body2 = json_decode($requests[1][0]->body(), true);
|
||||
$body3 = json_decode($requests[2][0]->body(), true);
|
||||
|
||||
// Client ID 1 and 2 must match exactly since they originate from the same user (deterministic hash)
|
||||
expect($body1['client_id'])->toBe($body2['client_id']);
|
||||
|
||||
// Client ID 3 must be different
|
||||
expect($body1['client_id'])->not->toBe($body3['client_id']);
|
||||
});
|
||||
|
||||
it('signs outgoing webhooks with X-ShortUrl-Signature when secret is configured', function () {
|
||||
app(ShortUrlSettingsManager::class)->set([
|
||||
'webhook_signing_secret' => 'my-signing-secret-key-123',
|
||||
]);
|
||||
|
||||
Http::fake();
|
||||
|
||||
$job = new SendWebhookJob(
|
||||
url: 'https://webhook.site/my-endpoint',
|
||||
event: 'visited',
|
||||
payload: ['click_id' => 99, 'short_url_id' => 1]
|
||||
);
|
||||
|
||||
$job->handle();
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
expect($request->hasHeader('X-ShortUrl-Signature'))->toBeTrue();
|
||||
|
||||
$secret = 'my-signing-secret-key-123';
|
||||
$payloadJson = json_encode(['click_id' => 99, 'short_url_id' => 1], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
$expectedSignature = hash_hmac('sha256', $payloadJson, $secret);
|
||||
|
||||
expect($request->header('X-ShortUrl-Signature')[0])->toBe($expectedSignature);
|
||||
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
it('runs database-level daily stats aggregation successfully', function () {
|
||||
// 1. Seed raw visits for a previous day
|
||||
$yesterday = Carbon::yesterday()->toDateString();
|
||||
|
||||
$link = ShortUrl::create([
|
||||
'destination_url' => 'https://example.com/aggregate',
|
||||
'url_key' => 'aggkey',
|
||||
]);
|
||||
|
||||
DB::table('short_url_visits')->insert([
|
||||
[
|
||||
'short_url_id' => $link->id,
|
||||
'ip_hash' => hash('sha256', '1.1.1.1'),
|
||||
'browser' => 'Chrome',
|
||||
'operating_system' => 'macOS',
|
||||
'device_type' => 'desktop',
|
||||
'country' => 'Poland',
|
||||
'country_code' => 'PL',
|
||||
'city' => 'Warsaw',
|
||||
'referer_host' => 'google.com',
|
||||
'visited_at' => $yesterday.' 10:00:00',
|
||||
'is_qr_scan' => false,
|
||||
'browser_language' => 'pl',
|
||||
],
|
||||
[
|
||||
'short_url_id' => $link->id,
|
||||
'ip_hash' => hash('sha256', '1.1.1.1'), // same user
|
||||
'browser' => 'Chrome',
|
||||
'operating_system' => 'macOS',
|
||||
'device_type' => 'desktop',
|
||||
'country' => 'Poland',
|
||||
'country_code' => 'PL',
|
||||
'city' => 'Warsaw',
|
||||
'referer_host' => 'google.com',
|
||||
'visited_at' => $yesterday.' 11:00:00',
|
||||
'is_qr_scan' => false,
|
||||
'browser_language' => 'pl',
|
||||
],
|
||||
[
|
||||
'short_url_id' => $link->id,
|
||||
'ip_hash' => hash('sha256', '2.2.2.2'), // unique user, QR scan
|
||||
'browser' => 'Safari',
|
||||
'operating_system' => 'iOS',
|
||||
'device_type' => 'mobile',
|
||||
'country' => 'Poland',
|
||||
'country_code' => 'PL',
|
||||
'city' => 'Krakow',
|
||||
'referer_host' => 'facebook.com',
|
||||
'visited_at' => $yesterday.' 12:00:00',
|
||||
'is_qr_scan' => true,
|
||||
'browser_language' => 'en',
|
||||
],
|
||||
]);
|
||||
|
||||
// Run the aggregation command
|
||||
$this->artisan('short-url:aggregate-and-prune')
|
||||
->assertExitCode(0);
|
||||
|
||||
// Verify statistics entry in database
|
||||
$this->assertDatabaseHas('short_url_daily_stats', [
|
||||
'short_url_id' => $link->id,
|
||||
'date' => $yesterday,
|
||||
'visits_count' => 3,
|
||||
'unique_visits_count' => 2,
|
||||
'qr_visits_count' => 1,
|
||||
]);
|
||||
|
||||
$stats = ShortUrlDailyStats::where('short_url_id', $link->id)
|
||||
->where('date', $yesterday)
|
||||
->first();
|
||||
|
||||
expect($stats->device_stats)->toBe(['desktop' => 2, 'mobile' => 1])
|
||||
->and($stats->browser_stats)->toBe(['Chrome' => 2, 'Safari' => 1])
|
||||
->and($stats->os_stats)->toBe(['macOS' => 2, 'iOS' => 1])
|
||||
->and($stats->country_stats)->toBe(['Poland' => 3])
|
||||
->and($stats->city_stats)->toBe(['Warsaw (PL)' => 2, 'Krakow (PL)' => 1])
|
||||
->and($stats->language_stats)->toBe(['pl' => 2, 'en' => 1]);
|
||||
});
|
||||
156
tests/Feature/ShortUrlDeepLinkingTest.php
Normal file
156
tests/Feature/ShortUrlDeepLinkingTest.php
Normal file
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\ShortUrlSettingsPage;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlSettingsManager;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
// Reset config overrides
|
||||
config([
|
||||
'filament-short-url.deep_linking.enabled' => false,
|
||||
'filament-short-url.deep_linking.aasa_json' => null,
|
||||
'filament-short-url.deep_linking.assetlinks_json' => null,
|
||||
]);
|
||||
|
||||
Cache::forget('fsu:deep-linking:aasa');
|
||||
Cache::forget('fsu:deep-linking:assetlinks');
|
||||
});
|
||||
|
||||
it('returns 404 for deep linking endpoints when disabled', function () {
|
||||
$this->get('/.well-known/apple-app-site-association')->assertStatus(404);
|
||||
$this->get('/apple-app-site-association')->assertStatus(404);
|
||||
$this->get('/.well-known/assetlinks.json')->assertStatus(404);
|
||||
});
|
||||
|
||||
it('returns 404 for deep linking endpoints when enabled but json is empty', function () {
|
||||
config([
|
||||
'filament-short-url.deep_linking.enabled' => true,
|
||||
'filament-short-url.deep_linking.aasa_json' => '',
|
||||
'filament-short-url.deep_linking.assetlinks_json' => null,
|
||||
]);
|
||||
|
||||
$this->get('/.well-known/apple-app-site-association')->assertStatus(404);
|
||||
$this->get('/.well-known/assetlinks.json')->assertStatus(404);
|
||||
});
|
||||
|
||||
it('serves minified aasa and assetlinks with correct headers and caches them', function () {
|
||||
$aasaInput = '{
|
||||
"applinks": {
|
||||
"apps": [],
|
||||
"details": [
|
||||
{
|
||||
"appID": "9JA89Q824A.com.example.app",
|
||||
"paths": [ "/s/*" ]
|
||||
}
|
||||
]
|
||||
}
|
||||
}';
|
||||
|
||||
$assetlinksInput = '[{
|
||||
"relation": ["delegate_permission/common.handle_all_urls"],
|
||||
"target": {
|
||||
"namespace": "android_app",
|
||||
"package_name": "com.example.app",
|
||||
"sha256_cert_fingerprints": ["14:6D:E9:..."]
|
||||
}
|
||||
}]';
|
||||
|
||||
config([
|
||||
'filament-short-url.deep_linking.enabled' => true,
|
||||
'filament-short-url.deep_linking.aasa_json' => $aasaInput,
|
||||
'filament-short-url.deep_linking.assetlinks_json' => $assetlinksInput,
|
||||
]);
|
||||
|
||||
// Request AASA
|
||||
$responseAasa = $this->get('/.well-known/apple-app-site-association');
|
||||
$responseAasa->assertStatus(200)
|
||||
->assertHeader('Content-Type', 'application/json; charset=utf-8')
|
||||
->assertHeader('Cache-Control', 'max-age=604800, must-revalidate, public')
|
||||
->assertHeader('X-Content-Type-Options', 'nosniff')
|
||||
->assertHeader('Access-Control-Allow-Origin', '*');
|
||||
|
||||
// Verify it is minified (no spaces or newlines)
|
||||
$minifiedAasa = json_encode(json_decode($aasaInput, true), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
expect($responseAasa->getContent())->toBe($minifiedAasa);
|
||||
|
||||
// Verify AASA is cached
|
||||
expect(Cache::get('fsu:deep-linking:aasa'))->toBe($minifiedAasa);
|
||||
|
||||
// Request AssetLinks
|
||||
$responseAssetLinks = $this->get('/.well-known/assetlinks.json');
|
||||
$responseAssetLinks->assertStatus(200)
|
||||
->assertHeader('Content-Type', 'application/json; charset=utf-8')
|
||||
->assertHeader('Cache-Control', 'max-age=604800, must-revalidate, public')
|
||||
->assertHeader('X-Content-Type-Options', 'nosniff')
|
||||
->assertHeader('Access-Control-Allow-Origin', '*');
|
||||
|
||||
$minifiedAssetLinks = json_encode(json_decode($assetlinksInput, true), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
expect($responseAssetLinks->getContent())->toBe($minifiedAssetLinks);
|
||||
|
||||
// Verify AssetLinks is cached
|
||||
expect(Cache::get('fsu:deep-linking:assetlinks'))->toBe($minifiedAssetLinks);
|
||||
});
|
||||
|
||||
it('clears deep linking cache when settings page save is triggered', function () {
|
||||
Cache::put('fsu:deep-linking:aasa', 'cached_aasa', 600);
|
||||
Cache::put('fsu:deep-linking:assetlinks', 'cached_assetlinks', 600);
|
||||
|
||||
expect(Cache::has('fsu:deep-linking:aasa'))->toBeTrue();
|
||||
expect(Cache::has('fsu:deep-linking:assetlinks'))->toBeTrue();
|
||||
|
||||
// Verify that calling page save logic (which invokes cache forgets) successfully clears cache
|
||||
$page = new class extends ShortUrlSettingsPage
|
||||
{
|
||||
public ?array $formData = [];
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
// Simulating save logic
|
||||
app(ShortUrlSettingsManager::class)->set($this->formData);
|
||||
|
||||
cache()->forget('fsu:deep-linking:aasa');
|
||||
cache()->forget('fsu:deep-linking:assetlinks');
|
||||
}
|
||||
};
|
||||
|
||||
$page->formData = [
|
||||
'deep_linking_enabled' => true,
|
||||
'aasa_json' => '{}',
|
||||
'assetlinks_json' => '[]',
|
||||
];
|
||||
|
||||
$page->save();
|
||||
|
||||
expect(Cache::has('fsu:deep-linking:aasa'))->toBeFalse();
|
||||
expect(Cache::has('fsu:deep-linking:assetlinks'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('redirects to the intermediate app-redirect page when auto_open_app_mobile is enabled and visited from mobile', function () {
|
||||
$shortUrl = app(ShortUrlService::class)->create([
|
||||
'destination_url' => 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
'url_key' => 'ytmobile',
|
||||
'auto_open_app_mobile' => true,
|
||||
'track_visits' => false,
|
||||
]);
|
||||
|
||||
// Visited from desktop -> should redirect directly (no intermediate page)
|
||||
$responseDesktop = $this->get('/s/ytmobile', [
|
||||
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0',
|
||||
]);
|
||||
$responseDesktop->assertRedirect('https://www.youtube.com/watch?v=dQw4w9WgXcQ');
|
||||
|
||||
// Visited from mobile (iPhone) -> should render intermediate app-redirect page
|
||||
$responseMobile = $this->get('/s/ytmobile', [
|
||||
'User-Agent' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 Safari/604.1',
|
||||
]);
|
||||
|
||||
$responseMobile->assertStatus(200)
|
||||
->assertSee('Opening in YouTube')
|
||||
->assertSee('youtube://www.youtube.com/watch?v=dQw4w9WgXcQ')
|
||||
->assertSee('Open Native App')
|
||||
->assertSee('Open in Browser');
|
||||
});
|
||||
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);
|
||||
});
|
||||
705
tests/Feature/ShortUrlRedirectTest.php
Normal file
705
tests/Feature/ShortUrlRedirectTest.php
Normal file
@@ -0,0 +1,705 @@
|
||||
<?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('applies rate limiting on password attempts when protected', function () {
|
||||
$shortUrl = createShortUrl([
|
||||
'url_key' => 'password-ratelimit',
|
||||
'password' => 'secret-combination',
|
||||
'track_visits' => false,
|
||||
]);
|
||||
|
||||
// First 5 incorrect attempts should return 200 (renders password prompt again)
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$this->post('/s/password-ratelimit', ['password' => 'wrong-pass'])
|
||||
->assertStatus(200)
|
||||
->assertSee('Incorrect password');
|
||||
}
|
||||
|
||||
// 6th incorrect attempt should be rate limited (429)
|
||||
$this->post('/s/password-ratelimit', ['password' => 'wrong-pass'])
|
||||
->assertStatus(429);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('renders custom branded expired view on deactivated URL', function () {
|
||||
createShortUrl(['url_key' => 'disabled-view', 'is_enabled' => false, 'track_visits' => false]);
|
||||
|
||||
$response = $this->get('/s/disabled-view');
|
||||
$response->assertStatus(410);
|
||||
$response->assertSee('Link Inactive or Expired');
|
||||
$response->assertSee('Go to Homepage');
|
||||
});
|
||||
Reference in New Issue
Block a user