Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15d992c5ad | ||
|
|
719901d4c6 | ||
|
|
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 | ||
|
|
34e4339c7c | ||
|
|
e847328b9b | ||
|
|
911e98a2e3 |
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.
|
||||
|
||||
761
README.md
761
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,25 +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.
|
||||
- 📈 **Real-Time Statistics Dashboard** — Track visits in real-time with cached aggregate metrics (countries, devices, browsers, operating systems, referrers, traffic charts, and maps).
|
||||
- 🎨 **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 (updated in v3.2.0)** — Full programmatical control with secure API Key authentication to create, read, update, list, delete, and inspect analytics for 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -114,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`:**
|
||||
|
||||
@@ -131,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`):
|
||||
@@ -175,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`).
|
||||
@@ -207,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.
|
||||
@@ -272,67 +322,173 @@ This feature is useful for NSFW links, external partner links, or any URL that l
|
||||
|
||||
---
|
||||
|
||||
## Smart Link Targeting (new in v1.2.0)
|
||||
## Security & Anti-Fraud v2.0 (new in v1.6.0)
|
||||
|
||||
Protect your application redirection routes and visitor data from malicious activities and automated scrapers.
|
||||
|
||||
### 1. VPN & Proxy Detection
|
||||
Filter out anonymous proxy, VPN, or Tor connections to ensure clean analytics and prevent abuse.
|
||||
* **Driver Selection**: Choose between the free **IP-API** service (default) or the premium **VPNAPI.io** service (requires setting an API key).
|
||||
* **Configurable Action**:
|
||||
* **Flag Only**: Flags VPN/Proxy visits in database statistics for inspection but allows the redirection to continue.
|
||||
* **Block Traffic**: Actively blocks the request, serving a `403 Forbidden` response to the client.
|
||||
* **Verify Key**: An interactive "Verify connection" action is available in settings to check your API credentials.
|
||||
|
||||
### 2. Google Safe Browsing URL Verification
|
||||
Scan and verify all user-provided target URLs against Google's safe browsing lookup API on creation and edit.
|
||||
* **Protection**: Blocks malware, phishing, and social engineering domains.
|
||||
* **Filament UI**: Displays a clean status badge and blocks form saving if the target URL is flagged as unsafe.
|
||||
* **Verify Key**: Includes a "Test API Connection" action on the settings dashboard to validate your Safe Browsing API credentials.
|
||||
|
||||
---
|
||||
|
||||
## 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 (updated in v3.3.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.
|
||||
|
||||
### Available Strategies
|
||||
You can configure multiple rules evaluated sequentially from top to bottom. Each rule contains:
|
||||
- A **Target URL** (the redirect destination if rule matches).
|
||||
- A **Match Strategy**: `AND` (all filters must match) or `OR` (any filter can match).
|
||||
- A list of **Filters**:
|
||||
- **Device**: Filter by `desktop`, `mobile`, `tablet`.
|
||||
- **Platform**: Filter by `windows`, `mac`, `linux`, `ios`, `android`, `fire_os`.
|
||||
- **Country**: Filter by country codes (e.g. `PL`, `US`, `DE`) with flags display.
|
||||
- **Language**: Filter by preferred browser language codes (e.g. `pl`, `en`, `de`).
|
||||
|
||||
#### 1. Device-Based Redirects
|
||||
Route visitors to different URLs based on their device type (detected from User-Agent):
|
||||
### Multi-Filter JSON Schema (v3.3.0+)
|
||||
|
||||
| Device | Detected by User-Agent containing |
|
||||
|--------|-----------------------------------|
|
||||
| iOS (Mobile) | `iphone`, `ipad`, `ipod` |
|
||||
| Android | `android` |
|
||||
| Desktop | Everything else |
|
||||
|
||||
```php
|
||||
// Programmatic example
|
||||
$shortUrl->update([
|
||||
'targeting_rules' => [
|
||||
'type' => 'device',
|
||||
'device' => [
|
||||
'ios' => 'https://apps.apple.com/your-app',
|
||||
'android' => 'https://play.google.com/your-app',
|
||||
'desktop' => 'https://example.com/download',
|
||||
],
|
||||
],
|
||||
]);
|
||||
```
|
||||
|
||||
#### 2. Country-Based (Geo-IP) Redirects
|
||||
Route visitors to country-specific URLs. Requires Geo-IP to be enabled in settings. Falls back to the default `destination_url` for unlisted countries.
|
||||
For programmatic or REST API updates, pass an array of rules to `targeting_rules`:
|
||||
|
||||
```php
|
||||
$shortUrl->update([
|
||||
'targeting_rules' => [
|
||||
'type' => 'geo',
|
||||
'geo' => [
|
||||
['country_code' => 'PL', 'url' => 'https://pl.example.com'],
|
||||
['country_code' => 'US', 'url' => 'https://us.example.com'],
|
||||
['country_code' => 'DE', 'url' => 'https://de.example.com'],
|
||||
[
|
||||
'match' => 'and',
|
||||
'url' => 'https://ios-pl.example.com',
|
||||
'filters' => [
|
||||
[
|
||||
'type' => 'platform',
|
||||
'data' => ['platforms' => ['ios']]
|
||||
],
|
||||
[
|
||||
'type' => 'language',
|
||||
'data' => ['languages' => ['pl']]
|
||||
]
|
||||
]
|
||||
],
|
||||
],
|
||||
[
|
||||
'match' => 'or',
|
||||
'url' => 'https://fallback-mobile.example.com',
|
||||
'filters' => [
|
||||
[
|
||||
'type' => 'device',
|
||||
'data' => ['devices' => ['mobile', 'tablet']]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]);
|
||||
```
|
||||
|
||||
#### 3. A/B Split Rotation
|
||||
Distribute traffic across multiple URLs using weighted random selection. Weights are proportional — they do not need to sum to 100.
|
||||
### Supported Filter Options & Formats
|
||||
|
||||
```php
|
||||
$shortUrl->update([
|
||||
'targeting_rules' => [
|
||||
'type' => 'rotation',
|
||||
'rotation' => [
|
||||
['url' => 'https://variant-a.example.com', 'weight' => 70],
|
||||
['url' => 'https://variant-b.example.com', 'weight' => 30],
|
||||
],
|
||||
],
|
||||
]);
|
||||
| Filter Type | Data Key | Allowed Values |
|
||||
|-------------|----------|----------------|
|
||||
| `device` | `devices` | `desktop`, `mobile`, `tablet` |
|
||||
| `platform` | `platforms` | `windows`, `mac`, `linux`, `ios`, `android`, `fire_os` |
|
||||
| `country` | `countries` | ISO-3166 2-letter country codes (e.g. `PL`, `US`, `DE`), case-insensitive |
|
||||
| `language` | `languages` | ISO-639-1 language codes (e.g. `pl`, `en`, `de`), case-insensitive |
|
||||
|
||||
### Legacy Strategies (v1.2.0 - v2.x)
|
||||
|
||||
If your database contains legacy single-strategy rules (e.g. `'type' => 'device'` or `'type' => 'geo'`), the plugin handles them automatically:
|
||||
* **Redirection Engine**: The redirect system detects the legacy structure and processes it on-the-fly using the legacy strategy.
|
||||
* **Filament UI**: When loading a link with legacy rules, the Filament Form automatically upgrades and hydrates them to equivalent new multi-filter rules.
|
||||
* **A/B Split Rotation**: The legacy rotation strategy is still supported backward-compatibly in redirect logic, but cannot be newly configured through the Filament v3.3.0 form.
|
||||
|
||||
---
|
||||
|
||||
## 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/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> All targeting strategies fall back gracefully to the link's primary `destination_url` if no rule matches.
|
||||
##### 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:..."
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -356,47 +512,79 @@ 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.
|
||||
|
||||
### Queue Worker vs. Synchronous Execution
|
||||
|
||||
The plugin is designed to be highly reliable and performant regardless of whether your hosting environment runs a background queue worker.
|
||||
|
||||
#### 1. With a Queue Worker (Recommended)
|
||||
If your application runs a queue worker (e.g. via supervisor running `php artisan queue:work`), you should configure the **Queue Connection** in the **Settings** panel (or via `SHORT_URL_QUEUE` env variable) to your primary queue driver (like `redis` or `database`).
|
||||
- **Performance**: High-speed redirects are prioritized. Visitor clicks, Geo-IP lookups, GA4 hits, and Webhook dispatches are immediately deferred to background queue jobs (`TrackShortUrlVisitJob` and `SendWebhookJob`), keeping redirect response times under 20ms.
|
||||
- **Worker Configuration**: Ensure your queue listener is running:
|
||||
```bash
|
||||
php artisan queue:work --queue=default
|
||||
```
|
||||
*(If you customized the queue name in Settings, replace `default` with your custom queue name).*
|
||||
|
||||
#### 2. Without a Queue Worker (Sync Driver Fallback)
|
||||
If you do not run background queue workers, set the **Queue Connection** to `sync`.
|
||||
- **Automatic Fallback**: The plugin dynamically executes all jobs synchronously on the request thread. The visitor is redirected as soon as the synchronous processes (log recording, webhook calls, etc.) complete.
|
||||
- **Fault Tolerance (Safety Nets)**: Webhook failures (timeouts, 500 errors) or database tracking glitches can happen. The plugin wraps all synchronous execution points in strict error boundaries (`try/catch`). **A failed tracking job or a slow/offline webhook will NEVER crash the visitor's redirect or programmatic REST API responses.** They are gracefully logged in the background.
|
||||
|
||||
### Queue-Based Counter Fallback
|
||||
|
||||
When Redis is not available and counter buffering is enabled, the `IncrementVisitJob` is dispatched to the configured queue connection. This guarantees visit counts are not lost during cache evictions or restarts.
|
||||
|
||||
---
|
||||
|
||||
## 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 HTML interstitial page (styled with glassmorphism) for ~250 ms. During that time the browser executes the pixel scripts — capturing cookies and building ad audiences — then the visitor is forwarded to the destination URL.
|
||||
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 (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.
|
||||
|
||||
---
|
||||
@@ -423,10 +611,14 @@ 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
|
||||
|
||||
All endpoints are prefix-grouped under `/api/short-url/` and are protected by the API Key middleware and rate-limited to **60 requests per minute** (`throttle:60,1`).
|
||||
|
||||
#### `GET /api/short-url/links`
|
||||
List all short URLs (paginated, 30 per page).
|
||||
|
||||
@@ -451,10 +643,22 @@ 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,
|
||||
"auto_open_app_mobile": false,
|
||||
"ga_tracking_id": null,
|
||||
"track_visits": true,
|
||||
"track_ip_address": true,
|
||||
"track_browser": true,
|
||||
"track_browser_version": true,
|
||||
"track_operating_system": true,
|
||||
"track_operating_system_version": true,
|
||||
"track_device_type": true,
|
||||
"track_referer_url": true,
|
||||
"track_browser_language": true,
|
||||
"pixels": [],
|
||||
"notes": null,
|
||||
"created_at": "2026-06-01T12:00:00+00:00"
|
||||
}
|
||||
@@ -468,6 +672,51 @@ curl https://yourdomain.com/api/short-url/links \
|
||||
}
|
||||
```
|
||||
|
||||
#### `GET /api/short-url/links/{idOrKey}`
|
||||
Retrieve details for a single short URL using either its database `id` or short `url_key`.
|
||||
|
||||
```bash
|
||||
curl https://yourdomain.com/api/short-url/links/abc123 \
|
||||
-H "X-Api-Key: sh_key_your_key_here"
|
||||
```
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": 1,
|
||||
"destination_url": "https://example.com",
|
||||
"url_key": "abc123",
|
||||
"short_url": "https://yourdomain.com/s/abc123",
|
||||
"is_enabled": true,
|
||||
"redirect_status_code": 302,
|
||||
"total_visits": 47,
|
||||
"unique_visits": 31,
|
||||
"max_visits": null,
|
||||
"activated_at": null,
|
||||
"expires_at": null,
|
||||
"webhook_url": null,
|
||||
"targeting_rules": null,
|
||||
"password": null,
|
||||
"show_warning_page": false,
|
||||
"auto_open_app_mobile": false,
|
||||
"ga_tracking_id": null,
|
||||
"track_visits": true,
|
||||
"track_ip_address": true,
|
||||
"track_browser": true,
|
||||
"track_browser_version": true,
|
||||
"track_operating_system": true,
|
||||
"track_operating_system_version": true,
|
||||
"track_device_type": true,
|
||||
"track_referer_url": true,
|
||||
"track_browser_language": true,
|
||||
"pixels": [],
|
||||
"notes": null,
|
||||
"created_at": "2026-06-01T12:00:00+00:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### `POST /api/short-url/links`
|
||||
Create a new short URL.
|
||||
|
||||
@@ -481,44 +730,176 @@ 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"
|
||||
}'
|
||||
```
|
||||
|
||||
**Accepted fields:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `destination_url` | string (URL) | ✅ | Target URL |
|
||||
| `url_key` | string | ❌ | Custom slug (auto-generated if omitted) |
|
||||
| `notes` | string | ❌ | Internal notes |
|
||||
| `is_enabled` | boolean | ❌ | Active status (default: `true`) |
|
||||
| `redirect_status_code` | integer (301/302) | ❌ | HTTP redirect code |
|
||||
| `single_use` | boolean | ❌ | Expire after first click |
|
||||
| `forward_query_params` | boolean | ❌ | Forward query string to destination |
|
||||
| `max_visits` | integer | ❌ | Click limit before expiry |
|
||||
| `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 |
|
||||
| `webhook_url` | string (URL) | ❌ | Per-link webhook endpoint |
|
||||
|
||||
**Response:** `201 Created` with the created link object.
|
||||
|
||||
#### `DELETE /api/short-url/links/{id}`
|
||||
Permanently delete a short URL by its ID.
|
||||
#### `PUT/PATCH /api/short-url/links/{idOrKey}`
|
||||
Update an existing short URL (resolved dynamically by either database `id` or short `url_key`).
|
||||
|
||||
```bash
|
||||
curl -X DELETE https://yourdomain.com/api/short-url/links/42 \
|
||||
curl -X PATCH https://yourdomain.com/api/short-url/links/promo26 \
|
||||
-H "X-Api-Key: sh_key_your_key_here" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"destination_url": "https://example.com/new-product-page",
|
||||
"notes": "Updated summer campaign description",
|
||||
"is_enabled": true
|
||||
}'
|
||||
```
|
||||
|
||||
**Accepted Fields (for POST & PUT/PATCH requests):**
|
||||
|
||||
| Field | Type | Required (POST) | Description |
|
||||
|---|---|---|---|
|
||||
| `destination_url` | string (URL) | ✅ | Target URL (max 2048 chars) |
|
||||
| `url_key` | string | ❌ | Custom unique slug/key (max 32 chars, alpha-dash; auto-generated if omitted) |
|
||||
| `notes` | string | ❌ | Internal admin notes (max 1000 chars) |
|
||||
| `is_enabled` | boolean | ❌ | Active status (default: `true`) |
|
||||
| `redirect_status_code` | integer (301/302) | ❌ | HTTP redirect status code |
|
||||
| `single_use` | boolean | ❌ | Expire the short link immediately after the first visit |
|
||||
| `forward_query_params` | boolean | ❌ | Forward visitor query parameters to target destination |
|
||||
| `max_visits` | integer | ❌ | Maximum click threshold limit |
|
||||
| `expiration_redirect_url` | string (URL) | ❌ | Fallback URL to redirect to upon link expiration |
|
||||
| `activated_at` | datetime | ❌ | Activation timestamp (must be after or equal to today) |
|
||||
| `expires_at` | datetime | ❌ | Expiration timestamp (must be after or equal to `activated_at`) |
|
||||
| `pixels` | array of integers | ❌ | List of registered retargeting pixel IDs to associate with the link |
|
||||
| `webhook_url` | string (URL) | ❌ | Per-link webhook URL for immediate event notifications |
|
||||
| `targeting_rules` | array | ❌ | Advanced Multi-Filter Targeting Rules JSON schema (see [Smart Link Targeting](#smart-link-targeting-updated-in-v330) for schema details) |
|
||||
| `password` | string | ❌ | Password to protect the short URL |
|
||||
| `show_warning_page` | boolean | ❌ | Toggle redirect warning interstitial page |
|
||||
| `auto_open_app_mobile` | boolean | ❌ | Auto open deep link in native application on mobile devices |
|
||||
| `ga_tracking_id` | string | ❌ | Custom Google Analytics 4 Measurement ID for this link (`G-XXXXXXXXXX`) |
|
||||
| `track_visits` | boolean | ❌ | Toggle tracking/analytics logging for this link (default: `true`) |
|
||||
| `track_ip_address` | boolean | ❌ | Track client IP address (default: `true`) |
|
||||
| `track_browser` | boolean | ❌ | Track client browser name (default: `true`) |
|
||||
| `track_browser_version` | boolean | ❌ | Track client browser version (default: `true`) |
|
||||
| `track_operating_system` | boolean | ❌ | Track client OS (default: `true`) |
|
||||
| `track_operating_system_version` | boolean | ❌ | Track client OS version (default: `true`) |
|
||||
| `track_device_type` | boolean | ❌ | Track client device type (default: `true`) |
|
||||
| `track_referer_url` | boolean | ❌ | Track visitor referrer URL (default: `true`) |
|
||||
| `track_browser_language` | boolean | ❌ | Track visitor preferred browser language (default: `true`) |
|
||||
|
||||
**Response (POST & PUT/PATCH success):** `200 OK` (or `201 Created` for POST) containing:
|
||||
```json
|
||||
{
|
||||
"message": "Short URL updated successfully.",
|
||||
"data": {
|
||||
"id": 2,
|
||||
"destination_url": "https://example.com/new-product-page",
|
||||
"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,
|
||||
"auto_open_app_mobile": false,
|
||||
"ga_tracking_id": null,
|
||||
"track_visits": true,
|
||||
"track_ip_address": true,
|
||||
"track_browser": true,
|
||||
"track_browser_version": true,
|
||||
"track_operating_system": true,
|
||||
"track_operating_system_version": true,
|
||||
"track_device_type": true,
|
||||
"track_referer_url": true,
|
||||
"track_browser_language": true,
|
||||
"pixels": [],
|
||||
"notes": "Updated summer campaign description",
|
||||
"created_at": "2026-06-04T12:00:00+00:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### `GET /api/short-url/links/{idOrKey}/stats`
|
||||
Retrieve visit analytics statistics for a single short URL.
|
||||
|
||||
```bash
|
||||
curl https://yourdomain.com/api/short-url/links/promo26/stats \
|
||||
-H "X-Api-Key: sh_key_your_key_here"
|
||||
```
|
||||
|
||||
**Optional Query Parameters:**
|
||||
* `date_from` (string): Filter stats starting from date (e.g. `YYYY-MM-DD`).
|
||||
* `date_to` (string): Filter stats up to date (e.g. `YYYY-MM-DD`).
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"totalVisits": 156,
|
||||
"uniqueVisits": 98,
|
||||
"visitsToday": 14,
|
||||
"visitsThisWeek": 114,
|
||||
"visitsThisMonth": 156,
|
||||
"visitsByDay": {
|
||||
"2026-06-01": 42,
|
||||
"2026-06-02": 50
|
||||
},
|
||||
"visitsByCountry": {
|
||||
"Poland": 100,
|
||||
"United States": 50
|
||||
},
|
||||
"visitsByCity": {
|
||||
"Warsaw (PL)": 80,
|
||||
"Krakow (PL)": 20
|
||||
},
|
||||
"visitsByDevice": {
|
||||
"desktop": 90,
|
||||
"mobile": 66
|
||||
},
|
||||
"visitsByBrowser": {
|
||||
"Chrome": 110,
|
||||
"Safari": 46
|
||||
},
|
||||
"visitsByOs": {
|
||||
"Windows": 70,
|
||||
"macOS": 60
|
||||
},
|
||||
"visitsByReferer": {
|
||||
"linkedin.com": 80,
|
||||
"twitter.com": 40
|
||||
},
|
||||
"utmSources": {
|
||||
"linkedin": 80,
|
||||
"twitter": 40
|
||||
},
|
||||
"utmMediums": {
|
||||
"social": 120
|
||||
},
|
||||
"utmCampaigns": {
|
||||
"spring_sale": 120
|
||||
},
|
||||
"qrScans": 12,
|
||||
"visitsByLanguage": {
|
||||
"pl": 100,
|
||||
"en": 56
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### `DELETE /api/short-url/links/{idOrKey}`
|
||||
Permanently delete a short URL by its database `id` or short `url_key`.
|
||||
|
||||
```bash
|
||||
curl -X DELETE https://yourdomain.com/api/short-url/links/promo26 \
|
||||
-H "X-Api-Key: sh_key_your_key_here"
|
||||
```
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
{ "message": "Short URL deleted successfully." }
|
||||
{
|
||||
"message": "Short URL deleted successfully."
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
@@ -526,6 +907,7 @@ curl -X DELETE https://yourdomain.com/api/short-url/links/42 \
|
||||
| HTTP Code | Reason |
|
||||
|---|---|
|
||||
| `401 Unauthorized` | Missing or invalid API key |
|
||||
| `404 Not Found` | Short URL not found |
|
||||
| `422 Unprocessable Entity` | Validation error (see `errors` field in response) |
|
||||
| `503 Service Unavailable` | REST API is disabled in Settings |
|
||||
|
||||
@@ -539,9 +921,14 @@ Webhooks allow external systems to receive real-time HTTP POST notifications whe
|
||||
|
||||
Webhooks can be configured at two levels:
|
||||
|
||||
**1. Per-link webhook** — Set a `Dedicated Webhook URL` in the **Marketing & API** tab of a specific short URL. Fires for every click on that link.
|
||||
**1. Per-link Webhook (Dedicated Webhook URL)**:
|
||||
* **Where to configure**: Set the `Dedicated Webhook URL` in the **Marketing & API** tab of a specific short URL (or pass it via the `webhook_url` parameter in the REST API).
|
||||
* **How it works**: When a visitor clicks the short URL, or when the link is programmatically created via the REST API, a webhook notification is sent directly to this URL.
|
||||
* **Bypassing Global Filters**: Dedicated webhooks **always fire** for these events and are completely independent of the global *Monitored Webhook Events* settings. This is useful for third-party landing pages or integrations that need to track a specific link's traffic without routing all package events.
|
||||
|
||||
**2. Global webhook** — Set a **Global Webhook URL** in **Settings → API & Webhooks → Global Webhook Configuration**. Fires for all links that don't have their own webhook URL, for the event types you select.
|
||||
**2. Global Webhook**:
|
||||
* **Where to configure**: Set a **Global Webhook URL** in **Settings → API & Webhooks → Global Webhook Configuration**.
|
||||
* **How it works**: Fires for all links that *do not* have their own Dedicated Webhook URL configured. It will only fire for the specific event types selected in the **Monitored Webhook Events** setting.
|
||||
|
||||
### Monitored Events
|
||||
|
||||
@@ -554,6 +941,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:
|
||||
@@ -612,11 +1006,13 @@ You can also pre-configure all parameters via your `.env` file:
|
||||
| Environment Variable | Config Path | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `SHORT_URL_PREFIX` | `route_prefix` | `'s'` | URL prefix for short URL redirects. |
|
||||
| `SHORT_URL_SITE_NAME` | `site_name` | `null` | Brand/Site name override for warning/interstitial pages. |
|
||||
| `SHORT_URL_GEO_IP` | `geo_ip.enabled` | `true` | Globally enable/disable Geo-IP tracking. |
|
||||
| `SHORT_URL_GEO_IP_DRIVER` | `geo_ip.driver` | `'headers'` | Geo-IP resolver driver (`headers`, `maxmind`, `ip-api`). |
|
||||
| `SHORT_URL_MAXMIND_DB` | `geo_ip.maxmind.database_path` | `storage_path('geoip/GeoLite2-Country.mmdb')` | Path to local MaxMind db. |
|
||||
| `SHORT_URL_STATS_CACHE_TTL` | `geo_ip.stats_cache_ttl` | `300` | Caching TTL in seconds for dashboard charts. |
|
||||
| `SHORT_URL_QUEUE` | `queue_connection` | `'sync'` | Queue connection for recording visits. |
|
||||
| `SHORT_URL_QUEUE_NAME` | `queue_name` | `'default'` | Queue name to which visit tracking jobs are dispatched. |
|
||||
| `SHORT_URL_CACHE_TTL` | `cache_ttl` | `3600` | Redirection model caching TTL (set to `0` to disable). |
|
||||
| `GA4_API_SECRET` | `ga4.api_secret` | `null` | Google Analytics 4 Measurement Protocol API Secret. |
|
||||
| `FIREBASE_APP_ID` | `ga4.firebase_app_id` | `null` | Google Analytics 4 Firebase App ID (or uses Measurement ID). |
|
||||
@@ -627,6 +1023,10 @@ You can also pre-configure all parameters via your `.env` file:
|
||||
| `SHORT_URL_RATE_LIMITING` | `rate_limiting.enabled` | `false` | Enable per-IP redirect rate limiting. |
|
||||
| `SHORT_URL_RATE_LIMIT_MAX` | `rate_limiting.max_attempts` | `60` | Max redirect requests within the decay window. |
|
||||
| `SHORT_URL_RATE_LIMIT_DECAY` | `rate_limiting.decay_seconds` | `60` | Rate limiter rolling window in seconds. |
|
||||
| `SHORT_URL_DEEP_LINKING_ENABLED` | `deep_linking.enabled` | `false` | Enable serving domain association files for deep linking. |
|
||||
|
||||
> [!TIP]
|
||||
> **Database Configuration Preferred**: Avoid configuring large, multi-line JSON blocks (such as `apple-app-site-association` and `assetlinks.json`) via `.env` file environment variables as it is error-prone and can cause parsing issues. The recommended approach is to configure them dynamically via the **Filament Settings Panel**, which stores them securely in the database with real-time JSON validation.
|
||||
|
||||
---
|
||||
|
||||
@@ -644,6 +1044,14 @@ As of version `1.3.0`, **you no longer need to manually copy scheduled commands
|
||||
- **`short-url:aggregate-and-prune`** is scheduled **daily at 02:00** (runs automatically only if **Enable Daily Aggregation** is ON).
|
||||
- **`short-url:sync-counters`** is scheduled **every minute** (runs automatically only if **Buffer Visit Counts in Cache** is ON).
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Cron Setup Required**:
|
||||
> For the scheduler to fire these tasks automatically, your server must run the standard Laravel Scheduler cron job. Add this single cron entry to your server:
|
||||
> ```bash
|
||||
> * * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1
|
||||
> ```
|
||||
> *Note: These commands execute synchronously within the scheduling process. You do **not** need a queue worker (`php artisan queue:work`) to run them.*
|
||||
|
||||
If you prefer to configure the schedule manually, you can still define them in `routes/console.php` (ensure the corresponding toggles are turned OFF in your Settings panel to avoid redundant executions):
|
||||
|
||||
```php
|
||||
@@ -763,6 +1171,57 @@ All migrations are compatible with **SQLite**, **MySQL**, and **PostgreSQL**:
|
||||
|
||||
## Changelog
|
||||
|
||||
### v3.3.0
|
||||
- **Advanced Multi-Filter Targeting Engine** — Replaced the legacy single-strategy selection panel with a highly flexible rule engine supporting custom `AND` / `OR` logic matching.
|
||||
- **Granular Filter Categories** — Added support for filtering by devices (Desktop, Mobile, Tablet), platform operating systems (Windows, macOS, Linux, iOS, Android, Fire OS), countries (multiselect with search), and browser languages (multiselect with search).
|
||||
- **Client-Side Flag Icons** — Integrated high-quality country flag icons dynamically loaded from a trusted CDN (`flagcdn.com`) inside both the Filament form targeting dropdown and the analytics country statistics widget.
|
||||
- **Redirection Performance Boost** — Bypassed full user agent parsing (which involves regular expression scanning for versions) during redirections by introducing specialized fast-path `getDeviceType` and `getOs` helper functions.
|
||||
- **Dynamic Legacy Data Adapter** — Added automatic on-the-fly hydration and upgrade of legacy database records to the new rule format when loaded in the Filament form.
|
||||
|
||||
### v3.2.0
|
||||
- **Expanded Developer REST API** — Added new endpoints to inspect single short URLs (`GET /api/short-url/links/{idOrKey}`), fetch real-time click metrics (`GET /api/short-url/links/{idOrKey}/stats`), and fully update links programmatically (`PUT/PATCH /api/short-url/links/{idOrKey}`). Enabled flexible lookup dynamically by ID or URL key.
|
||||
- **REST API Throttling & Rate Limiting** — Configured built-in route middleware to rate limit the Developer REST API to 60 requests per minute (`throttle:60,1`) to protect from abuse.
|
||||
- **Unified Validation System** — Cleaned up and unified API and admin panel form validation, supporting advanced fields like granular logging parameters (`track_visits`, `track_browser_language`, etc.), custom GA4 tracking IDs (`ga_tracking_id`), and auto-open deep linking options (`auto_open_app_mobile`).
|
||||
- **Media Controller Separation** — Refactored internal logo uploads and serving routes out of the public REST API controller into a dedicated `ShortUrlLogoController` (Single Responsibility compliance).
|
||||
- **Alpine.js Webhook Payload Preview** — Replaced the Filament package CodeEditor component with a custom, high-reliability dark-mode HTML/CSS component featuring live code highlighting and copy-to-clipboard functionality powered by Alpine.js.
|
||||
|
||||
### 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.
|
||||
- **Visitor World Map Widget** — Live interactive SVG world map showing clicks distribution per country, custom intensity highlighting, and hover details.
|
||||
- **Enhanced Caching & Chart Formatting** — Improved analytics caching for high volumes, and clean `d.m` date formatting (removed year) on visitor stats charts.
|
||||
|
||||
### v1.5.1
|
||||
- **REST API On/Off Toggle** — Enable or disable the entire developer REST API from Settings → API & Webhooks without touching code. Returns `503 Service Unavailable` when disabled. Toggle takes effect immediately without route cache clearing.
|
||||
|
||||
@@ -802,4 +1261,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
@@ -14,6 +14,16 @@ return [
|
||||
*/
|
||||
'route_prefix' => env('SHORT_URL_PREFIX', 's'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Site Name Override
|
||||
|--------------------------------------------------------------------------
|
||||
| The brand or site name displayed on the password prompt, redirect warnings,
|
||||
| and pixel loading screens. Falls back to config('app.name') if empty.
|
||||
|
|
||||
*/
|
||||
'site_name' => env('SHORT_URL_SITE_NAME'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Redirect Status Code
|
||||
@@ -68,6 +78,7 @@ return [
|
||||
'operating_system_version' => true,
|
||||
'referer_url' => true,
|
||||
'device_type' => true,
|
||||
'browser_language' => true,
|
||||
],
|
||||
],
|
||||
|
||||
@@ -206,4 +217,15 @@ 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),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?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_url_visits', function (Blueprint $table): void {
|
||||
$table->boolean('is_bot')->default(false)->index();
|
||||
$table->boolean('is_proxy')->default(false)->index();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('short_url_visits', function (Blueprint $table): void {
|
||||
$table->dropColumn(['is_bot', 'is_proxy']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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',
|
||||
@@ -167,8 +206,11 @@ return [
|
||||
'settings_section_ga4' => 'GA4 Measurement Protocol',
|
||||
'settings_ga4_description' => 'Configure server-side Google Analytics 4 event tracking via the Measurement Protocol. Events are sent in the background without blocking the redirect.',
|
||||
|
||||
'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',
|
||||
@@ -184,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',
|
||||
@@ -207,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',
|
||||
@@ -256,6 +302,16 @@ return [
|
||||
'security_section_title' => 'Security Controls',
|
||||
'password' => 'Access Password',
|
||||
'password_helper' => 'Require visitors to enter a password before being redirected.',
|
||||
'confirm_password' => 'Confirm Password',
|
||||
'new_password' => 'New Password',
|
||||
'change_password' => 'Change Password',
|
||||
'remove_password' => 'Remove Password',
|
||||
'password_status_active' => 'Password protection is enabled.',
|
||||
'set_password' => 'Set Password',
|
||||
'cancel' => 'Cancel',
|
||||
'confirm' => 'Confirm',
|
||||
'password_required_error' => 'Password is required.',
|
||||
'password_mismatch_error' => 'Passwords do not match.',
|
||||
'show_warning_page' => 'Show Redirect Warning Page',
|
||||
'show_warning_page_helper' => 'Show an intermediate screen warning visitors about external redirection (NSFW/phishing protection).',
|
||||
'targeting_type' => 'Targeting Strategy',
|
||||
@@ -263,17 +319,39 @@ 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.',
|
||||
|
||||
// New Advanced Targeting Builder
|
||||
'targeting_rules' => 'Targeting Rules',
|
||||
'add_filter' => 'Add a filter',
|
||||
'match' => 'Match',
|
||||
'match_or' => 'Any of the following filters (OR)',
|
||||
'match_and' => 'All the following filters (AND)',
|
||||
'filter_device' => 'Device',
|
||||
'filter_platform' => 'Platform',
|
||||
'filter_country' => 'Country',
|
||||
'filter_language' => 'Browser Language',
|
||||
'direct_to_url' => 'Direct to URL',
|
||||
'select_devices' => 'Select devices',
|
||||
'select_platforms' => 'Select operating systems',
|
||||
'select_countries' => 'Select countries',
|
||||
'select_languages' => 'Select browser languages',
|
||||
'device_desktop_label' => 'Desktop',
|
||||
'device_mobile_label' => 'Smartphone',
|
||||
'device_tablet_label' => 'Tablet',
|
||||
|
||||
// New Settings Page Fields
|
||||
'settings_tab_advanced' => 'Performance & Security',
|
||||
'settings_section_aggregation' => 'High-Traffic Log Management',
|
||||
@@ -298,8 +376,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',
|
||||
@@ -313,6 +392,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',
|
||||
@@ -338,6 +418,8 @@ return [
|
||||
'warning_description' => 'You are leaving this secure portal and being redirected to an external target link. Please ensure you trust the address below:',
|
||||
'warning_btn_continue' => 'Continue to Destination',
|
||||
'warning_btn_back' => 'Go Back',
|
||||
'pixel_loading_title' => 'Connecting...',
|
||||
'pixel_loading_description' => 'Preparing your connection and forwarding you now.',
|
||||
|
||||
// Marketing & API Tab
|
||||
'tab_marketing' => 'Marketing & API',
|
||||
@@ -349,6 +431,8 @@ return [
|
||||
'marketing_webhooks_title' => 'Link Webhook Integration',
|
||||
'marketing_webhooks_desc' => 'Configure a custom destination URL to send a real-time HTTP POST notification on each click.',
|
||||
'webhook_url' => 'Dedicated Webhook URL',
|
||||
'webhook_helper_alert' => 'Once configured, each visit to this short link triggers a real-time HTTP POST request to this URL. The payload contains detailed click metadata in JSON format (including URL key, IP address, country, browser, operating system, referrer, and UTM parameters).',
|
||||
'webhook_show_payload' => 'Show example JSON payload',
|
||||
|
||||
// Settings tab additions
|
||||
'settings_tab_developer' => 'API & Webhooks',
|
||||
@@ -356,6 +440,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',
|
||||
@@ -370,4 +456,90 @@ return [
|
||||
'api_key_name' => 'Key Name (Description)',
|
||||
'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_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.',
|
||||
'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',
|
||||
@@ -168,8 +203,11 @@ return [
|
||||
'settings_section_ga4' => 'GA4 Measurement Protocol',
|
||||
'settings_ga4_description' => 'Skonfiguruj serwerowe śledzenie zdarzeń GA4 przez Measurement Protocol. Zdarzenia są wysyłane w tle bez blokowania przekierowania.',
|
||||
|
||||
'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',
|
||||
@@ -184,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',
|
||||
@@ -208,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',
|
||||
@@ -257,6 +299,16 @@ return [
|
||||
'security_section_title' => 'Kontrola bezpieczeństwa',
|
||||
'password' => 'Hasło dostępu',
|
||||
'password_helper' => 'Wymagaj od odwiedzających wprowadzenia hasła przed przekierowaniem.',
|
||||
'confirm_password' => 'Potwierdź hasło',
|
||||
'new_password' => 'Nowe hasło',
|
||||
'change_password' => 'Zmień hasło',
|
||||
'remove_password' => 'Usuń hasło',
|
||||
'password_status_active' => 'Ochrona hasłem jest włączona.',
|
||||
'set_password' => 'Ustaw hasło',
|
||||
'cancel' => 'Anuluj',
|
||||
'confirm' => 'Potwierdź',
|
||||
'password_required_error' => 'Hasło jest wymagane.',
|
||||
'password_mismatch_error' => 'Hasła nie są identyczne.',
|
||||
'show_warning_page' => 'Pokaż stronę ostrzegającą przed przekierowaniem',
|
||||
'show_warning_page_helper' => 'Pokaż ekran pośredni ostrzegający o przekierowaniu na zewnętrzny adres (ochrona przed phishingiem/bezpieczeństwo NSFW).',
|
||||
'targeting_type' => 'Strategia targetowania',
|
||||
@@ -264,17 +316,39 @@ 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.',
|
||||
|
||||
// New Advanced Targeting Builder
|
||||
'targeting_rules' => 'Reguły targetowania',
|
||||
'add_filter' => 'Dodaj filtr',
|
||||
'match' => 'Warunek dopasowania',
|
||||
'match_or' => 'Dowolny z poniższych filtrów (OR)',
|
||||
'match_and' => 'Wszystkie poniższe filtry (AND)',
|
||||
'filter_device' => 'Urządzenie',
|
||||
'filter_platform' => 'Platforma (System operacyjny)',
|
||||
'filter_country' => 'Kraj',
|
||||
'filter_language' => 'Język przeglądarki',
|
||||
'direct_to_url' => 'Przekieruj na URL',
|
||||
'select_devices' => 'Wybierz urządzenia',
|
||||
'select_platforms' => 'Wybierz systemy operacyjne',
|
||||
'select_countries' => 'Wybierz kraje',
|
||||
'select_languages' => 'Wybierz języki przeglądarki',
|
||||
'device_desktop_label' => 'Komputer stacjonarny',
|
||||
'device_mobile_label' => 'Smartfon',
|
||||
'device_tablet_label' => 'Tablet',
|
||||
|
||||
// New Settings Page Fields
|
||||
'settings_tab_advanced' => 'Wydajność i bezpieczeństwo',
|
||||
'settings_section_aggregation' => 'Zarządzanie logami o dużym natężeniu ruchu',
|
||||
@@ -299,8 +373,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',
|
||||
@@ -314,6 +389,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',
|
||||
@@ -339,6 +415,8 @@ return [
|
||||
'warning_description' => 'Opuszczasz ten bezpieczny portal i zostajesz przekierowany na zewnętrzny link docelowy. Upewnij się, że ufasz poniższemu adresowi:',
|
||||
'warning_btn_continue' => 'Kontynuuj do celu',
|
||||
'warning_btn_back' => 'Wróć',
|
||||
'pixel_loading_title' => 'Łączenie...',
|
||||
'pixel_loading_description' => 'Przygotowujemy połączenie i za chwilę zostaniesz przekierowany.',
|
||||
|
||||
// Marketing & API Tab
|
||||
'tab_marketing' => 'Marketing i API',
|
||||
@@ -350,6 +428,8 @@ return [
|
||||
'marketing_webhooks_title' => 'Integracja Webhook linku',
|
||||
'marketing_webhooks_desc' => 'Skonfiguruj niestandardowy adres URL, pod który wysłane zostanie natychmiastowe powiadomienie HTTP POST po kliknięciu.',
|
||||
'webhook_url' => 'Dedykowany Webhook URL',
|
||||
'webhook_helper_alert' => 'Po skonfigurowaniu adresu URL webhooka, przy każdym kliknięciu w ten skrócony link wyślemy żądanie HTTP POST w tle w czasie rzeczywistym. Przekazuje ono szczegółowe metadane wizyty w formacie JSON (klucz linku, adres IP, kraj, przeglądarka, system operacyjny, referer i parametry UTM).',
|
||||
'webhook_show_payload' => 'Pokaż przykładowy payload JSON',
|
||||
|
||||
// Settings tab additions
|
||||
'settings_tab_developer' => 'API i Webhooki',
|
||||
@@ -357,6 +437,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',
|
||||
@@ -371,4 +453,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>
|
||||
@@ -37,7 +37,7 @@
|
||||
@php
|
||||
$logoPath = function_exists('setting') ? setting('logo_path') : null;
|
||||
$logoUrl = $logoPath ? \Illuminate\Support\Facades\Storage::disk('public')->url($logoPath) : null;
|
||||
$siteName = config('app.name', 'Laravel');
|
||||
$siteName = config('filament-short-url.site_name') ?: config('app.name', 'Laravel');
|
||||
@endphp
|
||||
|
||||
{{-- Main Sign-in Box --}}
|
||||
|
||||
@@ -1,17 +1,48 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full">
|
||||
<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>Connecting...</title>
|
||||
|
||||
<!-- Premium Fonts -->
|
||||
<title>{{ __('filament-short-url::default.pixel_loading_title') ?? 'Connecting...' }}</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=Outfit:wght@300;400;600&display=swap" rel="stylesheet">
|
||||
<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(!empty($pixelMetaId))
|
||||
@if($pixelMetaIds->isNotEmpty())
|
||||
<script>
|
||||
!function(f,b,e,v,n,t,s)
|
||||
{if(f.fbq)return;n=f.fbq=function(){n.callMethod?
|
||||
@@ -21,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=[]}
|
||||
@@ -53,105 +92,87 @@
|
||||
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
|
||||
|
||||
<!-- Premium Styling -->
|
||||
<style>
|
||||
body {
|
||||
background: linear-gradient(135deg, #0f172a 0%, #1e1b4b 100%);
|
||||
color: #f1f5f9;
|
||||
font-family: 'Outfit', -apple-system, sans-serif;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
<!-- 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
|
||||
|
||||
.container {
|
||||
text-align: center;
|
||||
padding: 2.5rem;
|
||||
border-radius: 1.5rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
animation: fadeIn 0.6s ease-out;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
position: relative;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
margin: 0 auto 2rem;
|
||||
}
|
||||
|
||||
.spinner-ring {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 4px solid transparent;
|
||||
border-top-color: #6366f1;
|
||||
border-radius: 50%;
|
||||
animation: spin 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
|
||||
}
|
||||
|
||||
.spinner-ring:nth-child(2) {
|
||||
animation-delay: -0.3s;
|
||||
border-top-color: #a855f7;
|
||||
}
|
||||
|
||||
.spinner-ring:nth-child(3) {
|
||||
animation-delay: -0.6s;
|
||||
border-top-color: #ec4899;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.5rem;
|
||||
background: linear-gradient(to right, #818cf8, #f472b6);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 0.95rem;
|
||||
color: #94a3b8;
|
||||
margin: 0;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
<!-- 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>
|
||||
<div class="container">
|
||||
<div class="spinner">
|
||||
<div class="spinner-ring"></div>
|
||||
<div class="spinner-ring"></div>
|
||||
<div class="spinner-ring"></div>
|
||||
<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
|
||||
$logoPath = function_exists('setting') ? setting('logo_path') : null;
|
||||
$logoUrl = $logoPath ? \Illuminate\Support\Facades\Storage::disk('public')->url($logoPath) : null;
|
||||
$siteName = config('filament-short-url.site_name') ?: config('app.name', 'Laravel');
|
||||
@endphp
|
||||
|
||||
{{-- Main card --}}
|
||||
<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">
|
||||
@if ($logoUrl)
|
||||
<img src="{{ $logoUrl }}" alt="{{ $siteName }}" class="h-[60px] w-auto object-contain mb-4" />
|
||||
@else
|
||||
<span class="text-3xl font-extrabold tracking-tight text-neutral-900 dark:text-white mb-3">{{ $siteName }}</span>
|
||||
@endif
|
||||
|
||||
{{-- Animated spinner --}}
|
||||
<div class="my-5 flex items-center justify-center gap-1.5" aria-hidden="true">
|
||||
<span class="block w-2.5 h-2.5 rounded-full bg-neutral-300 dark:bg-neutral-600 animate-bounce [animation-delay:-0.3s]"></span>
|
||||
<span class="block w-2.5 h-2.5 rounded-full bg-neutral-400 dark:bg-neutral-500 animate-bounce [animation-delay:-0.15s]"></span>
|
||||
<span class="block w-2.5 h-2.5 rounded-full bg-neutral-500 dark:bg-neutral-400 animate-bounce"></span>
|
||||
</div>
|
||||
|
||||
<p class="text-xl font-medium text-neutral-900 dark:text-white mt-2">
|
||||
{{ __('filament-short-url::default.pixel_loading_title') ?? 'Connecting...' }}
|
||||
</p>
|
||||
<p class="text-sm text-neutral-400 dark:text-neutral-500 mt-1">
|
||||
{{ __('filament-short-url::default.pixel_loading_description') ?? 'Preparing your connection and forwarding you now.' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{-- Progress bar --}}
|
||||
<div class="w-full h-1 rounded-full bg-neutral-200 dark:bg-neutral-800 overflow-hidden">
|
||||
<div id="pixel-progress" class="h-full w-0 rounded-full bg-neutral-900 dark:bg-white transition-all ease-linear" style="transition-duration: 220ms;"></div>
|
||||
</div>
|
||||
<h1>Connecting Safely</h1>
|
||||
<p>Securing connection & forwarding you now...</p>
|
||||
</div>
|
||||
|
||||
<!-- Async non-blocking redirect -->
|
||||
{{-- Footer --}}
|
||||
<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>
|
||||
|
||||
{{-- Redirect after pixels fire --}}
|
||||
<script>
|
||||
// Animate the progress bar to full in sync with the redirect delay
|
||||
requestAnimationFrame(function() {
|
||||
document.getElementById('pixel-progress').style.width = '100%';
|
||||
});
|
||||
|
||||
setTimeout(function() {
|
||||
window.location.replace("{!! addslashes($destination) !!}");
|
||||
}, 250);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -37,22 +37,23 @@
|
||||
'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,
|
||||
'dateFrom' => $dateFrom,
|
||||
'dateTo' => $dateTo,
|
||||
], key('stats-world-map-' . $dateFrom . '-' . $dateTo))
|
||||
|
||||
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsBottomBreakdown::class, [
|
||||
'record' => $record,
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
@php
|
||||
$logoPath = function_exists('setting') ? setting('logo_path') : null;
|
||||
$logoUrl = $logoPath ? \Illuminate\Support\Facades\Storage::disk('public')->url($logoPath) : null;
|
||||
$siteName = config('app.name', 'Laravel');
|
||||
$siteName = config('filament-short-url.site_name') ?: config('app.name', 'Laravel');
|
||||
@endphp
|
||||
|
||||
{{-- Main Warning Box --}}
|
||||
|
||||
108
resources/views/webhook-payload-example.blade.php
Normal file
108
resources/views/webhook-payload-example.blade.php
Normal file
@@ -0,0 +1,108 @@
|
||||
@php
|
||||
$rawJson = '{
|
||||
"event": "visited",
|
||||
"timestamp": "2026-06-04T12:00:00+02:00",
|
||||
"short_url": {
|
||||
"id": 12,
|
||||
"destination_url": "https://example.com/some-page",
|
||||
"url_key": "promo26",
|
||||
"short_url": "https://yoursite.com/s/promo26",
|
||||
"total_visits": 150,
|
||||
"unique_visits": 120
|
||||
},
|
||||
"visit": {
|
||||
"id": 345,
|
||||
"visited_at": "2026-06-04T12:00:00+02:00",
|
||||
"device_type": "mobile",
|
||||
"browser": "Chrome",
|
||||
"browser_version": "120.0",
|
||||
"operating_system": "Android",
|
||||
"operating_system_version": "14",
|
||||
"country": "Poland",
|
||||
"country_code": "PL",
|
||||
"city": "Warsaw",
|
||||
"referer_url": "https://t.co/",
|
||||
"referer_host": "t.co",
|
||||
"utm_source": "twitter",
|
||||
"utm_medium": "social",
|
||||
"utm_campaign": "summer_sale",
|
||||
"utm_term": null,
|
||||
"utm_content": "banner_ad",
|
||||
"is_qr_scan": false,
|
||||
"browser_language": "pl"
|
||||
}
|
||||
}';
|
||||
@endphp
|
||||
|
||||
<div class="space-y-2 mt-4">
|
||||
<label class="text-sm font-medium leading-6 text-gray-950 dark:text-white">
|
||||
{{ __('filament-short-url::default.webhook_show_payload') ?? 'Show example JSON payload' }}
|
||||
</label>
|
||||
|
||||
<div
|
||||
x-data="{
|
||||
copied: false,
|
||||
rawJson: @js($rawJson),
|
||||
copy() {
|
||||
navigator.clipboard.writeText(this.rawJson);
|
||||
this.copied = true;
|
||||
setTimeout(() => this.copied = false, 2000);
|
||||
}
|
||||
}"
|
||||
style="position: relative; overflow: hidden; border-radius: 1rem; border: 1px solid rgba(255, 255, 255, 0.1); background-color: #18181b; padding: 1.25rem; box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);"
|
||||
>
|
||||
<!-- Copy Button in Top Right -->
|
||||
<button
|
||||
type="button"
|
||||
x-on:click="copy"
|
||||
x-on:mouseenter="$el.style.backgroundColor='rgba(255, 255, 255, 0.15)'; $el.style.color='#ffffff';"
|
||||
x-on:mouseleave="$el.style.backgroundColor='rgba(255, 255, 255, 0.08)'; $el.style.color='#a1a1aa';"
|
||||
style="position: absolute; top: 1rem; right: 1rem; display: flex; align-items: center; justify-content: center; height: 2rem; width: 2rem; border-radius: 0.5rem; background-color: rgba(255, 255, 255, 0.08); color: #a1a1aa; border: none; cursor: pointer; transition: all 0.2s;"
|
||||
title="Copy payload to clipboard"
|
||||
>
|
||||
<!-- Copy Icon -->
|
||||
<svg x-show="!copied" style="height: 1rem; width: 1rem;" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 7v8a2 2 0 002 2h6M8 7V5a2 2 0 012-2h4.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V15a2 2 0 01-2 2h-2M8 7H6a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2v-2" />
|
||||
</svg>
|
||||
<!-- Check Icon -->
|
||||
<svg x-show="copied" x-cloak style="height: 1rem; width: 1rem; color: #34d399;" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Syntax Highlighted Payload -->
|
||||
<pre style="margin: 0; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; font-size: 13px; line-height: 1.6; color: #d4d4d8; overflow-x: auto; white-space: pre-wrap; word-break: break-all; padding-right: 2.5rem;"><code style="font-family: inherit; font-size: inherit; color: inherit;">{
|
||||
<span style="color: #f43f5e;">"event"</span>: <span style="color: #eab308;">"visited"</span>,
|
||||
<span style="color: #f43f5e;">"timestamp"</span>: <span style="color: #eab308;">"2026-06-04T12:00:00+02:00"</span>,
|
||||
<span style="color: #f43f5e;">"short_url"</span>: {
|
||||
<span style="color: #f43f5e;">"id"</span>: <span style="color: #c084fc;">12</span>,
|
||||
<span style="color: #f43f5e;">"destination_url"</span>: <span style="color: #eab308;">"https://example.com/some-page"</span>,
|
||||
<span style="color: #f43f5e;">"url_key"</span>: <span style="color: #eab308;">"promo26"</span>,
|
||||
<span style="color: #f43f5e;">"short_url"</span>: <span style="color: #eab308;">"https://yoursite.com/s/promo26"</span>,
|
||||
<span style="color: #f43f5e;">"total_visits"</span>: <span style="color: #c084fc;">150</span>,
|
||||
<span style="color: #f43f5e;">"unique_visits"</span>: <span style="color: #c084fc;">120</span>
|
||||
},
|
||||
<span style="color: #f43f5e;">"visit"</span>: {
|
||||
<span style="color: #f43f5e;">"id"</span>: <span style="color: #c084fc;">345</span>,
|
||||
<span style="color: #f43f5e;">"visited_at"</span>: <span style="color: #eab308;">"2026-06-04T12:00:00+02:00"</span>,
|
||||
<span style="color: #f43f5e;">"device_type"</span>: <span style="color: #eab308;">"mobile"</span>,
|
||||
<span style="color: #f43f5e;">"browser"</span>: <span style="color: #eab308;">"Chrome"</span>,
|
||||
<span style="color: #f43f5e;">"browser_version"</span>: <span style="color: #eab308;">"120.0"</span>,
|
||||
<span style="color: #f43f5e;">"operating_system"</span>: <span style="color: #eab308;">"Android"</span>,
|
||||
<span style="color: #f43f5e;">"operating_system_version"</span>: <span style="color: #eab308;">"14"</span>,
|
||||
<span style="color: #f43f5e;">"country"</span>: <span style="color: #eab308;">"Poland"</span>,
|
||||
<span style="color: #f43f5e;">"country_code"</span>: <span style="color: #eab308;">"PL"</span>,
|
||||
<span style="color: #f43f5e;">"city"</span>: <span style="color: #eab308;">"Warsaw"</span>,
|
||||
<span style="color: #f43f5e;">"referer_url"</span>: <span style="color: #eab308;">"https://t.co/"</span>,
|
||||
<span style="color: #f43f5e;">"referer_host"</span>: <span style="color: #eab308;">"t.co"</span>,
|
||||
<span style="color: #f43f5e;">"utm_source"</span>: <span style="color: #eab308;">"twitter"</span>,
|
||||
<span style="color: #f43f5e;">"utm_medium"</span>: <span style="color: #eab308;">"social"</span>,
|
||||
<span style="color: #f43f5e;">"utm_campaign"</span>: <span style="color: #eab308;">"summer_sale"</span>,
|
||||
<span style="color: #f43f5e;">"utm_term"</span>: <span style="color: #60a5fa;">null</span>,
|
||||
<span style="color: #f43f5e;">"utm_content"</span>: <span style="color: #eab308;">"banner_ad"</span>,
|
||||
<span style="color: #f43f5e;">"is_qr_scan"</span>: <span style="color: #60a5fa;">false</span>,
|
||||
<span style="color: #f43f5e;">"browser_language"</span>: <span style="color: #eab308;">"pl"</span>
|
||||
}
|
||||
}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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,19 @@
|
||||
</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);
|
||||
$code = \Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsRightBreakdown::getCountryCode($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="flex items-center gap-2 font-medium text-gray-700 dark:text-gray-300">
|
||||
@if ($code)
|
||||
<img src="https://flagcdn.com/h20/{{ strtolower($code) }}.webp" class="w-5 h-auto rounded-sm inline-block" alt="{{ $translatedCountry }}" />
|
||||
@endif
|
||||
<span>{{ $translatedCountry }}</span>
|
||||
</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 +36,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>
|
||||
|
||||
331
resources/views/widgets/world-map.blade.php
Normal file
331
resources/views/widgets/world-map.blade.php
Normal file
@@ -0,0 +1,331 @@
|
||||
@php
|
||||
// Computes top 10 for the ranked sidebar
|
||||
$topCountries = collect($countryData)->sortDesc()->take(10);
|
||||
|
||||
// Country name lookup (ISO-2 → English name)
|
||||
$countryNames = [
|
||||
'AF'=>'Afghanistan','AL'=>'Albania','DZ'=>'Algeria','AO'=>'Angola','AR'=>'Argentina','AU'=>'Australia',
|
||||
'AT'=>'Austria','AZ'=>'Azerbaijan','BD'=>'Bangladesh','BE'=>'Belgium','BF'=>'Burkina Faso','BY'=>'Belarus',
|
||||
'BJ'=>'Benin','BO'=>'Bolivia','BA'=>'Bosnia & Herz.','BW'=>'Botswana','BR'=>'Brazil','BN'=>'Brunei',
|
||||
'BG'=>'Bulgaria','KH'=>'Cambodia','CM'=>'Cameroon','CA'=>'Canada','CF'=>'C. African Rep.','TD'=>'Chad',
|
||||
'CL'=>'Chile','CN'=>'China','CO'=>'Colombia','CD'=>'DR Congo','CG'=>'Congo','CR'=>'Costa Rica',
|
||||
'HR'=>'Croatia','CU'=>'Cuba','CY'=>'Cyprus','CZ'=>'Czech Rep.','DK'=>'Denmark','DO'=>'Dominican Rep.',
|
||||
'EC'=>'Ecuador','EG'=>'Egypt','SV'=>'El Salvador','GQ'=>'Eq. Guinea','ER'=>'Eritrea','EE'=>'Estonia',
|
||||
'ET'=>'Ethiopia','FI'=>'Finland','FR'=>'France','GA'=>'Gabon','GM'=>'Gambia','GE'=>'Georgia',
|
||||
'DE'=>'Germany','GH'=>'Ghana','GR'=>'Greece','GT'=>'Guatemala','GN'=>'Guinea','GW'=>'Guinea-Bissau',
|
||||
'GY'=>'Guyana','HT'=>'Haiti','HN'=>'Honduras','HU'=>'Hungary','IN'=>'India','ID'=>'Indonesia',
|
||||
'IR'=>'Iran','IQ'=>'Iraq','IE'=>'Ireland','IL'=>'Israel','IT'=>'Italy','CI'=>'Ivory Coast',
|
||||
'JP'=>'Japan','JO'=>'Jordan','KZ'=>'Kazakhstan','KE'=>'Kenya','KP'=>'North Korea','KR'=>'South Korea',
|
||||
'KW'=>'Kuwait','KG'=>'Kyrgyzstan','LA'=>'Laos','LV'=>'Latvia','LB'=>'Lebanon','LS'=>'Lesotho',
|
||||
'LR'=>'Liberia','LY'=>'Libya','LT'=>'Lithuania','MK'=>'N. Macedonia','MG'=>'Madagascar','MW'=>'Malawi',
|
||||
'MY'=>'Malaysia','ML'=>'Mali','MR'=>'Mauritania','MX'=>'Mexico','MD'=>'Moldova','MN'=>'Mongolia',
|
||||
'ME'=>'Montenegro','MA'=>'Morocco','MZ'=>'Mozambique','MM'=>'Myanmar','NA'=>'Namibia','NP'=>'Nepal',
|
||||
'NL'=>'Netherlands','NZ'=>'New Zealand','NI'=>'Nicaragua','NE'=>'Niger','NG'=>'Nigeria','NO'=>'Norway',
|
||||
'OM'=>'Oman','PK'=>'Pakistan','PS'=>'Palestine','PA'=>'Panama','PG'=>'Papua N. Guinea','PY'=>'Paraguay',
|
||||
'PE'=>'Peru','PH'=>'Philippines','PL'=>'Poland','PT'=>'Portugal','RO'=>'Romania','RU'=>'Russia',
|
||||
'RW'=>'Rwanda','SA'=>'Saudi Arabia','SN'=>'Senegal','RS'=>'Serbia','SL'=>'Sierra Leone','SO'=>'Somalia',
|
||||
'ZA'=>'South Africa','SS'=>'South Sudan','ES'=>'Spain','LK'=>'Sri Lanka','SD'=>'Sudan','SR'=>'Suriname',
|
||||
'SZ'=>'Eswatini','SE'=>'Sweden','CH'=>'Switzerland','SY'=>'Syria','TW'=>'Taiwan','TJ'=>'Tajikistan',
|
||||
'TZ'=>'Tanzania','TH'=>'Thailand','TG'=>'Togo','TN'=>'Tunisia','TR'=>'Turkey','TM'=>'Turkmenistan',
|
||||
'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',
|
||||
];
|
||||
@endphp
|
||||
|
||||
<x-filament-widgets::widget>
|
||||
<div class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-800 dark:bg-gray-900/50">
|
||||
|
||||
{{-- Header --}}
|
||||
<div class="mb-5 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<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-globe-alt" class="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">
|
||||
{{ __('filament-short-url::default.world_map_title') }}
|
||||
</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ number_format($totalClicks) }} {{ __('filament-short-url::default.world_map_total_clicks') }}
|
||||
· {{ count($countryData) }} {{ __('filament-short-url::default.world_map_countries') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Legend --}}
|
||||
@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>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if(empty($countryData))
|
||||
{{-- Empty state --}}
|
||||
<div class="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div class="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-gray-100 dark:bg-gray-800">
|
||||
<x-filament::icon icon="heroicon-o-globe-alt" class="h-8 w-8 text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ __('filament-short-url::default.world_map_no_data') }}</p>
|
||||
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.world_map_no_data_sub') }}</p>
|
||||
</div>
|
||||
@else
|
||||
{{-- Map + Sidebar layout --}}
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-4">
|
||||
|
||||
{{-- 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 },
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
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; 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">
|
||||
<span x-text="tooltip.count.toLocaleString()"></span> clicks
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-xl border border-gray-100 dark:border-gray-800/80">
|
||||
{!! $svgContent !!}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Ranked Country Sidebar --}}
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="mb-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ __('filament-short-url::default.world_map_top_countries') }}
|
||||
</p>
|
||||
@forelse($topCountries as $code => $count)
|
||||
@php
|
||||
$pct = $totalClicks > 0 ? round($count / $totalClicks * 100, 1) : 0;
|
||||
$name = $countryNames[$code] ?? $code;
|
||||
$barWidth = $maxCount > 0 ? round($count / $maxCount * 100) : 0;
|
||||
@endphp
|
||||
<div class="group rounded-lg px-2 py-1.5 transition-colors hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-5 text-center text-xs font-bold text-gray-400 dark:text-gray-500">
|
||||
{{ $loop->iteration }}
|
||||
</span>
|
||||
<span class="flex-1 truncate text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ $name }}
|
||||
</span>
|
||||
<span class="shrink-0 font-mono text-xs font-semibold text-gray-900 dark:text-white">
|
||||
{{ number_format($count) }}
|
||||
</span>
|
||||
<span class="w-9 shrink-0 text-right text-xs text-gray-400 dark:text-gray-500">
|
||||
{{ $pct }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1 ml-7 h-1 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
|
||||
<div class="h-full rounded-full bg-indigo-500 transition-all duration-700"
|
||||
style="width: {{ $barWidth }}%">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">
|
||||
{{ __('filament-short-url::default.world_map_no_data') }}
|
||||
</p>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@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 |
@@ -1,8 +1,20 @@
|
||||
<?php
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlApiController;
|
||||
use Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlLogoController;
|
||||
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}',
|
||||
@@ -13,9 +25,28 @@ Route::match(
|
||||
->middleware(config('filament-short-url.middleware', ['web', 'throttle:120,1']));
|
||||
|
||||
Route::prefix('api/short-url')
|
||||
->middleware([\Bjanczak\FilamentShortUrl\Http\Middleware\AuthenticateShortUrlApi::class])
|
||||
->middleware([
|
||||
AuthenticateShortUrlApi::class,
|
||||
'throttle:60,1',
|
||||
])
|
||||
->group(function () {
|
||||
Route::get('links', [\Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlApiController::class, 'index']);
|
||||
Route::post('links', [\Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlApiController::class, 'store']);
|
||||
Route::delete('links/{id}', [\Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlApiController::class, 'destroy']);
|
||||
Route::get('links', [ShortUrlApiController::class, 'index']);
|
||||
Route::post('links', [ShortUrlApiController::class, 'store']);
|
||||
Route::get('links/{idOrKey}', [ShortUrlApiController::class, 'show']);
|
||||
Route::get('links/{idOrKey}/stats', [ShortUrlApiController::class, 'stats']);
|
||||
Route::match(['PUT', 'PATCH'], 'links/{idOrKey}', [ShortUrlApiController::class, 'update']);
|
||||
Route::delete('links/{idOrKey}', [ShortUrlApiController::class, 'destroy']);
|
||||
});
|
||||
|
||||
Route::post('admin/short-url/upload-logo', [ShortUrlLogoController::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}', [ShortUrlLogoController::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,81 +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 = [];
|
||||
// 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::whereBetween('visited_at', [$date.' 00:00:00', $date.' 23:59:59'])
|
||||
->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}.");
|
||||
}
|
||||
@@ -129,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,38 +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),
|
||||
@@ -65,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'),
|
||||
@@ -76,8 +148,23 @@ 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'),
|
||||
// Security v2.0
|
||||
'vpn_detection_enabled' => $mgr->get('vpn_detection_enabled', false),
|
||||
'vpn_detection_driver' => $mgr->get('vpn_detection_driver', 'ip-api'),
|
||||
'vpnapi_key' => $mgr->get('vpnapi_key'),
|
||||
'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'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -96,15 +183,24 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
Section::make(__('filament-short-url::default.settings_section_routing'))
|
||||
->columns(2)
|
||||
->schema([
|
||||
TextInput::make('site_name')
|
||||
->label(__('filament-short-url::default.settings_site_name'))
|
||||
->helperText(__('filament-short-url::default.settings_site_name_helper'))
|
||||
->nullable()
|
||||
->maxLength(100)
|
||||
->columnSpanFull(),
|
||||
|
||||
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'),
|
||||
@@ -114,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')
|
||||
@@ -133,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'))
|
||||
@@ -151,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'))
|
||||
@@ -164,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(),
|
||||
]),
|
||||
]),
|
||||
|
||||
@@ -195,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'))
|
||||
@@ -202,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')),
|
||||
@@ -213,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')),
|
||||
@@ -230,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'),
|
||||
]),
|
||||
]),
|
||||
@@ -414,6 +571,98 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
->required()
|
||||
->visible(fn (Get $get): bool => (bool) $get('rate_limiting_enabled')),
|
||||
]),
|
||||
|
||||
Section::make(__('filament-short-url::default.settings_section_security_v2'))
|
||||
->description(__('filament-short-url::default.settings_section_security_v2_desc'))
|
||||
->columns(2)
|
||||
->schema([
|
||||
Toggle::make('vpn_detection_enabled')
|
||||
->label(__('filament-short-url::default.settings_vpn_detection_enabled'))
|
||||
->helperText(__('filament-short-url::default.settings_vpn_detection_enabled_helper'))
|
||||
->default(false)
|
||||
->inline(false)
|
||||
->live()
|
||||
->columnSpanFull(),
|
||||
|
||||
Select::make('vpn_detection_driver')
|
||||
->label(__('filament-short-url::default.settings_vpn_driver'))
|
||||
->helperText(__('filament-short-url::default.settings_vpn_driver_helper'))
|
||||
->options([
|
||||
'ip-api' => __('filament-short-url::default.settings_vpn_driver_ipapi'),
|
||||
'vpnapi' => __('filament-short-url::default.settings_vpn_driver_vpnapi'),
|
||||
])
|
||||
->default('ip-api')
|
||||
->live()
|
||||
->required()
|
||||
->visible(fn (Get $get): bool => (bool) $get('vpn_detection_enabled')),
|
||||
|
||||
TextInput::make('vpnapi_key')
|
||||
->label(__('filament-short-url::default.settings_vpnapi_key'))
|
||||
->helperText(__('filament-short-url::default.settings_vpnapi_key_helper'))
|
||||
->password()
|
||||
->revealable()
|
||||
->placeholder('••••••••••••••••••••')
|
||||
->visible(fn (Get $get): bool => (bool) $get('vpn_detection_enabled') && $get('vpn_detection_driver') === 'vpnapi'),
|
||||
|
||||
Select::make('vpn_block_action')
|
||||
->label(__('filament-short-url::default.settings_vpn_block_action'))
|
||||
->helperText(__('filament-short-url::default.settings_vpn_block_action_helper'))
|
||||
->options([
|
||||
'flag_only' => __('filament-short-url::default.settings_vpn_block_flag_only'),
|
||||
'block_with_403' => __('filament-short-url::default.settings_vpn_block_block_403'),
|
||||
])
|
||||
->default('flag_only')
|
||||
->required()
|
||||
->visible(fn (Get $get): bool => (bool) $get('vpn_detection_enabled')),
|
||||
|
||||
Toggle::make('safe_browsing_enabled')
|
||||
->label(__('filament-short-url::default.settings_safe_browsing_enabled'))
|
||||
->helperText(__('filament-short-url::default.settings_safe_browsing_enabled_helper'))
|
||||
->default(false)
|
||||
->inline(false)
|
||||
->live()
|
||||
->columnSpanFull(),
|
||||
|
||||
TextInput::make('google_safe_browsing_api_key')
|
||||
->label(__('filament-short-url::default.settings_safe_browsing_api_key'))
|
||||
->helperText(__('filament-short-url::default.settings_safe_browsing_api_key_helper'))
|
||||
->password()
|
||||
->revealable()
|
||||
->placeholder('AIza••••••••••••••••••')
|
||||
->columnSpanFull()
|
||||
->suffixAction(
|
||||
Action::make('testSafeBrowsing')
|
||||
->label(__('filament-short-url::default.settings_safe_browsing_test'))
|
||||
->icon('heroicon-o-signal')
|
||||
->color('gray')
|
||||
->action(function (Get $get): void {
|
||||
$key = trim($get('google_safe_browsing_api_key') ?? '');
|
||||
if (empty($key)) {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_safe_browsing_test_empty'))
|
||||
->warning()->send();
|
||||
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$svc = app(SafeBrowsingService::class);
|
||||
$safe = $svc->isSafeWithKey('https://google.com', $key);
|
||||
Notification::make()
|
||||
->title($safe
|
||||
? __('filament-short-url::default.settings_safe_browsing_test_ok')
|
||||
: __('filament-short-url::default.settings_safe_browsing_test_fail'))
|
||||
->color($safe ? 'success' : 'danger')
|
||||
->send();
|
||||
} catch (\Throwable $e) {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_safe_browsing_test_error'))
|
||||
->body($e->getMessage())
|
||||
->danger()->send();
|
||||
}
|
||||
})
|
||||
)
|
||||
->visible(fn (Get $get): bool => (bool) $get('safe_browsing_enabled')),
|
||||
]),
|
||||
]),
|
||||
|
||||
// ── Tracking Defaults ─────────────────────────────────
|
||||
@@ -454,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)
|
||||
@@ -553,36 +807,15 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
->helperText(__('filament-short-url::default.settings_api_enabled_helper'))
|
||||
->default(false)
|
||||
->inline(false)
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
|
||||
Section::make(__('filament-short-url::default.settings_section_global_webhook'))
|
||||
->schema([
|
||||
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(),
|
||||
|
||||
Select::make('webhook_events')
|
||||
->label(__('filament-short-url::default.settings_webhook_events'))
|
||||
->helperText(__('filament-short-url::default.settings_webhook_events_helper'))
|
||||
->multiple()
|
||||
->options([
|
||||
'visited' => __('filament-short-url::default.webhook_event_visited'),
|
||||
'created' => __('filament-short-url::default.webhook_event_created'),
|
||||
'expired' => __('filament-short-url::default.webhook_event_expired'),
|
||||
'limit_reached' => __('filament-short-url::default.webhook_event_limit_reached'),
|
||||
])
|
||||
->default(['visited'])
|
||||
->columnSpanFull(),
|
||||
->columnSpanFull()
|
||||
->live(),
|
||||
]),
|
||||
|
||||
Section::make(__('filament-short-url::default.settings_section_api_keys'))
|
||||
->description(__('filament-short-url::default.settings_api_keys_description'))
|
||||
->visible(fn (Get $get): bool => (bool) $get('api_enabled'))
|
||||
->schema([
|
||||
\Filament\Forms\Components\Repeater::make('api_keys')
|
||||
Repeater::make('api_keys')
|
||||
->label(__('filament-short-url::default.settings_api_keys'))
|
||||
->schema([
|
||||
TextInput::make('name')
|
||||
@@ -600,6 +833,112 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
->columns(3)
|
||||
->default([]),
|
||||
]),
|
||||
|
||||
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()
|
||||
->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'))
|
||||
->helperText(__('filament-short-url::default.settings_webhook_events_helper'))
|
||||
->multiple()
|
||||
->options([
|
||||
'visited' => __('filament-short-url::default.webhook_event_visited'),
|
||||
'created' => __('filament-short-url::default.webhook_event_created'),
|
||||
'expired' => __('filament-short-url::default.webhook_event_expired'),
|
||||
'limit_reached' => __('filament-short-url::default.webhook_event_limit_reached'),
|
||||
])
|
||||
->default(['visited'])
|
||||
->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'));
|
||||
}
|
||||
};
|
||||
},
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
@@ -612,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
|
||||
@@ -626,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,21 +2,32 @@
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Services\SafeBrowsingService;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Builder;
|
||||
use Filament\Forms\Components\Builder\Block;
|
||||
use Filament\Forms\Components\CheckboxList;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Forms\Components\Hidden;
|
||||
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\Components\ViewField;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Schemas\Components\Actions;
|
||||
use Filament\Schemas\Components\Group;
|
||||
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\Components\Utilities\Set;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Support\HtmlString;
|
||||
|
||||
class ShortUrlForm
|
||||
{
|
||||
@@ -26,6 +37,7 @@ class ShortUrlForm
|
||||
Tabs::make()->tabs([
|
||||
static::linkTab(),
|
||||
static::targetingTab(),
|
||||
static::appLinkingTab(),
|
||||
static::trackingTab(),
|
||||
static::marketingTab(),
|
||||
static::qrDesignTab(),
|
||||
@@ -45,6 +57,14 @@ class ShortUrlForm
|
||||
->required()
|
||||
->url()
|
||||
->maxLength(2048)
|
||||
->rules([
|
||||
fn (): \Closure => function (string $attribute, $value, \Closure $fail) {
|
||||
$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.');
|
||||
}
|
||||
},
|
||||
])
|
||||
->live(onBlur: true)
|
||||
->afterStateHydrated(function (TextInput $component, $state, Set $set) {
|
||||
if (! $state) {
|
||||
@@ -108,7 +128,7 @@ class ShortUrlForm
|
||||
->required(),
|
||||
])->columns(2),
|
||||
|
||||
Section::make(__('filament-short-url::default.form_section_options'))->schema([
|
||||
Section::make(__('filament-short-url::default.form_section_options'))->schema([
|
||||
Toggle::make('is_enabled')
|
||||
->label(__('filament-short-url::default.status'))
|
||||
->default(true)
|
||||
@@ -288,6 +308,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')),
|
||||
@@ -352,18 +378,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')
|
||||
@@ -379,13 +419,153 @@ class ShortUrlForm
|
||||
->schema([
|
||||
Section::make(__('filament-short-url::default.security_section_title'))
|
||||
->schema([
|
||||
TextInput::make('password')
|
||||
->label(__('filament-short-url::default.password'))
|
||||
->helperText(__('filament-short-url::default.password_helper'))
|
||||
->password()
|
||||
->revealable()
|
||||
->nullable()
|
||||
->maxLength(255),
|
||||
Hidden::make('password'),
|
||||
|
||||
Hidden::make('password_active_flag')
|
||||
->dehydrated(false)
|
||||
->afterStateHydrated(function (Hidden $component, $state, ?ShortUrl $record) {
|
||||
$component->state($record && ! empty($record->password));
|
||||
}),
|
||||
|
||||
Hidden::make('is_entering_password')
|
||||
->dehydrated(false)
|
||||
->default(false),
|
||||
|
||||
// State 1: Password is active (password_active_flag is true)
|
||||
Group::make([
|
||||
Placeholder::make('password_status')
|
||||
->label(__('filament-short-url::default.password'))
|
||||
->content(new HtmlString(
|
||||
'<div class="flex items-center gap-2 text-emerald-600 dark:text-emerald-400 font-semibold">'.
|
||||
'<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" /></svg>'.
|
||||
'<span>'.(__('filament-short-url::default.password_status_active') ?? 'Password protection is enabled.').'</span>'.
|
||||
'</div>'
|
||||
))
|
||||
->columnSpan(1),
|
||||
|
||||
Actions::make([
|
||||
Action::make('change_password')
|
||||
->label(__('filament-short-url::default.change_password'))
|
||||
->icon('heroicon-o-pencil')
|
||||
->color('primary')
|
||||
->action(function (Set $set) {
|
||||
$set('password_active_flag', false);
|
||||
$set('is_entering_password', true);
|
||||
$set('new_password_input', null);
|
||||
$set('new_password_confirmation_input', null);
|
||||
}),
|
||||
Action::make('remove_password')
|
||||
->label(__('filament-short-url::default.remove_password'))
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->action(function (Set $set, ?ShortUrl $record) {
|
||||
$set('password', null);
|
||||
$set('new_password_input', null);
|
||||
$set('new_password_confirmation_input', null);
|
||||
$set('password_active_flag', false);
|
||||
$set('is_entering_password', false);
|
||||
if ($record) {
|
||||
$record->password = null;
|
||||
}
|
||||
}),
|
||||
])
|
||||
->columnSpan(1),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull()
|
||||
->visible(fn (Get $get): bool => (bool) $get('password_active_flag')),
|
||||
|
||||
// State 2: No Password, Setup Button Group (password_active_flag is false, is_entering_password is false)
|
||||
Group::make([
|
||||
Actions::make([
|
||||
Action::make('setup_password')
|
||||
->label(__('filament-short-url::default.set_password'))
|
||||
->icon('heroicon-o-key')
|
||||
->color('primary')
|
||||
->action(function (Set $set) {
|
||||
$set('is_entering_password', true);
|
||||
}),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull()
|
||||
->visible(fn (Get $get): bool => ! $get('password_active_flag') && ! $get('is_entering_password')),
|
||||
|
||||
// State 3: Entering Password Group (password_active_flag is false, is_entering_password is true)
|
||||
Group::make([
|
||||
Group::make([
|
||||
TextInput::make('new_password_input')
|
||||
->label(__('filament-short-url::default.new_password'))
|
||||
->password()
|
||||
->revealable()
|
||||
->live()
|
||||
->maxLength(255)
|
||||
->dehydrated(false)
|
||||
->required(fn (Get $get): bool => ! $get('password_active_flag') && $get('is_entering_password'))
|
||||
->columnSpan(1),
|
||||
|
||||
TextInput::make('new_password_confirmation_input')
|
||||
->label(__('filament-short-url::default.confirm_password'))
|
||||
->password()
|
||||
->revealable()
|
||||
->same('new_password_input')
|
||||
->maxLength(255)
|
||||
->dehydrated(false)
|
||||
->required(fn (Get $get): bool => ! empty($get('new_password_input')))
|
||||
->columnSpan(1),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
|
||||
Actions::make([
|
||||
Action::make('confirm_password')
|
||||
->label(__('filament-short-url::default.confirm'))
|
||||
->icon('heroicon-o-check')
|
||||
->color('success')
|
||||
->action(function (Get $get, Set $set) {
|
||||
$password = $get('new_password_input');
|
||||
$confirm = $get('new_password_confirmation_input');
|
||||
if (empty($password)) {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.password_required_error') ?? 'Password is required.')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
if ($password !== $confirm) {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.password_mismatch_error') ?? 'Passwords do not match.')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
$set('password', $password);
|
||||
$set('password_active_flag', true);
|
||||
$set('is_entering_password', false);
|
||||
}),
|
||||
|
||||
Action::make('cancel_password')
|
||||
->label(__('filament-short-url::default.cancel'))
|
||||
->icon('heroicon-o-x-mark')
|
||||
->color('gray')
|
||||
->action(function (Get $get, Set $set, ?ShortUrl $record) {
|
||||
if ($record && ! empty($record->password)) {
|
||||
$set('password_active_flag', true);
|
||||
} else {
|
||||
$set('password_active_flag', false);
|
||||
}
|
||||
$set('is_entering_password', false);
|
||||
$set('new_password_input', null);
|
||||
$set('new_password_confirmation_input', null);
|
||||
}),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull()
|
||||
->visible(fn (Get $get): bool => ! $get('password_active_flag') && $get('is_entering_password')),
|
||||
|
||||
Toggle::make('show_warning_page')
|
||||
->label(__('filament-short-url::default.show_warning_page'))
|
||||
@@ -394,87 +574,265 @@ class ShortUrlForm
|
||||
->inline(false),
|
||||
])->columns(2),
|
||||
|
||||
Section::make(__('filament-short-url::default.targeting_type'))
|
||||
Section::make(__('filament-short-url::default.targeting_rules'))
|
||||
->compact()
|
||||
->schema([
|
||||
Select::make('targeting_rules.type')
|
||||
->label(__('filament-short-url::default.targeting_type'))
|
||||
->options([
|
||||
'none' => __('filament-short-url::default.targeting_type_none'),
|
||||
'device' => __('filament-short-url::default.targeting_type_device'),
|
||||
'geo' => __('filament-short-url::default.targeting_type_country'),
|
||||
'rotation' => __('filament-short-url::default.targeting_type_rotation'),
|
||||
])
|
||||
->default('none')
|
||||
->live()
|
||||
->required(),
|
||||
Repeater::make('targeting_rules')
|
||||
->label(__('filament-short-url::default.targeting_rules'))
|
||||
->hiddenLabel()
|
||||
->defaultItems(0)
|
||||
->reorderable(false)
|
||||
->collapsible()
|
||||
->collapsed()
|
||||
->itemLabel(fn (array $state): ?string => filled($state['url'] ?? null) ? __('filament-short-url::default.direct_to_url').': '.$state['url'] : null)
|
||||
->columns(12)
|
||||
->afterStateHydrated(function (Repeater $component, $state) {
|
||||
if (! is_array($state)) {
|
||||
$component->state([]);
|
||||
|
||||
Section::make(__('filament-short-url::default.device_targeting_rules'))
|
||||
->schema([
|
||||
TextInput::make('targeting_rules.device.mobile')
|
||||
->label(__('filament-short-url::default.device_mobile'))
|
||||
->url()
|
||||
->nullable()
|
||||
->maxLength(2048),
|
||||
TextInput::make('targeting_rules.device.tablet')
|
||||
->label(__('filament-short-url::default.device_tablet'))
|
||||
->url()
|
||||
->nullable()
|
||||
->maxLength(2048),
|
||||
TextInput::make('targeting_rules.device.desktop')
|
||||
->label(__('filament-short-url::default.device_desktop'))
|
||||
->url()
|
||||
->nullable()
|
||||
->maxLength(2048),
|
||||
])
|
||||
->columns(1)
|
||||
->visible(fn (Get $get): bool => $get('targeting_rules.type') === 'device'),
|
||||
return;
|
||||
}
|
||||
|
||||
Repeater::make('targeting_rules.geo')
|
||||
->label(__('filament-short-url::default.country_targeting_rules'))
|
||||
->schema([
|
||||
Select::make('country_code')
|
||||
->label(__('filament-short-url::default.country_code'))
|
||||
->options(function (): array {
|
||||
$countries = __('filament-short-url::countries');
|
||||
if (is_array($countries)) {
|
||||
asort($countries, SORT_LOCALE_STRING);
|
||||
return $countries;
|
||||
// If it is legacy format (has 'type' key)
|
||||
if (isset($state['type'])) {
|
||||
$type = $state['type'];
|
||||
$newRules = [];
|
||||
|
||||
if ($type === 'device') {
|
||||
$devices = $state['device'] ?? [];
|
||||
$mobileUrl = $devices['mobile'] ?? $devices['ios'] ?? null;
|
||||
if ($mobileUrl) {
|
||||
$newRules[] = [
|
||||
'match' => 'or',
|
||||
'url' => $mobileUrl,
|
||||
'filters' => [
|
||||
[
|
||||
'type' => 'device',
|
||||
'data' => ['devices' => ['mobile']],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
return [];
|
||||
})
|
||||
->searchable()
|
||||
->optionsLimit(300)
|
||||
->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') === 'geo'),
|
||||
$tabletUrl = $devices['tablet'] ?? $devices['android'] ?? null;
|
||||
if ($tabletUrl) {
|
||||
$newRules[] = [
|
||||
'match' => 'or',
|
||||
'url' => $tabletUrl,
|
||||
'filters' => [
|
||||
[
|
||||
'type' => 'device',
|
||||
'data' => ['devices' => ['tablet']],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
$desktopUrl = $devices['desktop'] ?? null;
|
||||
if ($desktopUrl) {
|
||||
$newRules[] = [
|
||||
'match' => 'or',
|
||||
'url' => $desktopUrl,
|
||||
'filters' => [
|
||||
[
|
||||
'type' => 'device',
|
||||
'data' => ['devices' => ['desktop']],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
} elseif ($type === 'geo') {
|
||||
foreach ($state['geo'] ?? [] as $geoRule) {
|
||||
if (! empty($geoRule['url']) && ! empty($geoRule['country_code'])) {
|
||||
$newRules[] = [
|
||||
'match' => 'or',
|
||||
'url' => $geoRule['url'],
|
||||
'filters' => [
|
||||
[
|
||||
'type' => 'country',
|
||||
'data' => ['countries' => [strtoupper($geoRule['country_code'])]],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
} elseif ($type === 'language') {
|
||||
foreach ($state['language'] ?? [] as $langRule) {
|
||||
if (! empty($langRule['url']) && ! empty($langRule['language_code'])) {
|
||||
$newRules[] = [
|
||||
'match' => 'or',
|
||||
'url' => $langRule['url'],
|
||||
'filters' => [
|
||||
[
|
||||
'type' => 'language',
|
||||
'data' => ['languages' => [strtolower($langRule['language_code'])]],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Repeater::make('targeting_rules.rotation')
|
||||
->label(__('filament-short-url::default.rotation_targeting_rules'))
|
||||
$component->state($newRules);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! array_is_list($state)) {
|
||||
$component->state([]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$component->state($state);
|
||||
})
|
||||
->schema([
|
||||
Select::make('match')
|
||||
->label(__('filament-short-url::default.match'))
|
||||
->options([
|
||||
'or' => __('filament-short-url::default.match_or'),
|
||||
'and' => __('filament-short-url::default.match_and'),
|
||||
])
|
||||
->default('or')
|
||||
->required()
|
||||
->columnSpan(3),
|
||||
|
||||
TextInput::make('url')
|
||||
->label(__('filament-short-url::default.rotation_url'))
|
||||
->label(__('filament-short-url::default.direct_to_url'))
|
||||
->url()
|
||||
->required()
|
||||
->maxLength(2048),
|
||||
TextInput::make('weight')
|
||||
->label(__('filament-short-url::default.rotation_weight'))
|
||||
->helperText(__('filament-short-url::default.rotation_weight_helper'))
|
||||
->numeric()
|
||||
->integer()
|
||||
->minValue(1)
|
||||
->maxValue(1000)
|
||||
->required()
|
||||
->default(50),
|
||||
->maxLength(2048)
|
||||
->columnSpan(9),
|
||||
|
||||
Builder::make('filters')
|
||||
->label(__('filament-short-url::default.add_filter'))
|
||||
->hiddenLabel()
|
||||
->addActionLabel(__('filament-short-url::default.add_filter'))
|
||||
->reorderable(false)
|
||||
->collapsible()
|
||||
->blockNumbers(false)
|
||||
->addBetweenAction(fn ($action) => $action->hidden())
|
||||
->minItems(1)
|
||||
->blocks([
|
||||
Block::make('device')
|
||||
->label(fn (?array $state) => $state === null
|
||||
? __('filament-short-url::default.filter_device')
|
||||
: new HtmlString(__('filament-short-url::default.select_devices').' <sup class="text-danger-600 dark:text-danger-400 fi-fo-field-label-required-mark" style="color: rgb(220, 38, 38) !important;">*</sup>')
|
||||
)
|
||||
->icon('heroicon-o-device-phone-mobile')
|
||||
->schema([
|
||||
CheckboxList::make('devices')
|
||||
->label(__('filament-short-url::default.select_devices'))
|
||||
->hiddenLabel()
|
||||
->options([
|
||||
'desktop' => __('filament-short-url::default.device_desktop_label'),
|
||||
'mobile' => __('filament-short-url::default.device_mobile_label'),
|
||||
'tablet' => __('filament-short-url::default.device_tablet_label'),
|
||||
])
|
||||
->required()
|
||||
->columns(3)
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->maxItems(1),
|
||||
Block::make('platform')
|
||||
->label(fn (?array $state) => $state === null
|
||||
? __('filament-short-url::default.filter_platform')
|
||||
: new HtmlString(__('filament-short-url::default.select_platforms').' <sup class="text-danger-600 dark:text-danger-400 fi-fo-field-label-required-mark" style="color: rgb(220, 38, 38) !important;">*</sup>')
|
||||
)
|
||||
->icon('heroicon-o-computer-desktop')
|
||||
->schema([
|
||||
CheckboxList::make('platforms')
|
||||
->label(__('filament-short-url::default.select_platforms'))
|
||||
->hiddenLabel()
|
||||
->options([
|
||||
'android' => 'Android',
|
||||
'fire_os' => 'Fire OS',
|
||||
'ios' => 'iOS / iPadOS',
|
||||
'linux' => 'Linux',
|
||||
'mac' => 'macOS',
|
||||
'windows' => 'Windows',
|
||||
])
|
||||
->required()
|
||||
->columns(3)
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->maxItems(1),
|
||||
Block::make('country')
|
||||
->label(fn (?array $state) => $state === null
|
||||
? __('filament-short-url::default.filter_country')
|
||||
: new HtmlString(__('filament-short-url::default.select_countries').' <sup class="text-danger-600 dark:text-danger-400 fi-fo-field-label-required-mark" style="color: rgb(220, 38, 38) !important;">*</sup>')
|
||||
)
|
||||
->icon('heroicon-o-globe-alt')
|
||||
->schema([
|
||||
Select::make('countries')
|
||||
->label(__('filament-short-url::default.select_countries'))
|
||||
->hiddenLabel()
|
||||
->multiple()
|
||||
->searchable()
|
||||
->allowHtml()
|
||||
->options(function (): array {
|
||||
$countries = __('filament-short-url::countries');
|
||||
if (is_array($countries)) {
|
||||
asort($countries, SORT_LOCALE_STRING);
|
||||
|
||||
$htmlOptions = [];
|
||||
foreach ($countries as $code => $name) {
|
||||
$lowerCode = strtolower($code);
|
||||
$htmlOptions[$code] = "<span class=\"flex items-center gap-2\"><img src=\"https://flagcdn.com/h20/{$lowerCode}.webp\" class=\"w-5 h-auto rounded-sm inline-block mr-2\" alt=\"{$name}\" style=\"vertical-align: middle;\" /><span>{$name}</span></span>";
|
||||
}
|
||||
|
||||
return $htmlOptions;
|
||||
}
|
||||
|
||||
return [];
|
||||
})
|
||||
->optionsLimit(300)
|
||||
->required()
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->maxItems(1),
|
||||
Block::make('language')
|
||||
->label(fn (?array $state) => $state === null
|
||||
? __('filament-short-url::default.filter_language')
|
||||
: new HtmlString(__('filament-short-url::default.select_languages').' <sup class="text-danger-600 dark:text-danger-400 fi-fo-field-label-required-mark" style="color: rgb(220, 38, 38) !important;">*</sup>')
|
||||
)
|
||||
->icon('heroicon-o-language')
|
||||
->schema([
|
||||
Select::make('languages')
|
||||
->label(__('filament-short-url::default.select_languages'))
|
||||
->hiddenLabel()
|
||||
->multiple()
|
||||
->searchable()
|
||||
->options(function (): array {
|
||||
$languages = __('filament-short-url::languages');
|
||||
if (is_array($languages)) {
|
||||
asort($languages, SORT_LOCALE_STRING);
|
||||
|
||||
return $languages;
|
||||
}
|
||||
|
||||
return [];
|
||||
})
|
||||
->required()
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->maxItems(1),
|
||||
])
|
||||
->rules([
|
||||
function () {
|
||||
return function (string $attribute, $value, \Closure $fail) {
|
||||
if (! is_array($value)) {
|
||||
return;
|
||||
}
|
||||
$types = collect($value)->pluck('type');
|
||||
if ($types->duplicates()->isNotEmpty()) {
|
||||
$fail('Each filter type (Device, Platform, Country, Language) can only be added once.');
|
||||
}
|
||||
};
|
||||
},
|
||||
])
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columns(2)
|
||||
->visible(fn (Get $get): bool => $get('targeting_rules.type') === 'rotation'),
|
||||
->columnSpanFull()
|
||||
->default([]),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
@@ -487,26 +845,36 @@ 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'))
|
||||
->schema([
|
||||
Placeholder::make('webhook_info')
|
||||
->hiddenLabel()
|
||||
->content(new HtmlString(
|
||||
'<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.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 data-as="p">'.
|
||||
(__('filament-short-url::default.webhook_helper_alert') ?? 'Once configured, each visit to this short link triggers a real-time HTTP POST request to this URL. The payload contains detailed click metadata in JSON format (including URL key, IP address, country, browser, operating system, referrer, and UTM parameters).').
|
||||
'</span>'.
|
||||
'</div>'.
|
||||
'</div>'
|
||||
))
|
||||
->columnSpanFull(),
|
||||
|
||||
TextInput::make('webhook_url')
|
||||
->label(__('filament-short-url::default.webhook_url'))
|
||||
->placeholder('https://api.yourcrm.com/webhooks/clicks')
|
||||
@@ -514,6 +882,34 @@ class ShortUrlForm
|
||||
->maxLength(2048)
|
||||
->nullable()
|
||||
->columnSpanFull(),
|
||||
ViewField::make('webhook_payload_example')
|
||||
->view('filament-short-url::webhook-payload-example')
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
@@ -50,7 +49,18 @@ class ShortUrlVisitsChart extends ChartWidget
|
||||
'fill' => true,
|
||||
],
|
||||
],
|
||||
'labels' => array_keys($visitsByDay),
|
||||
'labels' => array_map(function (string $date) {
|
||||
try {
|
||||
$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;
|
||||
}
|
||||
}, array_keys($visitsByDay)),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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,85 @@ 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;
|
||||
}
|
||||
|
||||
public static function getCountryCode(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);
|
||||
|
||||
return $flipped[$lookupKey] ?? null;
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit;
|
||||
use Filament\Widgets\Widget;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ShortUrlWorldMapWidget extends Widget
|
||||
{
|
||||
public ?ShortUrl $record = null;
|
||||
|
||||
public ?string $dateFrom = null;
|
||||
|
||||
public ?string $dateTo = null;
|
||||
|
||||
protected string $view = 'filament-short-url::widgets.world-map';
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
public function mount(?ShortUrl $record = null): void
|
||||
{
|
||||
$this->record = $record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a country_code → visit count map, merging daily stats and today's raw visits.
|
||||
*
|
||||
* @return array<string, int>
|
||||
*/
|
||||
public function getCountryData(): array
|
||||
{
|
||||
if (! $this->record) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$dateFromClean = $this->dateFrom ? Carbon::parse($this->dateFrom)->toDateString() : null;
|
||||
$dateToClean = $this->dateTo ? Carbon::parse($this->dateTo)->toDateString() : null;
|
||||
$today = Carbon::today()->toDateString();
|
||||
|
||||
$cacheTtl = (int) config('filament-short-url.geo_ip.stats_cache_ttl', 300);
|
||||
$cacheKey = "short_url_world_map_{$this->record->id}_".($dateFromClean ?: 'all').'_'.($dateToClean ?: 'all');
|
||||
|
||||
return cache()->remember($cacheKey, $cacheTtl, function () use ($dateFromClean, $dateToClean) {
|
||||
$counts = [];
|
||||
|
||||
$query = ShortUrlVisit::query()
|
||||
->select('country_code', DB::raw('COUNT(*) as cnt'))
|
||||
->where('short_url_id', $this->record->id)
|
||||
->whereNotNull('country_code')
|
||||
->where('country_code', '!=', '')
|
||||
->where('is_bot', false)
|
||||
->where('is_proxy', false);
|
||||
|
||||
if ($dateFromClean) {
|
||||
$query->whereDate('visited_at', '>=', $dateFromClean);
|
||||
}
|
||||
if ($dateToClean) {
|
||||
$query->whereDate('visited_at', '<=', $dateToClean);
|
||||
}
|
||||
|
||||
foreach ($query->groupBy('country_code')->get() as $row) {
|
||||
$code = strtoupper(trim($row->country_code));
|
||||
if ($code) {
|
||||
$counts[$code] = (int) $row->cnt;
|
||||
}
|
||||
}
|
||||
|
||||
arsort($counts);
|
||||
|
||||
return $counts;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function getViewData(): array
|
||||
{
|
||||
$countryData = $this->getCountryData();
|
||||
$max = max(1, ...array_values($countryData) ?: [1]);
|
||||
$total = array_sum($countryData);
|
||||
|
||||
// Normalise to 0–100 for CSS opacity/intensity
|
||||
$normalized = [];
|
||||
foreach ($countryData as $code => $count) {
|
||||
$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;
|
||||
@@ -33,6 +41,13 @@ class FilamentShortUrlServiceProvider extends PackageServiceProvider
|
||||
'2026_06_01_000005_create_short_url_daily_stats_table',
|
||||
'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,
|
||||
@@ -51,6 +66,8 @@ class FilamentShortUrlServiceProvider extends PackageServiceProvider
|
||||
$this->app->singleton(GeoIpService::class);
|
||||
$this->app->singleton(ShortUrlService::class);
|
||||
$this->app->singleton(ShortUrlTracker::class);
|
||||
$this->app->singleton(ProxyDetectionService::class);
|
||||
$this->app->singleton(SafeBrowsingService::class);
|
||||
}
|
||||
|
||||
public function packageBooted(): void
|
||||
@@ -63,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();
|
||||
|
||||
@@ -8,6 +8,7 @@ use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ShortUrlApiController extends Controller
|
||||
{
|
||||
@@ -40,26 +41,19 @@ class ShortUrlApiController extends Controller
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'destination_url' => 'required|url|max:2048',
|
||||
'url_key' => 'nullable|string|alpha_dash|max:50|unique:short_urls,url_key',
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
'is_enabled' => 'nullable|boolean',
|
||||
'redirect_status_code' => 'nullable|integer|in:301,302',
|
||||
'single_use' => 'nullable|boolean',
|
||||
'forward_query_params' => 'nullable|boolean',
|
||||
'max_visits' => 'nullable|integer|min:1',
|
||||
'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',
|
||||
]);
|
||||
$validated = $request->validate($this->getValidationRules($request));
|
||||
|
||||
$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);
|
||||
|
||||
@@ -69,12 +63,41 @@ class ShortUrlApiController extends Controller
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified short URL.
|
||||
*/
|
||||
public function show(string|int $idOrKey): JsonResponse
|
||||
{
|
||||
$shortUrl = $this->findLink($idOrKey);
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->transformLink($shortUrl),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display statistics/analytics for the specified short URL.
|
||||
*/
|
||||
public function stats(Request $request, string|int $idOrKey): JsonResponse
|
||||
{
|
||||
$shortUrl = $this->findLink($idOrKey);
|
||||
|
||||
$stats = $shortUrl->getCachedStats(
|
||||
dateFrom: $request->query('date_from'),
|
||||
dateTo: $request->query('date_to')
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'data' => $stats,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified short URL.
|
||||
*/
|
||||
public function destroy(int $id): JsonResponse
|
||||
public function destroy(string|int $idOrKey): JsonResponse
|
||||
{
|
||||
$shortUrl = ShortUrl::findOrFail($id);
|
||||
$shortUrl = $this->findLink($idOrKey);
|
||||
$shortUrl->delete();
|
||||
|
||||
return response()->json([
|
||||
@@ -82,11 +105,260 @@ class ShortUrlApiController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified short URL.
|
||||
*/
|
||||
public function update(Request $request, string|int $idOrKey): JsonResponse
|
||||
{
|
||||
$shortUrl = $this->findLink($idOrKey);
|
||||
|
||||
// Merge existing dates for validation if only one of them is sent
|
||||
if ($request->has('expires_at') && ! $request->has('activated_at')) {
|
||||
$request->merge(['activated_at' => $shortUrl->activated_at?->toIso8601String()]);
|
||||
}
|
||||
if ($request->has('activated_at') && ! $request->has('expires_at')) {
|
||||
$request->merge(['expires_at' => $shortUrl->expires_at?->toIso8601String()]);
|
||||
}
|
||||
|
||||
$validated = $request->validate($this->getValidationRules($request, $shortUrl));
|
||||
|
||||
$pixelIds = $validated['pixels'] ?? null;
|
||||
unset($validated['pixels']);
|
||||
|
||||
$shortUrl->update($validated);
|
||||
|
||||
if ($pixelIds !== null) {
|
||||
$shortUrl->pixels()->sync($pixelIds);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Short URL updated successfully.',
|
||||
'data' => $this->transformLink($shortUrl->fresh()),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a ShortUrl by database ID or URL key.
|
||||
*/
|
||||
private function findLink(string|int $idOrKey): ShortUrl
|
||||
{
|
||||
if (is_numeric($idOrKey)) {
|
||||
return ShortUrl::findOrFail((int) $idOrKey);
|
||||
}
|
||||
|
||||
$link = ShortUrl::where('url_key', $idOrKey)->first();
|
||||
|
||||
if (! $link) {
|
||||
abort(404, 'Short URL not found.');
|
||||
}
|
||||
|
||||
return $link;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules for creating or updating a short URL.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function getValidationRules(Request $request, ?ShortUrl $model = null): array
|
||||
{
|
||||
$countries = __('filament-short-url::countries');
|
||||
$countryRule = is_array($countries) && ! empty($countries)
|
||||
? 'in:'.implode(',', array_merge(array_keys($countries), array_map('strtolower', array_keys($countries))))
|
||||
: 'string|max:10';
|
||||
|
||||
$languages = __('filament-short-url::languages');
|
||||
$languageRule = is_array($languages) && ! empty($languages)
|
||||
? 'in:'.implode(',', array_merge(array_keys($languages), array_map('strtoupper', array_keys($languages))))
|
||||
: 'string|max:10';
|
||||
|
||||
$isUpdate = $model !== null;
|
||||
|
||||
// Apply after_or_equal:today only if the activated_at date is actually being changed
|
||||
$activatedAtRule = 'nullable|date';
|
||||
if ($isUpdate) {
|
||||
if ($request->has('activated_at') && $request->input('activated_at') !== $model->activated_at?->toIso8601String() && $request->input('activated_at') !== $model->activated_at?->toDateTimeString()) {
|
||||
$activatedAtRule .= '|after_or_equal:today';
|
||||
}
|
||||
} else {
|
||||
$activatedAtRule .= '|after_or_equal:today';
|
||||
}
|
||||
|
||||
$uniqueKeyRule = 'unique:short_urls,url_key';
|
||||
if ($isUpdate) {
|
||||
$uniqueKeyRule .= ','.$model->id;
|
||||
}
|
||||
|
||||
$isLegacyRules = is_array($request->input('targeting_rules')) && isset($request->input('targeting_rules')['type']);
|
||||
|
||||
$targetingRules = [];
|
||||
if ($isLegacyRules) {
|
||||
$targetingRules = [
|
||||
'targeting_rules' => 'nullable|array',
|
||||
'targeting_rules.type' => 'required_with:targeting_rules|string|in:none,device,geo,language,rotation',
|
||||
'targeting_rules.device' => 'nullable|array',
|
||||
'targeting_rules.device.mobile' => 'nullable|url|max:2048',
|
||||
'targeting_rules.device.tablet' => 'nullable|url|max:2048',
|
||||
'targeting_rules.device.desktop' => 'nullable|url|max:2048',
|
||||
'targeting_rules.device.ios' => 'nullable|url|max:2048',
|
||||
'targeting_rules.device.android' => 'nullable|url|max:2048',
|
||||
'targeting_rules.geo' => 'nullable|array',
|
||||
'targeting_rules.geo.*.country_code' => 'required_with:targeting_rules.geo|distinct:ignore_case|'.$countryRule,
|
||||
'targeting_rules.geo.*.url' => 'required_with:targeting_rules.geo|url|max:2048',
|
||||
'targeting_rules.language' => 'nullable|array',
|
||||
'targeting_rules.language.*.language_code' => 'required_with:targeting_rules.language|distinct:ignore_case|'.$languageRule,
|
||||
'targeting_rules.language.*.url' => 'required_with:targeting_rules.language|url|max:2048',
|
||||
'targeting_rules.rotation' => 'nullable|array',
|
||||
'targeting_rules.rotation.*.url' => 'required_with:targeting_rules.rotation|url|max:2048',
|
||||
'targeting_rules.rotation.*.weight' => 'required_with:targeting_rules.rotation|integer|min:1|max:1000',
|
||||
];
|
||||
} else {
|
||||
$targetingRules = [
|
||||
'targeting_rules' => [
|
||||
'nullable',
|
||||
'array',
|
||||
function (string $attribute, $value, \Closure $fail) {
|
||||
if (! is_array($value)) {
|
||||
return;
|
||||
}
|
||||
foreach ($value as $index => $rule) {
|
||||
if (! is_array($rule)) {
|
||||
$fail("Targeting rule at index {$index} must be an array.");
|
||||
|
||||
continue;
|
||||
}
|
||||
$allowedKeys = ['match', 'url', 'filters'];
|
||||
$invalidKeys = array_diff(array_keys($rule), $allowedKeys);
|
||||
if (! empty($invalidKeys)) {
|
||||
$fail("Invalid keys in targeting rule at index {$index}: ".implode(', ', $invalidKeys));
|
||||
}
|
||||
}
|
||||
},
|
||||
],
|
||||
'targeting_rules.*.match' => 'required_with:targeting_rules|string|in:or,and',
|
||||
'targeting_rules.*.url' => 'required_with:targeting_rules|url|max:2048',
|
||||
'targeting_rules.*.filters' => [
|
||||
'required_with:targeting_rules',
|
||||
'array',
|
||||
'min:1',
|
||||
function (string $attribute, $value, \Closure $fail) {
|
||||
if (! is_array($value)) {
|
||||
return;
|
||||
}
|
||||
$types = collect($value)->pluck('type');
|
||||
if ($types->duplicates()->isNotEmpty()) {
|
||||
$fail('Each filter type (device, platform, country, language) can only be added once.');
|
||||
}
|
||||
|
||||
foreach ($value as $index => $filter) {
|
||||
if (! is_array($filter)) {
|
||||
$fail("Filter at index {$index} must be an array.");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$allowedFilterKeys = ['type', 'data'];
|
||||
$invalidFilterKeys = array_diff(array_keys($filter), $allowedFilterKeys);
|
||||
if (! empty($invalidFilterKeys)) {
|
||||
$fail("Invalid keys in filter at index {$index}: ".implode(', ', $invalidFilterKeys));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$type = $filter['type'] ?? null;
|
||||
$data = $filter['data'] ?? null;
|
||||
|
||||
if (! in_array($type, ['device', 'platform', 'country', 'language'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! is_array($data)) {
|
||||
$fail("Filter data for type '{$type}' must be an array.");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$allowedKeys = match ($type) {
|
||||
'device' => ['devices'],
|
||||
'platform' => ['platforms'],
|
||||
'country' => ['countries'],
|
||||
'language' => ['languages'],
|
||||
};
|
||||
|
||||
$invalidKeys = array_diff(array_keys($data), $allowedKeys);
|
||||
if (! empty($invalidKeys)) {
|
||||
$fail("Invalid keys in data for filter '{$type}': ".implode(', ', $invalidKeys));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$mainKey = $allowedKeys[0];
|
||||
if (! isset($data[$mainKey]) || ! is_array($data[$mainKey]) || empty($data[$mainKey])) {
|
||||
$fail("Filter '{$type}' requires a non-empty array named '{$mainKey}'.");
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
},
|
||||
],
|
||||
'targeting_rules.*.filters.*.type' => 'required_with:targeting_rules|string|in:device,platform,country,language',
|
||||
'targeting_rules.*.filters.*.data' => 'required_with:targeting_rules|array',
|
||||
'targeting_rules.*.filters.*.data.devices' => 'nullable|array',
|
||||
'targeting_rules.*.filters.*.data.devices.*' => 'string|in:desktop,mobile,tablet',
|
||||
'targeting_rules.*.filters.*.data.platforms' => 'nullable|array',
|
||||
'targeting_rules.*.filters.*.data.platforms.*' => 'string|in:android,fire_os,ios,linux,mac,windows',
|
||||
'targeting_rules.*.filters.*.data.countries' => 'nullable|array',
|
||||
'targeting_rules.*.filters.*.data.countries.*' => 'string|'.$countryRule,
|
||||
'targeting_rules.*.filters.*.data.languages' => 'nullable|array',
|
||||
'targeting_rules.*.filters.*.data.languages.*' => 'string|'.$languageRule,
|
||||
];
|
||||
}
|
||||
|
||||
$rules = [
|
||||
'destination_url' => ($isUpdate ? 'sometimes|' : '').'required|url|max:2048',
|
||||
'url_key' => ($isUpdate ? 'sometimes|' : '').'nullable|string|alpha_dash|max:32|'.$uniqueKeyRule,
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
'is_enabled' => 'nullable|boolean',
|
||||
'redirect_status_code' => 'nullable|integer|in:301,302',
|
||||
'single_use' => 'nullable|boolean',
|
||||
'forward_query_params' => 'nullable|boolean',
|
||||
'max_visits' => 'nullable|integer|min:1',
|
||||
'expiration_redirect_url' => 'nullable|url|max:2048',
|
||||
'activated_at' => $activatedAtRule,
|
||||
'expires_at' => 'nullable|date|after_or_equal:activated_at',
|
||||
'webhook_url' => 'nullable|url|max:2048',
|
||||
];
|
||||
|
||||
$rules = array_merge($rules, $targetingRules);
|
||||
|
||||
$rules = array_merge($rules, [
|
||||
'password' => 'nullable|string|max:255',
|
||||
'show_warning_page' => 'nullable|boolean',
|
||||
'auto_open_app_mobile' => 'nullable|boolean',
|
||||
'ga_tracking_id' => 'nullable|string|regex:/^G-[A-Z0-9]+$/',
|
||||
'track_visits' => 'nullable|boolean',
|
||||
'track_ip_address' => 'nullable|boolean',
|
||||
'track_browser' => 'nullable|boolean',
|
||||
'track_browser_version' => 'nullable|boolean',
|
||||
'track_operating_system' => 'nullable|boolean',
|
||||
'track_operating_system_version' => 'nullable|boolean',
|
||||
'track_device_type' => 'nullable|boolean',
|
||||
'track_referer_url' => 'nullable|boolean',
|
||||
'track_browser_language' => 'nullable|boolean',
|
||||
'pixels' => 'nullable|array',
|
||||
'pixels.*' => 'integer|exists:short_url_pixels,id',
|
||||
]);
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a ShortUrl model to API response array.
|
||||
*/
|
||||
private function transformLink(ShortUrl $link): array
|
||||
{
|
||||
$pixels = $link->relationLoaded('pixels') ? $link->pixels : $link->pixels()->get();
|
||||
|
||||
return [
|
||||
'id' => $link->id,
|
||||
'destination_url' => $link->destination_url,
|
||||
@@ -99,10 +371,28 @@ 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,
|
||||
'auto_open_app_mobile' => (bool) $link->auto_open_app_mobile,
|
||||
'ga_tracking_id' => $link->ga_tracking_id,
|
||||
'track_visits' => (bool) $link->track_visits,
|
||||
'track_ip_address' => (bool) $link->track_ip_address,
|
||||
'track_browser' => (bool) $link->track_browser,
|
||||
'track_browser_version' => (bool) $link->track_browser_version,
|
||||
'track_operating_system' => (bool) $link->track_operating_system,
|
||||
'track_operating_system_version' => (bool) $link->track_operating_system_version,
|
||||
'track_device_type' => (bool) $link->track_device_type,
|
||||
'track_referer_url' => (bool) $link->track_referer_url,
|
||||
'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(),
|
||||
];
|
||||
@@ -117,21 +407,28 @@ class ShortUrlApiController extends Controller
|
||||
$globalUrl = config('filament-short-url.global_webhook_url');
|
||||
$events = config('filament-short-url.webhook_events', []);
|
||||
|
||||
if (empty($targetUrl) && !empty($globalUrl) && in_array('created', $events)) {
|
||||
if (empty($targetUrl) && ! empty($globalUrl) && in_array('created', $events)) {
|
||||
$targetUrl = $globalUrl;
|
||||
}
|
||||
|
||||
if (!empty($targetUrl)) {
|
||||
$connection = config('filament-short-url.queue_connection', 'sync');
|
||||
dispatch(new SendWebhookJob(
|
||||
url: $targetUrl,
|
||||
event: 'created',
|
||||
payload: [
|
||||
'event' => 'created',
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
'short_url' => $this->transformLink($shortUrl),
|
||||
]
|
||||
)->onConnection($connection ?: 'sync'));
|
||||
if (! empty($targetUrl)) {
|
||||
try {
|
||||
$connection = config('filament-short-url.queue_connection', 'sync');
|
||||
dispatch(new SendWebhookJob(
|
||||
url: $targetUrl,
|
||||
event: 'created',
|
||||
payload: [
|
||||
'event' => 'created',
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
'short_url' => $this->transformLink($shortUrl),
|
||||
]
|
||||
)->onConnection($connection ?: 'sync'));
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('[FilamentShortUrl] Created webhook dispatch failed', [
|
||||
'url_key' => $shortUrl->url_key,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
240
src/Http/Controllers/ShortUrlLogoController.php
Normal file
240
src/Http/Controllers/ShortUrlLogoController.php
Normal file
@@ -0,0 +1,240 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Http\Controllers;
|
||||
|
||||
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 ShortUrlLogoController extends Controller
|
||||
{
|
||||
/**
|
||||
* Upload QR logo file from admin panel.
|
||||
*/
|
||||
public function uploadLogo(Request $request): JsonResponse
|
||||
{
|
||||
Log::info('ShortUrlLogoController::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,10 +4,14 @@ 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;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\MessageBag;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -33,10 +37,22 @@ 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. Rate Limiting Check
|
||||
// 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(ProxyDetectionService::class);
|
||||
$detection = $proxyDetector->detect($ipAddress);
|
||||
if ($detection['is_proxy'] || $detection['is_bot']) {
|
||||
abort(403, 'Access denied. VPN, Proxy, or automated scraping connection detected.');
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Rate Limiting Check
|
||||
if (config('filament-short-url.rate_limiting.enabled', false)) {
|
||||
$maxAttempts = (int) config('filament-short-url.rate_limiting.max_attempts', 60);
|
||||
$decaySeconds = (int) config('filament-short-url.rate_limiting.decay_seconds', 60);
|
||||
@@ -52,18 +68,32 @@ class ShortUrlRedirectController extends Controller
|
||||
RateLimiter::hit($limiterKey, $decaySeconds);
|
||||
}
|
||||
|
||||
// 2. Password Protection Check
|
||||
// 3. Password Protection Check
|
||||
if (! empty($shortUrl->password)) {
|
||||
$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.',
|
||||
]);
|
||||
@@ -77,52 +107,82 @@ class ShortUrlRedirectController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 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);
|
||||
$deviceType = $uaParser->getDeviceType($request->userAgent() ?? '');
|
||||
|
||||
if (! empty($queryParams)) {
|
||||
$separator = str_contains($destination, '?') ? '&' : '?';
|
||||
$destination .= $separator.http_build_query($queryParams);
|
||||
if ($deviceType === 'mobile' || $deviceType === '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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Warning / Intermediate Page Check
|
||||
// 5. Warning / Intermediate Page Check
|
||||
if ($shortUrl->show_warning_page && ! $request->has('confirmed')) {
|
||||
return response(view('filament-short-url::warning', ['destinationUrl' => $destination]))
|
||||
->header('Content-Type', 'text/html');
|
||||
}
|
||||
|
||||
// 5. Track Visit
|
||||
// 6. Track Visit
|
||||
if ($shortUrl->track_visits) {
|
||||
$connection = config('filament-short-url.queue_connection', 'sync');
|
||||
$ipAddress = ClientIpExtractor::getIp($request);
|
||||
$countryCode = ClientIpExtractor::getCountryCode($request);
|
||||
$city = ClientIpExtractor::getCity($request);
|
||||
try {
|
||||
$connection = config('filament-short-url.queue_connection', 'sync');
|
||||
$ipAddress = ClientIpExtractor::getIp($request);
|
||||
$countryCode = ClientIpExtractor::getCountryCode($request);
|
||||
$city = ClientIpExtractor::getCity($request);
|
||||
|
||||
$job = new TrackShortUrlVisitJob(
|
||||
shortUrl: $shortUrl,
|
||||
ipAddress: $ipAddress,
|
||||
userAgent: $request->userAgent() ?? '',
|
||||
refererUrl: $request->header('Referer'),
|
||||
countryCode: $countryCode,
|
||||
city: $city,
|
||||
utmSource: $request->query('utm_source'),
|
||||
utmMedium: $request->query('utm_medium'),
|
||||
utmCampaign: $request->query('utm_campaign'),
|
||||
utmTerm: $request->query('utm_term'),
|
||||
utmContent: $request->query('utm_content'),
|
||||
);
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
||||
if ($connection) {
|
||||
dispatch($job->onConnection($connection));
|
||||
} else {
|
||||
dispatch($job->onConnection('sync'));
|
||||
$job = new TrackShortUrlVisitJob(
|
||||
shortUrl: $shortUrl,
|
||||
ipAddress: $ipAddress,
|
||||
userAgent: $request->userAgent() ?? '',
|
||||
refererUrl: $request->header('Referer'),
|
||||
countryCode: $countryCode,
|
||||
city: $city,
|
||||
utmSource: $request->query('utm_source'),
|
||||
utmMedium: $request->query('utm_medium'),
|
||||
utmCampaign: $request->query('utm_campaign'),
|
||||
utmTerm: $request->query('utm_term'),
|
||||
utmContent: $request->query('utm_content'),
|
||||
isQrScan: $isQrScan,
|
||||
browserLanguage: $browserLanguage,
|
||||
);
|
||||
|
||||
if ($connection) {
|
||||
dispatch($job->onConnection($connection));
|
||||
} else {
|
||||
dispatch($job->onConnection('sync'));
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Never let tracking failures block the redirection of the user!
|
||||
Log::error('[FilamentShortUrl] Redirect tracking failed', [
|
||||
'url_key' => $key,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,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' => '*',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ class AuthenticateShortUrlApi
|
||||
|
||||
$apiKey = $request->header('X-Api-Key');
|
||||
|
||||
if (!$apiKey && $auth = $request->header('Authorization')) {
|
||||
if (! $apiKey && $auth = $request->header('Authorization')) {
|
||||
if (str_starts_with(strtolower($auth), 'bearer ')) {
|
||||
$apiKey = substr($auth, 7);
|
||||
}
|
||||
@@ -38,14 +38,30 @@ 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$valid) {
|
||||
if (! $valid) {
|
||||
return response()->json([
|
||||
'error' => 'Unauthorized. Invalid or inactive API Key.',
|
||||
], 401);
|
||||
|
||||
@@ -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,25 +40,33 @@ 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()) {
|
||||
Log::warning("[FilamentShortUrl] Webhook delivery returned client/server error", [
|
||||
Log::warning('[FilamentShortUrl] Webhook delivery returned client/server error', [
|
||||
'url' => $this->url,
|
||||
'event' => $this->event,
|
||||
'status' => $response->status(),
|
||||
]);
|
||||
|
||||
// Throw exception to trigger queue retry
|
||||
throw new \RuntimeException("Webhook failed with status " . $response->status());
|
||||
throw new \RuntimeException('Webhook failed with status '.$response->status());
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("[FilamentShortUrl] Webhook delivery failed", [
|
||||
Log::warning('[FilamentShortUrl] Webhook delivery failed', [
|
||||
'url' => $this->url,
|
||||
'event' => $this->event,
|
||||
'error' => $e->getMessage(),
|
||||
|
||||
@@ -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.
|
||||
@@ -39,7 +38,7 @@ class TrackShortUrlVisitJob implements ShouldQueue
|
||||
public readonly ShortUrl $shortUrl,
|
||||
public readonly string $ipAddress,
|
||||
public readonly string $userAgent,
|
||||
public readonly ?string $refererUrl,
|
||||
public readonly ?string $refererUrl = null,
|
||||
public readonly ?string $countryCode = null,
|
||||
public readonly ?string $city = null,
|
||||
public readonly ?string $utmSource = null,
|
||||
@@ -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,46 +98,64 @@ 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)) {
|
||||
dispatch(new \Bjanczak\FilamentShortUrl\Jobs\SendWebhookJob(
|
||||
url: $targetUrl,
|
||||
event: 'visited',
|
||||
payload: [
|
||||
'event' => 'visited',
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
'short_url' => [
|
||||
'id' => $shortUrl->id,
|
||||
'destination_url' => $shortUrl->destination_url,
|
||||
if (! empty($webhooksToDispatch)) {
|
||||
$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,
|
||||
'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,
|
||||
'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'));
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Optional GA4 Measurement Protocol integration
|
||||
@@ -148,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -344,56 +407,198 @@ class ShortUrl extends Model
|
||||
return $this->destination_url;
|
||||
}
|
||||
|
||||
$type = $rules['type'] ?? 'none';
|
||||
// Backward compatibility check: legacy type-based strategy
|
||||
if (is_array($rules) && isset($rules['type'])) {
|
||||
$type = $rules['type'] ?? 'none';
|
||||
|
||||
if ($type === 'device') {
|
||||
$parser = app(UserAgentParser::class);
|
||||
$deviceType = $parser->parse($request->userAgent() ?? '')['device_type'];
|
||||
if ($type === 'device') {
|
||||
$parser = app(UserAgentParser::class);
|
||||
$deviceType = $parser->getDeviceType($request->userAgent() ?? '');
|
||||
|
||||
if ($deviceType === 'mobile') {
|
||||
return $rules['device']['mobile'] ?? $rules['device']['ios'] ?? $this->destination_url;
|
||||
}
|
||||
if ($deviceType === 'tablet') {
|
||||
return $rules['device']['tablet'] ?? $rules['device']['android'] ?? $this->destination_url;
|
||||
}
|
||||
|
||||
return $rules['device']['desktop'] ?? $this->destination_url;
|
||||
}
|
||||
|
||||
if ($type === 'geo') {
|
||||
$countryCode = ClientIpExtractor::getCountryCode($request);
|
||||
if (! $countryCode) {
|
||||
// Try resolving via GeoIpService
|
||||
$ip = ClientIpExtractor::getIp($request);
|
||||
$geo = app(GeoIpService::class)->resolve($ip);
|
||||
$countryCode = $geo['country_code'] ?? null;
|
||||
}
|
||||
|
||||
if ($countryCode) {
|
||||
$countryCode = strtoupper(trim($countryCode));
|
||||
foreach ($rules['geo'] ?? [] as $rule) {
|
||||
if (strtoupper($rule['country_code'] ?? '') === $countryCode) {
|
||||
return $rule['url'] ?? $this->destination_url;
|
||||
}
|
||||
if ($deviceType === 'mobile') {
|
||||
return $rules['device']['mobile'] ?? $rules['device']['ios'] ?? $this->destination_url;
|
||||
}
|
||||
if ($deviceType === 'tablet') {
|
||||
return $rules['device']['tablet'] ?? $rules['device']['android'] ?? $this->destination_url;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($type === 'rotation') {
|
||||
$items = $rules['rotation'] ?? [];
|
||||
if (! empty($items)) {
|
||||
$totalWeight = array_sum(array_column($items, 'weight'));
|
||||
if ($totalWeight > 0) {
|
||||
$rand = mt_rand(1, $totalWeight);
|
||||
$currentWeight = 0;
|
||||
foreach ($items as $item) {
|
||||
$currentWeight += (int) ($item['weight'] ?? 0);
|
||||
if ($rand <= $currentWeight) {
|
||||
return $item['url'] ?? $this->destination_url;
|
||||
return $rules['device']['desktop'] ?? $this->destination_url;
|
||||
}
|
||||
|
||||
if ($type === 'geo') {
|
||||
$countryCode = ClientIpExtractor::getCountryCode($request);
|
||||
if (! $countryCode) {
|
||||
$ip = ClientIpExtractor::getIp($request);
|
||||
$geo = app(GeoIpService::class)->resolve($ip);
|
||||
$countryCode = $geo['country_code'] ?? null;
|
||||
}
|
||||
|
||||
if ($countryCode) {
|
||||
$countryCode = strtoupper(trim($countryCode));
|
||||
foreach ($rules['geo'] ?? [] as $rule) {
|
||||
if (strtoupper($rule['country_code'] ?? '') === $countryCode) {
|
||||
return $rule['url'] ?? $this->destination_url;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($type === 'language') {
|
||||
$acceptedLanguages = $request->getLanguages();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)) {
|
||||
$totalWeight = array_sum(array_column($items, 'weight'));
|
||||
if ($totalWeight > 0) {
|
||||
$rand = mt_rand(1, $totalWeight);
|
||||
$currentWeight = 0;
|
||||
foreach ($items as $item) {
|
||||
$currentWeight += (int) ($item['weight'] ?? 0);
|
||||
if ($rand <= $currentWeight) {
|
||||
return $item['url'] ?? $this->destination_url;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this->destination_url;
|
||||
}
|
||||
|
||||
// Evaluate new multi-filter rule engine
|
||||
if (! is_array($rules)) {
|
||||
return $this->destination_url;
|
||||
}
|
||||
|
||||
// Lazy-loaded request properties for maximum performance
|
||||
$parsedUserAgent = null;
|
||||
$deviceType = null;
|
||||
$platformOs = null;
|
||||
$countryCode = null;
|
||||
$browserLanguages = null;
|
||||
|
||||
foreach ($rules as $rule) {
|
||||
$filters = $rule['filters'] ?? [];
|
||||
if (empty($filters)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$matchType = $rule['match'] ?? 'or';
|
||||
$ruleMatches = $matchType === 'and'; // 'and' defaults to true (requires all matching), 'or' defaults to false
|
||||
|
||||
foreach ($filters as $filter) {
|
||||
$filterType = $filter['type'] ?? '';
|
||||
$filterData = $filter['data'] ?? [];
|
||||
$filterMatches = false;
|
||||
|
||||
if ($filterType === 'device') {
|
||||
if ($deviceType === null) {
|
||||
$deviceType = app(UserAgentParser::class)->getDeviceType($request->userAgent() ?? '');
|
||||
}
|
||||
$filterMatches = in_array($deviceType, $filterData['devices'] ?? []);
|
||||
} elseif ($filterType === 'platform') {
|
||||
if ($platformOs === null) {
|
||||
$rawOs = app(UserAgentParser::class)->getOs($request->userAgent() ?? '') ?? '';
|
||||
$platformOs = match (true) {
|
||||
stripos($rawOs, 'Windows') !== false => 'windows',
|
||||
stripos($rawOs, 'Fire OS') !== false => 'fire_os',
|
||||
stripos($rawOs, 'iOS') !== false || stripos($rawOs, 'iPad') !== false => 'ios',
|
||||
stripos($rawOs, 'Mac') !== false => 'mac',
|
||||
stripos($rawOs, 'Android') !== false => 'android',
|
||||
stripos($rawOs, 'Linux') !== false => 'linux',
|
||||
default => strtolower($rawOs),
|
||||
};
|
||||
}
|
||||
$filterMatches = in_array($platformOs, $filterData['platforms'] ?? []);
|
||||
} elseif ($filterType === 'country') {
|
||||
if ($countryCode === null) {
|
||||
$countryCode = ClientIpExtractor::getCountryCode($request);
|
||||
if (! $countryCode) {
|
||||
$ip = ClientIpExtractor::getIp($request);
|
||||
$geo = app(GeoIpService::class)->resolve($ip);
|
||||
$countryCode = $geo['country_code'] ?? '';
|
||||
}
|
||||
$countryCode = strtoupper(trim($countryCode));
|
||||
}
|
||||
$filterMatches = in_array($countryCode, array_map('strtoupper', $filterData['countries'] ?? []));
|
||||
} elseif ($filterType === 'language') {
|
||||
if ($browserLanguages === null) {
|
||||
$browserLanguages = array_map(function ($lang) {
|
||||
return strtolower(trim(str_replace('_', '-', $lang)));
|
||||
}, $request->getLanguages());
|
||||
}
|
||||
|
||||
$filterLangs = array_map('strtolower', $filterData['languages'] ?? []);
|
||||
|
||||
// Pass 1: Exact match
|
||||
foreach ($browserLanguages as $browserLang) {
|
||||
if (in_array($browserLang, $filterLangs)) {
|
||||
$filterMatches = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: Fallback prefix match
|
||||
if (! $filterMatches) {
|
||||
foreach ($browserLanguages as $browserLang) {
|
||||
$primaryLang = explode('-', $browserLang)[0];
|
||||
if (in_array($primaryLang, $filterLangs)) {
|
||||
$filterMatches = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($matchType === 'and') {
|
||||
if (! $filterMatches) {
|
||||
$ruleMatches = false;
|
||||
break; // fail fast
|
||||
}
|
||||
} else { // 'or'
|
||||
if ($filterMatches) {
|
||||
$ruleMatches = true;
|
||||
break; // succeed fast
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($ruleMatches && ! empty($rule['url'])) {
|
||||
return $rule['url'];
|
||||
}
|
||||
}
|
||||
|
||||
return $this->destination_url;
|
||||
@@ -403,36 +608,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 +734,7 @@ class ShortUrl extends Model
|
||||
$visitsToday = count($rawVisits);
|
||||
$visitsThisWeek = 0;
|
||||
$visitsThisMonth = 0;
|
||||
$qrScans = 0;
|
||||
|
||||
$visitsByCountry = [];
|
||||
$visitsByCity = [];
|
||||
@@ -500,6 +745,7 @@ class ShortUrl extends Model
|
||||
$utmSources = [];
|
||||
$utmMediums = [];
|
||||
$utmCampaigns = [];
|
||||
$visitsByLanguage = [];
|
||||
|
||||
// Sum up daily stats
|
||||
$startOfWeek = now()->startOfWeek()->toDateString();
|
||||
@@ -508,6 +754,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 +773,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 +785,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 +867,7 @@ class ShortUrl extends Model
|
||||
arsort($utmSources);
|
||||
arsort($utmMediums);
|
||||
arsort($utmCampaigns);
|
||||
arsort($visitsByLanguage);
|
||||
|
||||
return [
|
||||
'totalVisits' => $totalVisits,
|
||||
@@ -630,6 +885,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 +898,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 +912,7 @@ class ShortUrl extends Model
|
||||
|
||||
static::$bufferedTotalVisits = [];
|
||||
static::$bufferedUniqueVisits = [];
|
||||
static::$bufferedQrScans = [];
|
||||
|
||||
if (! config('filament-short-url.counter_buffering.enabled', false)) {
|
||||
return;
|
||||
@@ -682,15 +942,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 +961,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 +1006,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;
|
||||
@@ -47,11 +53,18 @@ class ShortUrlVisit extends Model
|
||||
'utm_term',
|
||||
'utm_content',
|
||||
'visited_at',
|
||||
'is_bot',
|
||||
'is_proxy',
|
||||
'is_qr_scan',
|
||||
'browser_language',
|
||||
];
|
||||
|
||||
/** @var array<string, string> */
|
||||
protected $casts = [
|
||||
'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;
|
||||
}
|
||||
|
||||
128
src/Services/ProxyDetectionService.php
Normal file
128
src/Services/ProxyDetectionService.php
Normal file
@@ -0,0 +1,128 @@
|
||||
<?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\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ProxyDetectionService
|
||||
{
|
||||
/**
|
||||
* Detect if an IP address belongs to a VPN, proxy, Tor node, or data center hosting.
|
||||
*
|
||||
* @return array{is_proxy: bool, is_bot: bool}
|
||||
*/
|
||||
public function detect(string $ip): array
|
||||
{
|
||||
if (empty($ip) || $ip === '127.0.0.1' || $ip === '::1') {
|
||||
return ['is_proxy' => false, 'is_bot' => false];
|
||||
}
|
||||
|
||||
$enabled = config('filament-short-url.vpn_detection.enabled', false);
|
||||
if (! $enabled) {
|
||||
return ['is_proxy' => false, 'is_bot' => false];
|
||||
}
|
||||
|
||||
$cacheKey = "short-url:proxy-check:{$ip}";
|
||||
|
||||
if (Cache::has($cacheKey)) {
|
||||
return Cache::get($cacheKey);
|
||||
}
|
||||
|
||||
$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;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query ip-api.com endpoint.
|
||||
*
|
||||
* @return array{is_proxy: bool, is_bot: bool}
|
||||
*/
|
||||
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()) {
|
||||
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);
|
||||
$isBot = (bool) $response->json('hosting', false); // hosting indicates data centers (bots/scrapers)
|
||||
|
||||
return [
|
||||
'is_proxy' => $isProxy,
|
||||
'is_bot' => $isBot,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Query vpnapi.io endpoint.
|
||||
*
|
||||
* @return array{is_proxy: bool, is_bot: bool}
|
||||
*/
|
||||
private function queryVpnApi(string $ip, float|int $timeout): array
|
||||
{
|
||||
$key = config('filament-short-url.vpn_detection.vpnapi_key');
|
||||
|
||||
if (empty($key)) {
|
||||
throw new \RuntimeException('vpnapi.io API key is empty.');
|
||||
}
|
||||
|
||||
$url = "https://vpnapi.io/api/{$ip}?key={$key}";
|
||||
|
||||
$response = Http::timeout($timeout)->get($url);
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new \RuntimeException('HTTP request failed with status: '.$response->status());
|
||||
}
|
||||
|
||||
$security = $response->json('security', []);
|
||||
$isVpn = (bool) ($security['vpn'] ?? false);
|
||||
$isProxy = (bool) ($security['proxy'] ?? false);
|
||||
$isTor = (bool) ($security['tor'] ?? false);
|
||||
$isRelay = (bool) ($security['relay'] ?? false);
|
||||
|
||||
// Treat hosting/data center as bot traffic
|
||||
$isHosting = (bool) ($response->json('security.hosting') ?? false);
|
||||
|
||||
return [
|
||||
'is_proxy' => $isVpn || $isProxy || $isTor || $isRelay,
|
||||
'is_bot' => $isHosting,
|
||||
];
|
||||
}
|
||||
}
|
||||
85
src/Services/SafeBrowsingService.php
Normal file
85
src/Services/SafeBrowsingService.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?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;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class SafeBrowsingService
|
||||
{
|
||||
/**
|
||||
* Check if a URL is safe using Google Safe Browsing API.
|
||||
*
|
||||
* Returns true if safe (or if API is disabled/failed), false if a threat is detected.
|
||||
*/
|
||||
public function isSafe(string $url): bool
|
||||
{
|
||||
$enabled = config('filament-short-url.safe_browsing.enabled', false);
|
||||
$apiKey = config('filament-short-url.safe_browsing.api_key');
|
||||
|
||||
if (! $enabled || empty($apiKey)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->isSafeWithKey($url, $apiKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a URL is safe using Google Safe Browsing API with a specific key.
|
||||
*/
|
||||
public function isSafeWithKey(string $url, string $apiKey): bool
|
||||
{
|
||||
if (empty($apiKey)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
$endpoint = "https://safebrowsing.googleapis.com/v4/threatMatches:find?key={$apiKey}";
|
||||
|
||||
$payload = [
|
||||
'client' => [
|
||||
'clientId' => 'filament-short-url-plugin',
|
||||
'clientVersion' => '2.0.0',
|
||||
],
|
||||
'threatInfo' => [
|
||||
'threatTypes' => [
|
||||
'MALWARE',
|
||||
'SOCIAL_ENGINEERING',
|
||||
'UNWANTED_SOFTWARE',
|
||||
'POTENTIALLY_HARMFUL_APPLICATION',
|
||||
],
|
||||
'platformTypes' => ['ANY_PLATFORM'],
|
||||
'threatEntryTypes' => ['URL'],
|
||||
'threatEntries' => [
|
||||
['url' => $url],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$response = Http::timeout(3)
|
||||
->withHeaders(['Content-Type' => 'application/json'])
|
||||
->post($endpoint, $payload);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::warning('Google Safe Browsing API request failed with status: '.$response->status());
|
||||
|
||||
return true; // Default to safe if API is down
|
||||
}
|
||||
|
||||
$matches = $response->json('matches', []);
|
||||
|
||||
// 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());
|
||||
|
||||
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;
|
||||
@@ -107,13 +113,15 @@ class ShortUrlService
|
||||
*/
|
||||
public function resolveRedirectUrl(ShortUrl $shortUrl, Request $request): string
|
||||
{
|
||||
$destination = $shortUrl->destination_url;
|
||||
$destination = $shortUrl->resolveDestinationUrl($request);
|
||||
|
||||
if (! $shortUrl->forward_query_params) {
|
||||
return $destination;
|
||||
}
|
||||
|
||||
$queryParams = $request->query();
|
||||
// Remove routing/auth parameters so they don't leak to destination
|
||||
unset($queryParams['confirmed'], $queryParams['password']);
|
||||
|
||||
if (empty($queryParams)) {
|
||||
return $destination;
|
||||
|
||||
@@ -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,8 +134,23 @@ 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'),
|
||||
// Security v2.0
|
||||
'vpn_detection_enabled' => config('filament-short-url.vpn_detection.enabled', false),
|
||||
'vpn_detection_driver' => config('filament-short-url.vpn_detection.driver', 'ip-api'),
|
||||
'vpnapi_key' => config('filament-short-url.vpn_detection.vpnapi_key'),
|
||||
'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;
|
||||
@@ -91,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
|
||||
@@ -137,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',
|
||||
@@ -148,8 +213,23 @@ class ShortUrlSettingsManager
|
||||
'qr_gradient_type',
|
||||
'global_webhook_url',
|
||||
'webhook_events',
|
||||
'global_webhook_enabled',
|
||||
'api_keys',
|
||||
'api_enabled',
|
||||
'site_name',
|
||||
// Security v2.0
|
||||
'vpn_detection_enabled',
|
||||
'vpn_detection_driver',
|
||||
'vpnapi_key',
|
||||
'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));
|
||||
@@ -223,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'])) {
|
||||
@@ -235,7 +318,68 @@ class ShortUrlSettingsManager
|
||||
$filtered['qr_gradient_enabled'] = (bool) $filtered['qr_gradient_enabled'];
|
||||
}
|
||||
|
||||
File::put($path, json_encode($filtered, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
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'];
|
||||
}
|
||||
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'];
|
||||
}
|
||||
|
||||
$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
|
||||
@@ -291,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'],
|
||||
@@ -303,6 +448,22 @@ class ShortUrlSettingsManager
|
||||
'filament-short-url.global_webhook_url' => $settings['global_webhook_url'] ?? null,
|
||||
'filament-short-url.webhook_events' => $settings['webhook_events'] ?? ['visited'],
|
||||
'filament-short-url.api_enabled' => (bool) ($settings['api_enabled'] ?? false),
|
||||
'filament-short-url.site_name' => $settings['site_name'] ?? null,
|
||||
// Security v2.0
|
||||
'filament-short-url.vpn_detection.enabled' => (bool) ($settings['vpn_detection_enabled'] ?? false),
|
||||
'filament-short-url.vpn_detection.driver' => $settings['vpn_detection_driver'] ?? 'ip-api',
|
||||
'filament-short-url.vpn_detection.vpnapi_key' => $settings['vpnapi_key'] ?? null,
|
||||
'filament-short-url.vpn_detection.block_action' => $settings['vpn_block_action'] ?? 'flag_only',
|
||||
'filament-short-url.vpn_detection.cache_ttl' => 86400,
|
||||
'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;
|
||||
@@ -16,6 +22,7 @@ class ShortUrlTracker
|
||||
public function __construct(
|
||||
private readonly UserAgentParser $uaParser,
|
||||
private readonly GeoIpService $geoIp,
|
||||
private readonly ProxyDetectionService $proxyDetector,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -33,15 +40,22 @@ 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);
|
||||
$ua = $request->userAgent() ?? '';
|
||||
$parsed = $this->uaParser->parse($ua);
|
||||
|
||||
// Don't track bots — they inflate stats and waste API calls
|
||||
if ($parsed['device_type'] === 'robot') {
|
||||
return null;
|
||||
// Run bot & proxy/VPN detection
|
||||
$isBot = $parsed['device_type'] === 'robot';
|
||||
$isProxy = false;
|
||||
|
||||
if (! $isBot) {
|
||||
$detection = $this->proxyDetector->detect($ip);
|
||||
$isBot = (bool) $detection['is_bot'];
|
||||
$isProxy = (bool) $detection['is_proxy'];
|
||||
}
|
||||
|
||||
$geo = config('filament-short-url.geo_ip.enabled', true)
|
||||
@@ -50,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;
|
||||
@@ -68,6 +92,10 @@ class ShortUrlTracker
|
||||
$visit->country = $geo['country'];
|
||||
$visit->country_code = $geo['country_code'];
|
||||
$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;
|
||||
@@ -104,7 +132,10 @@ class ShortUrlTracker
|
||||
|
||||
$visit->save();
|
||||
|
||||
$shortUrl->incrementVisits($isUnique);
|
||||
// Increment stats only if it's NOT a bot or proxy/VPN to keep analytics clean
|
||||
if (! $isBot && ! $isProxy) {
|
||||
$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;
|
||||
|
||||
/**
|
||||
@@ -32,11 +38,23 @@ class UserAgentParser
|
||||
];
|
||||
}
|
||||
|
||||
public function getDeviceType(string $userAgent): string
|
||||
{
|
||||
return $this->parseDeviceType($userAgent);
|
||||
}
|
||||
|
||||
public function getOs(string $userAgent): ?string
|
||||
{
|
||||
return $this->parseOs($userAgent);
|
||||
}
|
||||
|
||||
private function parseBrowser(string $ua): ?string
|
||||
{
|
||||
// Order matters — check specific first, generic last
|
||||
$patterns = [
|
||||
'Edg/' => 'Edge',
|
||||
'EdgiOS' => 'Edge',
|
||||
'EdgA' => 'Edge',
|
||||
'OPR/' => 'Opera',
|
||||
'Opera' => 'Opera',
|
||||
'SamsungBrowser' => 'Samsung Browser',
|
||||
@@ -67,6 +85,8 @@ class UserAgentParser
|
||||
{
|
||||
$patterns = [
|
||||
'/Edg\/([0-9.]+)/',
|
||||
'/EdgiOS\/([0-9.]+)/',
|
||||
'/EdgA\/([0-9.]+)/',
|
||||
'/OPR\/([0-9.]+)/',
|
||||
'/SamsungBrowser\/([0-9.]+)/',
|
||||
'/CriOS\/([0-9.]+)/',
|
||||
@@ -93,6 +113,7 @@ class UserAgentParser
|
||||
private function parseOs(string $ua): ?string
|
||||
{
|
||||
return match (true) {
|
||||
(stripos($ua, 'Silk/') !== false || stripos($ua, 'Kindle') !== false) => 'Fire OS',
|
||||
stripos($ua, 'Windows') !== false => 'Windows',
|
||||
stripos($ua, 'iPad') !== false => 'iPadOS',
|
||||
stripos($ua, 'iPhone') !== false => 'iOS',
|
||||
|
||||
349
tests/Feature/ShortUrlApiTest.php
Normal file
349
tests/Feature/ShortUrlApiTest.php
Normal file
@@ -0,0 +1,349 @@
|
||||
<?php
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Jobs\SendWebhookJob;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlPixel;
|
||||
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 = 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,
|
||||
]);
|
||||
});
|
||||
|
||||
it('allows updating a short link via PUT', function () {
|
||||
$link = ShortUrl::create([
|
||||
'destination_url' => 'https://initial-destination.com',
|
||||
'url_key' => 'initkey',
|
||||
'notes' => 'Initial notes',
|
||||
]);
|
||||
|
||||
$response = $this->putJson("/api/short-url/links/{$link->id}", [
|
||||
'destination_url' => 'https://updated-destination.com',
|
||||
'notes' => 'Updated notes',
|
||||
], [
|
||||
'X-Api-Key' => 'sh_key_active_token',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonFragment(['destination_url' => 'https://updated-destination.com'])
|
||||
->assertJsonFragment(['notes' => 'Updated notes'])
|
||||
->assertJsonFragment(['url_key' => 'initkey']);
|
||||
|
||||
$this->assertDatabaseHas('short_urls', [
|
||||
'id' => $link->id,
|
||||
'destination_url' => 'https://updated-destination.com',
|
||||
'notes' => 'Updated notes',
|
||||
'url_key' => 'initkey',
|
||||
]);
|
||||
});
|
||||
|
||||
it('allows showing a single short link via GET by ID or key', function () {
|
||||
$link = ShortUrl::create([
|
||||
'destination_url' => 'https://single-show.com',
|
||||
'url_key' => 'showkey',
|
||||
]);
|
||||
|
||||
// Show by ID
|
||||
$response = $this->getJson("/api/short-url/links/{$link->id}", [
|
||||
'X-Api-Key' => 'sh_key_active_token',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonFragment(['url_key' => 'showkey']);
|
||||
|
||||
// Show by Key
|
||||
$responseKey = $this->getJson('/api/short-url/links/showkey', [
|
||||
'X-Api-Key' => 'sh_key_active_token',
|
||||
]);
|
||||
|
||||
$responseKey->assertStatus(200)
|
||||
->assertJsonFragment(['id' => $link->id]);
|
||||
});
|
||||
|
||||
it('allows fetching link statistics via GET', function () {
|
||||
$link = ShortUrl::create([
|
||||
'destination_url' => 'https://stats-test.com',
|
||||
'url_key' => 'statskey',
|
||||
]);
|
||||
|
||||
$response = $this->getJson("/api/short-url/links/{$link->id}/stats", [
|
||||
'X-Api-Key' => 'sh_key_active_token',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonStructure([
|
||||
'data' => [
|
||||
'totalVisits',
|
||||
'uniqueVisits',
|
||||
'visitsToday',
|
||||
'visitsByDay',
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('validates new targeting rules in API payload', function () {
|
||||
// 1. Empty filters array validation
|
||||
$responseEmpty = $this->postJson('/api/short-url/links', [
|
||||
'destination_url' => 'https://google.com',
|
||||
'url_key' => 'rule_empty',
|
||||
'targeting_rules' => [
|
||||
[
|
||||
'match' => 'or',
|
||||
'url' => 'https://mobile.com',
|
||||
'filters' => [], // Empty filters array, should fail
|
||||
],
|
||||
],
|
||||
], [
|
||||
'X-Api-Key' => 'sh_key_active_token',
|
||||
]);
|
||||
|
||||
$responseEmpty->assertStatus(422)
|
||||
->assertJsonValidationErrors(['targeting_rules.0.filters']);
|
||||
|
||||
// 2. Duplicate filter type validation
|
||||
$responseDuplicate = $this->postJson('/api/short-url/links', [
|
||||
'destination_url' => 'https://google.com',
|
||||
'url_key' => 'rule_dup',
|
||||
'targeting_rules' => [
|
||||
[
|
||||
'match' => 'or',
|
||||
'url' => 'https://mobile.com',
|
||||
'filters' => [
|
||||
[
|
||||
'type' => 'device',
|
||||
'data' => ['devices' => ['mobile']],
|
||||
],
|
||||
[
|
||||
'type' => 'device', // Duplicate filter type, should fail
|
||||
'data' => ['devices' => ['desktop']],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
], [
|
||||
'X-Api-Key' => 'sh_key_active_token',
|
||||
]);
|
||||
|
||||
$responseDuplicate->assertStatus(422)
|
||||
->assertJsonValidationErrors(['targeting_rules.0.filters']);
|
||||
|
||||
// 3. Valid rules should succeed
|
||||
$responseValid = $this->postJson('/api/short-url/links', [
|
||||
'destination_url' => 'https://google.com',
|
||||
'url_key' => 'rule_valid',
|
||||
'targeting_rules' => [
|
||||
[
|
||||
'match' => 'or',
|
||||
'url' => 'https://mobile.com',
|
||||
'filters' => [
|
||||
[
|
||||
'type' => 'device',
|
||||
'data' => ['devices' => ['mobile']],
|
||||
],
|
||||
[
|
||||
'type' => 'platform',
|
||||
'data' => ['platforms' => ['ios']],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
], [
|
||||
'X-Api-Key' => 'sh_key_active_token',
|
||||
]);
|
||||
|
||||
$responseValid->assertStatus(201);
|
||||
|
||||
// 4. Extraneous key in targeting rules validation
|
||||
$responseExtraRuleKey = $this->postJson('/api/short-url/links', [
|
||||
'destination_url' => 'https://google.com',
|
||||
'url_key' => 'rule_extra_key',
|
||||
'targeting_rules' => [
|
||||
[
|
||||
'match' => 'or',
|
||||
'url' => 'https://mobile.com',
|
||||
'random_junk_key' => 'garbage', // extraneous key, should fail
|
||||
'filters' => [
|
||||
[
|
||||
'type' => 'device',
|
||||
'data' => ['devices' => ['mobile']],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
], [
|
||||
'X-Api-Key' => 'sh_key_active_token',
|
||||
]);
|
||||
|
||||
$responseExtraRuleKey->assertStatus(422)
|
||||
->assertJsonValidationErrors(['targeting_rules']);
|
||||
|
||||
// 5. Extraneous key in filters validation
|
||||
$responseExtraFilterKey = $this->postJson('/api/short-url/links', [
|
||||
'destination_url' => 'https://google.com',
|
||||
'url_key' => 'filter_extra_key',
|
||||
'targeting_rules' => [
|
||||
[
|
||||
'match' => 'or',
|
||||
'url' => 'https://mobile.com',
|
||||
'filters' => [
|
||||
[
|
||||
'type' => 'device',
|
||||
'data' => ['devices' => ['mobile']],
|
||||
'random_junk_key' => 'garbage', // extraneous key, should fail
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
], [
|
||||
'X-Api-Key' => 'sh_key_active_token',
|
||||
]);
|
||||
|
||||
$responseExtraFilterKey->assertStatus(422)
|
||||
->assertJsonValidationErrors(['targeting_rules.0.filters']);
|
||||
|
||||
// 6. Invalid values in targeting rules (e.g. invalid country code and invalid device)
|
||||
$responseInvalidValues = $this->postJson('/api/short-url/links', [
|
||||
'destination_url' => 'https://google.com',
|
||||
'url_key' => 'rule_invalid_values',
|
||||
'targeting_rules' => [
|
||||
[
|
||||
'match' => 'or',
|
||||
'url' => 'https://mobile.com',
|
||||
'filters' => [
|
||||
[
|
||||
'type' => 'device',
|
||||
'data' => ['devices' => ['microwave']], // invalid device type
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
], [
|
||||
'X-Api-Key' => 'sh_key_active_token',
|
||||
]);
|
||||
|
||||
$responseInvalidValues->assertStatus(422)
|
||||
->assertJsonValidationErrors(['targeting_rules.0.filters.0.data.devices.0']);
|
||||
});
|
||||
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.' 00:00:00',
|
||||
'visits_count' => 3,
|
||||
'unique_visits_count' => 2,
|
||||
'qr_visits_count' => 1,
|
||||
]);
|
||||
|
||||
$stats = ShortUrlDailyStats::where('short_url_id', $link->id)
|
||||
->where('date', $yesterday.' 00:00:00')
|
||||
->first();
|
||||
|
||||
expect($stats->device_stats)->toBe(['desktop' => 2, 'mobile' => 1])
|
||||
->and($stats->browser_stats)->toBe(['Chrome' => 2, 'Safari' => 1])
|
||||
->and($stats->os_stats)->toBe(['iOS' => 1, 'macOS' => 2])
|
||||
->and($stats->country_stats)->toBe(['Poland' => 3])
|
||||
->and($stats->city_stats)->toBe(['Krakow (PL)' => 1, 'Warsaw (PL)' => 2])
|
||||
->and($stats->language_stats)->toBe(['en' => 1, 'pl' => 2]);
|
||||
});
|
||||
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);
|
||||
});
|
||||
836
tests/Feature/ShortUrlRedirectTest.php
Normal file
836
tests/Feature/ShortUrlRedirectTest.php
Normal file
@@ -0,0 +1,836 @@
|
||||
<?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');
|
||||
});
|
||||
|
||||
it('evaluates new multi-filter targeting rules with OR match logic', function () {
|
||||
config(['filament-short-url.trust_cdn_headers' => true]);
|
||||
|
||||
createShortUrl([
|
||||
'url_key' => 'or-multi',
|
||||
'track_visits' => false,
|
||||
'targeting_rules' => [
|
||||
[
|
||||
'match' => 'or',
|
||||
'url' => 'https://matched-or.example.com',
|
||||
'filters' => [
|
||||
[
|
||||
'type' => 'country',
|
||||
'data' => ['countries' => ['PL', 'DE']],
|
||||
],
|
||||
[
|
||||
'type' => 'device',
|
||||
'data' => ['devices' => ['mobile']],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
// Matches Country (PL) -> redirects
|
||||
$this->get('/s/or-multi', ['CF-IPCountry' => 'PL'])
|
||||
->assertRedirect('https://matched-or.example.com');
|
||||
|
||||
// Matches Device (mobile) but different Country -> redirects
|
||||
$this->get('/s/or-multi', [
|
||||
'CF-IPCountry' => 'US',
|
||||
'User-Agent' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)',
|
||||
])->assertRedirect('https://matched-or.example.com');
|
||||
|
||||
// Matches neither -> falls back to default destination_url
|
||||
$this->get('/s/or-multi', [
|
||||
'CF-IPCountry' => 'US',
|
||||
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
|
||||
])->assertRedirect('https://example.com');
|
||||
});
|
||||
|
||||
it('evaluates new multi-filter targeting rules with AND match logic', function () {
|
||||
config(['filament-short-url.trust_cdn_headers' => true]);
|
||||
|
||||
createShortUrl([
|
||||
'url_key' => 'and-multi',
|
||||
'track_visits' => false,
|
||||
'targeting_rules' => [
|
||||
[
|
||||
'match' => 'and',
|
||||
'url' => 'https://matched-and.example.com',
|
||||
'filters' => [
|
||||
[
|
||||
'type' => 'country',
|
||||
'data' => ['countries' => ['PL']],
|
||||
],
|
||||
[
|
||||
'type' => 'device',
|
||||
'data' => ['devices' => ['mobile']],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
// Matches both -> redirects
|
||||
$this->get('/s/and-multi', [
|
||||
'CF-IPCountry' => 'PL',
|
||||
'User-Agent' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)',
|
||||
])->assertRedirect('https://matched-and.example.com');
|
||||
|
||||
// Matches only Country -> falls back
|
||||
$this->get('/s/and-multi', [
|
||||
'CF-IPCountry' => 'PL',
|
||||
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
|
||||
])->assertRedirect('https://example.com');
|
||||
|
||||
// Matches only Device -> falls back
|
||||
$this->get('/s/and-multi', [
|
||||
'CF-IPCountry' => 'US',
|
||||
'User-Agent' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)',
|
||||
])->assertRedirect('https://example.com');
|
||||
});
|
||||
|
||||
it('evaluates platform and browser language filters in new rule engine', function () {
|
||||
createShortUrl([
|
||||
'url_key' => 'platform-lang',
|
||||
'track_visits' => false,
|
||||
'targeting_rules' => [
|
||||
[
|
||||
'match' => 'and',
|
||||
'url' => 'https://matched-platform-lang.example.com',
|
||||
'filters' => [
|
||||
[
|
||||
'type' => 'platform',
|
||||
'data' => ['platforms' => ['ios', 'android']],
|
||||
],
|
||||
[
|
||||
'type' => 'language',
|
||||
'data' => ['languages' => ['pl', 'de']],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
// iOS + pl -> redirects
|
||||
$this->get('/s/platform-lang', [
|
||||
'User-Agent' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)',
|
||||
'Accept-Language' => 'pl-PL,pl;q=0.9',
|
||||
])->assertRedirect('https://matched-platform-lang.example.com');
|
||||
|
||||
// Android + de -> redirects
|
||||
$this->get('/s/platform-lang', [
|
||||
'User-Agent' => 'Mozilla/5.0 (Linux; Android 13)',
|
||||
'Accept-Language' => 'de-DE,de;q=0.9',
|
||||
])->assertRedirect('https://matched-platform-lang.example.com');
|
||||
|
||||
// iOS + en -> falls back
|
||||
$this->get('/s/platform-lang', [
|
||||
'User-Agent' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)',
|
||||
'Accept-Language' => 'en-US,en;q=0.9',
|
||||
])->assertRedirect('https://example.com');
|
||||
|
||||
// Windows + pl -> falls back
|
||||
$this->get('/s/platform-lang', [
|
||||
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
|
||||
'Accept-Language' => 'pl-PL,pl;q=0.9',
|
||||
])->assertRedirect('https://example.com');
|
||||
});
|
||||
Reference in New Issue
Block a user