12 Commits

Author SHA1 Message Date
Bartłomiej Janczak
0e371ad139 Fix database compatibility: use database-agnostic boolean check in case statement for PostgreSQL 2026-06-02 07:55:22 +02:00
Bartłomiej Janczak
e5437dd0a6 Audit fixes: device targeting, query index optimizations, translation gaps, rate-limiting, and path consistency 2026-06-02 07:51:45 +02:00
Bartłomiej Janczak
65a1b126df feat: global overview widget, optimized cache, CSS asset docs
- Add ShortUrlGlobalOverview StatsOverviewWidget with split cache strategy:
  link counts cached forever (invalidated on create/delete), click stats
  with short TTL (geo_ip.stats_cache_ttl config)
- Fix visited_at column in ShortUrlVisit queries (was created_at)
- Disable Livewire polling (pollingInterval = null)
- Add cache invalidation for global overview in ShortUrl::booted()
- Rewrite README asset section: user-facing install instructions clarified,
  post-autoload-dump tip added, developer recompile notes separated
2026-06-01 18:59:17 +02:00
Bartłomiej Janczak
f0d76ff03b fix: memory-safe aggregation via chunking + allow 0 retention days
- Replace ->get() with ->chunk(1000) in AggregateAndPruneVisitsCommand
  to avoid loading entire day's visits into PHP RAM (critical at scale)
- Stats are now accumulated incrementally via a hash-keyed array,
  unique visits counted via ip_hash deduplication key
- Fix pruning_retention_days minValue(1) → minValue(0) in Settings form;
  0 was documented to disable pruning but form validation blocked it
2026-06-01 14:20:43 +02:00
Bartłomiej Janczak
eda4794cac feat: v1.2.0 — password protection, warning pages, smart targeting, rate limiting, daily stats aggregation
- Add password-protected links with session-based unlock flow
- Add redirect warning (interstitial) pages before external URLs
- Add smart targeting rules: device-based, country/geo, A/B weighted rotation
- Add configurable per-IP rate limiting with 429 + Retry-After headers
- Add daily stats aggregation & log pruning (short-url:aggregate-and-prune)
- Add IncrementVisitJob as queue-based counter buffering fallback
- Add ShortUrlDailyStats model with JSON stat columns per day
- Add two new migrations: targeting/security fields, daily_stats table
- Add password-prompt.blade.php and warning.blade.php views
- Extend Settings GUI with Performance & Security tab (aggregation + rate limiting)
- Extend ShortUrlForm with Targeting & Security section
- Add POST route for password form submission (was GET-only → 405)
- Replace enum(device_type) with string(20) for cross-DB compatibility
- Remove ->after() MySQL-only hints from ALTER TABLE migrations
- Fix aggregation test: use whereDate() instead of assertDatabaseHas for date column
- Extend en/pl translations for all new features
- Expand README.md with full v1.2.0 documentation (476 lines)
2026-06-01 13:54:13 +02:00
Bartłomiej Janczak
69f6cef31b feat: Add UTM parameters, interactive date range filter, cleaned referrer domains, and visitor city tracking to analytics 2026-06-01 13:24:02 +02:00
Bartłomiej Janczak
2ab85dd264 fix: Import ShortUrlResource in ShortUrlsTable to resolve class not found error 2026-06-01 13:10:01 +02:00
Bartłomiej Janczak
f518d4dae9 chore: Update license badge URL in README 2026-06-01 13:07:55 +02:00
Bartłomiej Janczak
bdbe9b2ffd v1.2.0: Added counter buffering (write-back caching), CDN trust headers toggle, full UI localization, dynamic Filament routes, and high-concurrency fixes 2026-06-01 13:05:48 +02:00
Bartłomiej Janczak
c842096a22 style: fix qr-designer spacing when Tailwind helper classes are unbuilt 2026-06-01 12:35:49 +02:00
Bartłomiej Janczak
7035e5b84b Update README.md 2026-06-01 12:35:17 +02:00
Bartłomiej Janczak
3b97ad3d00 add LICENSE file 2026-06-01 12:17:49 +02:00
41 changed files with 3078 additions and 379 deletions

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT 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:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
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.

296
README.md
View File

@@ -5,15 +5,15 @@
<h1 align="center">Filament Short URL</h1>
<p align="center">
<a href="https://packagist.org/packages/janczakb/filament-short-url"><img src="https://img.shields.io/packagist/l/janczakb/filament-short-url.svg?style=flat-square" alt="License"></a>
<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://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/network/members"><img src="https://img.shields.io/github/forks/janczakb/filament-short-url.svg?style=flat-square" alt="GitHub Forks"></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="Packagist Downloads"></a>
<a href="https://github.com/janczakb/filament-short-url/actions"><img src="https://img.shields.io/github/actions/workflow/status/janczakb/filament-short-url/run-tests.yml?branch=main&label=tests&style=flat-square" alt="Tests"></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, and zero external shortening API dependencies.
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.
---
@@ -28,8 +28,13 @@ A professional, high-performance **Short URL Manager** plugin for [Filament v5](
- ⚙️ **Dual-way UTM Campaign Builder** — Built-in form builder synchronizes UTM parameters with your destination URLs in real-time (two-way binding).
- 🔒 **Link Rules & Expiry** — Disable links manually, set expiration dates, or activate single-use links that deactivate automatically after the first click.
- ➡️ **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) directly inside the Filament panel without modifying files or `.env` files.
- 🛠️ **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.
---
@@ -80,11 +85,47 @@ Copies the dashboard components, charts, and QR designer templates to `resources
php artisan vendor:publish --tag=filament-short-url-views
```
### 4. Publish All Assets
### 4. Publish CSS Assets
The plugin ships with a pre-compiled stylesheet. Copy it to your application's public directory so the browser can load it:
```bash
php artisan vendor:publish --provider="Bjanczak\FilamentShortUrl\FilamentShortUrlServiceProvider"
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.
**Tip — automate on every `composer install` / `composer update`:**
Add `filament:assets` to the `post-autoload-dump` scripts in your application's `composer.json` so the assets are always up to date without manual steps:
```json
"scripts": {
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi",
"@php artisan filament:assets"
]
}
```
---
> #### 🛠 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
@@ -121,7 +162,7 @@ All fluent methods on the plugin are optional. If not called, the plugin falls b
## 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. You can access it by clicking the **Settings** action button on the top-right header of the Short URLs resource.
Settings are stored dynamically in `storage/app/filament-short-url-settings.json` and immediately override config defaults.
@@ -129,7 +170,7 @@ 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}`).
* **Default Redirect Status**: Choose `302 (Found / Temporary)` or `301 (Moved Permanently)`.
* **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`).
* **Queue Connection**: Define the Laravel queue connection (e.g. `redis`, `database`, `sync`) used for processing visit analytics asynchronously.
@@ -145,6 +186,165 @@ Sends server-side `short_url_visit` hits using the **GA4 Measurement Protocol AP
* **GA4 API Secret**: Create this secret in Google Analytics under `Admin -> Data Streams -> Measurement Protocol API secrets`.
* **Firebase App ID / Measurement ID**: The target analytics stream identifier.
### 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();
```
### 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.
* **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.
* **Max Redirects Allowed**: Maximum number of redirect requests per IP within the decay window. Default: `60`.
* **Decay Window (seconds)**: The rolling time window for the rate limiter. Default: `60` seconds.
When a client exceeds the limit, a `429 Too Many Requests` response is returned with a `Retry-After` header.
---
## Password-Protected Links (new in v1.2.0)
You can require visitors to enter a password before being redirected. Enable this in the **Targeting & Security** tab of the short URL form:
- Set a plain-text password in the **Access Password** field.
- Visitors will see a styled password prompt page before gaining access.
- The unlock state is stored in the PHP session — visitors only need to enter the password once per session.
```php
// Programmatically — set via fillable attributes
$shortUrl = ShortUrl::destination('https://secret.example.com')
->create();
$shortUrl->update(['password' => 'my-secret-pass']);
```
> **Note**: Passwords are currently stored as plain text. For sensitive use-cases, hash the password and compare with `Hash::check()` by overriding the redirect controller.
---
## Redirect Warning Pages (new in v1.2.0)
Enable the **Show Redirect Warning Page** toggle in the **Targeting & Security** tab to display a safety interstitial before redirecting.
The warning page:
- Shows the destination URL clearly so visitors can verify they trust it.
- Provides **Continue** and **Go Back** buttons.
- Is confirmed via a `?confirmed=1` query parameter — no additional session storage required.
- Is styled to match the password prompt page (glassmorphism, dark mode compatible).
This feature is useful for NSFW links, external partner links, or any URL that leaves a trusted domain.
---
## Smart Link Targeting (new in v1.2.0)
The **Targeting & Security** tab exposes a powerful rule engine that lets you route different visitors to different destinations — all from a single short URL.
### Available Strategies
#### 1. Device-Based Redirects
Route visitors to different URLs based on their device type (detected from User-Agent):
| 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.
```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'],
],
],
]);
```
#### 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.
```php
$shortUrl->update([
'targeting_rules' => [
'type' => 'rotation',
'rotation' => [
['url' => 'https://variant-a.example.com', 'weight' => 70],
['url' => 'https://variant-b.example.com', 'weight' => 30],
],
],
]);
```
> All targeting strategies fall back gracefully to the link's primary `destination_url` if no rule matches.
---
## High-Traffic Optimizations (new in v1.2.0)
### Daily Stats Aggregation
The `short_url_daily_stats` table stores pre-aggregated daily summaries per short URL. Each row contains:
| Column | Description |
|--------|-------------|
| `date` | The calendar day |
| `visits_count` | Total visits |
| `unique_visits_count` | Unique visitors (by hashed IP) |
| `device_stats` | JSON — visit counts by device type |
| `browser_stats` | JSON — visit counts by browser |
| `os_stats` | JSON — visit counts by operating system |
| `country_stats` | JSON — visit counts by country |
| `city_stats` | JSON — visit counts by city |
| `referer_stats` | JSON — visit counts by referer domain |
| `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 |
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-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.
---
## Configuration Reference (.env)
@@ -156,12 +356,49 @@ You can also pre-configure all parameters via your `.env` file:
| `SHORT_URL_PREFIX` | `route_prefix` | `'s'` | URL prefix for short URL redirects. |
| `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` | `database_path('geoip/GeoLite2-Country.mmdb')` | Path to local MaxMind db. |
| `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_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). |
| `SHORT_URL_COUNTER_BUFFERING` | `counter_buffering.enabled` | `false` | Buffer click counts in cache (flushed via console command). |
| `SHORT_URL_TRUST_CDN_HEADERS` | `trust_cdn_headers` | `false` | Trust proxy/CDN headers to extract real client IP and country. |
| `SHORT_URL_PRUNING_ENABLED` | `pruning.enabled` | `true` | Enable daily aggregation and log pruning. |
| `SHORT_URL_PRUNING_DAYS` | `pruning.retention_days` | `90` | Number of days to retain raw visit logs. |
| `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. |
---
## Artisan Commands
| Command | Description |
|---------|-------------|
| `short-url:sync-counters` | Flushes buffered visit counts from cache to the database. Schedule every minute when counter buffering is enabled. |
| `short-url:aggregate-and-prune` | Aggregates previous days' raw visits into `short_url_daily_stats` and prunes raw records older than the configured retention period. Schedule daily (e.g. at `02:00`). |
### Automatic Scheduler Registration (v1.3.0)
As of version `1.3.0`, **you no longer need to manually copy scheduled commands into your host application code!** The plugin automatically registers the required tasks inside its ServiceProvider booted phase, dynamically respecting your Settings GUI toggles:
- **`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).
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
// routes/console.php
use Illuminate\Support\Facades\Schedule;
// Flush buffered click counts every minute (only if counter buffering is enabled)
Schedule::command('short-url:sync-counters')->everyMinute();
// Aggregate stats and prune old raw visits nightly
Schedule::command('short-url:aggregate-and-prune')->dailyAt('02:00');
```
---
@@ -212,8 +449,9 @@ echo $shortUrl->getShortUrl(); // https://yourdomain.com/s/promo2026
* `isActive(): bool` — Checks if the short URL is enabled and within its activation/expiration timestamps.
* `isExpired(): bool` — Checks if the URL has passed its expiration date.
* `getShortUrl(): string` — Resolves the complete URL string.
* `incrementVisits(bool $isUnique = false): void` — Atomically increments visit counts.
* `getCachedStats(): array` — Returns cached key metrics for dashboard charts.
* `incrementVisits(bool $isUnique = false): void` — Atomically increments visit counts (with Redis buffering or queue fallback).
* `getCachedStats(): array` — Returns cached key metrics for dashboard charts. Automatically merges daily aggregated stats with today's raw visits.
* `resolveDestinationUrl(Request $request): string` — Evaluates active targeting rules (device, geo, rotation) and returns the correct destination URL for the current visitor.
### Injecting the Service
For standard creations, you can inject `ShortUrlService`:
@@ -251,6 +489,38 @@ Visits from scrapers, search bots, and web crawlers are automatically ignored to
---
## Database Compatibility
All migrations are compatible with **SQLite**, **MySQL**, and **PostgreSQL**:
- No MySQL-specific `ENUM` types — `device_type` uses `VARCHAR(20)` validated at PHP level.
- No `->after()` column ordering hints — fully cross-database.
- JSON columns work natively on MySQL 5.7.8+ and are stored as TEXT on SQLite.
---
## Changelog
### v1.3.0 (Latest)
- **Automatic Scheduler Registration** — Zero-configuration task registration within the ServiceProvider booted phase (dynamically honors Settings toggles).
- **Interactive Settings Validators** — Adds real-time "Test connection" verify action for GA4 Measurement Protocol and "Verify file" action for MaxMind database paths.
- **Robust Table Row Copy Action** — High-reliability, conflict-free click-to-copy in table rows with built-in fallback helper for non-HTTPS (secure context) browser environments.
- **Filament v5 Notification API** — Seamless integration of the new `FilamentNotification` class API inside client-side JS.
- **Asset Compilation Guide** — Explains Tailwind CLI CSS compilation and Filament asset publishing workflows for package extensions.
### v1.2.0
- **Password-protected links** — Session-based unlock flow with a styled prompt page.
- **Redirect warning pages** — Interstitial security screen before external redirects.
- **Smart targeting** — Device-based, Country/Geo-based, and A/B weighted rotation rules per link.
- **Rate limiting** — Configurable per-IP redirect throttling with 429 responses.
- **Daily stats aggregation** — Nightly `short-url:aggregate-and-prune` command for scalable log management.
- **Counter buffering fallback** — `IncrementVisitJob` as queue-based fallback when Redis is unavailable.
- **Database compatibility** — Replaced `ENUM` with `VARCHAR`, removed MySQL-only `->after()` calls.
- **Extended Settings GUI** — New "Performance & Security" tab for aggregation and rate limiting configuration.
- **Polish translations** — Full `pl` locale support for all new features.
---
## License
MIT

View File

@@ -46,7 +46,7 @@ return [
'driver' => env('SHORT_URL_GEO_IP_DRIVER', 'headers'), // 'headers', 'maxmind', 'ip-api'
'timeout' => 3, // seconds to wait for geo-ip response
'maxmind' => [
'database_path' => env('SHORT_URL_MAXMIND_DB', database_path('geoip/GeoLite2-Country.mmdb')),
'database_path' => env('SHORT_URL_MAXMIND_DB', storage_path('geoip/GeoLite2-Country.mmdb')),
],
'stats_cache_ttl' => env('SHORT_URL_STATS_CACHE_TTL', 300), // 5 minutes caching for stats page calculations
],
@@ -132,4 +132,69 @@ return [
'gradient_type' => 'linear',
],
/*
|--------------------------------------------------------------------------
| Counter Buffering (Write-back Caching)
|--------------------------------------------------------------------------
| For high-traffic applications, direct database writes on every visit
| can cause locks. Enable this to buffer total/unique visit counts in
| cache, then sync them to the database periodically via a scheduled task.
*/
'counter_buffering' => [
'enabled' => (bool) env('SHORT_URL_COUNTER_BUFFERING', false),
'cache_key_prefix' => 'filament-short-url:buffer:',
],
/*
|--------------------------------------------------------------------------
| Trust CDN/Proxy Headers
|--------------------------------------------------------------------------
| If your application sits behind a CDN (like Cloudflare, AWS CloudFront)
| or a reverse proxy, set this to true to parse real visitor IP addresses
| and country codes from proxy headers. Only enable this if you are
| actually behind a proxy to prevent client IP spoofing!
|
*/
'trust_cdn_headers' => (bool) env('SHORT_URL_TRUST_CDN_HEADERS', false),
/*
|--------------------------------------------------------------------------
| Data Pruning & Aggregation
|--------------------------------------------------------------------------
| To keep the database clean and fast, raw visit logs can be aggregated
| into daily statistics and pruned after a retention period.
|
*/
'pruning' => [
'enabled' => env('SHORT_URL_PRUNING_ENABLED', true),
'retention_days' => env('SHORT_URL_PRUNING_DAYS', 90),
],
/*
|--------------------------------------------------------------------------
| Rate Limiting
|--------------------------------------------------------------------------
| Protect redirect routes from bot abuse and brute force by enabling
| rate limiting.
|
*/
'rate_limiting' => [
'enabled' => env('SHORT_URL_RATE_LIMITING', false),
'max_attempts' => env('SHORT_URL_RATE_LIMIT_MAX', 60),
'decay_seconds' => env('SHORT_URL_RATE_LIMIT_DECAY', 60),
],
/*
|--------------------------------------------------------------------------
| Redirect Route Middleware
|--------------------------------------------------------------------------
| The middleware list applied to the short URL redirect route.
| By default, standard web middleware and rate limiting are applied.
|
*/
'middleware' => [
'web',
'throttle:120,1',
],
];

View File

@@ -27,8 +27,8 @@ return new class extends Migration
$table->string('operating_system', 100)->nullable();
$table->string('operating_system_version', 50)->nullable();
// Device classification
$table->enum('device_type', ['desktop', 'mobile', 'tablet', 'robot'])->nullable()->index();
// Device classification ('desktop', 'mobile', 'tablet', 'robot') — validated at PHP level
$table->string('device_type', 20)->nullable()->index();
// Traffic source
$table->text('referer_url')->nullable();

View File

@@ -0,0 +1,36 @@
<?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->string('city', 100)->nullable();
$table->string('referer_host', 255)->nullable()->index();
$table->string('utm_source', 100)->nullable()->index();
$table->string('utm_medium', 100)->nullable()->index();
$table->string('utm_campaign', 100)->nullable()->index();
$table->string('utm_term', 100)->nullable();
$table->string('utm_content', 100)->nullable();
});
}
public function down(): void
{
Schema::table('short_url_visits', function (Blueprint $table): void {
$table->dropColumn([
'city',
'referer_host',
'utm_source',
'utm_medium',
'utm_campaign',
'utm_term',
'utm_content',
]);
});
}
};

View File

@@ -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
{
public function up(): void
{
Schema::table('short_urls', function (Blueprint $table): void {
$table->string('password', 255)->nullable();
$table->boolean('show_warning_page')->default(false);
$table->json('targeting_rules')->nullable();
});
}
public function down(): void
{
Schema::table('short_urls', function (Blueprint $table): void {
$table->dropColumn([
'password',
'show_warning_page',
'targeting_rules',
]);
});
}
};

View File

@@ -0,0 +1,38 @@
<?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_daily_stats', function (Blueprint $table): void {
$table->id();
$table->foreignId('short_url_id')
->constrained('short_urls')
->cascadeOnDelete();
$table->date('date');
$table->integer('visits_count')->default(0);
$table->integer('unique_visits_count')->default(0);
$table->json('device_stats')->nullable();
$table->json('browser_stats')->nullable();
$table->json('os_stats')->nullable();
$table->json('country_stats')->nullable();
$table->json('city_stats')->nullable();
$table->json('referer_stats')->nullable();
$table->json('utm_source_stats')->nullable();
$table->json('utm_medium_stats')->nullable();
$table->json('utm_campaign_stats')->nullable();
$table->timestamps();
$table->unique(['short_url_id', 'date']);
});
}
public function down(): void
{
Schema::dropIfExists('short_url_daily_stats');
}
};

17
resources/css/plugin.css Normal file
View File

@@ -0,0 +1,17 @@
@import "tailwindcss";
@source "../../src/**/*.php";
@source "../../resources/views/**/*.blade.php";
.short-url-card {
position: relative !important;
}
.short-url-card .fi-ta-actions {
position: absolute !important;
top: 1.25rem !important;
right: 1.25rem !important;
z-index: 10 !important;
margin: 0 !important;
padding: 0 !important;
}

2
resources/dist/filament-short-url.css vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1,7 +1,7 @@
<?php
return [
'navigation_label' => 'Short URLs',
'navigation_label' => 'Short Links',
'navigation_group' => 'Tools',
'resource_title' => 'Short URL',
@@ -106,6 +106,13 @@ return [
'action_stats' => 'Statistics',
'action_copy' => 'Copy URL',
'action_qr' => 'QR Code',
'action_edit' => 'Edit URL',
'action_delete' => 'Delete URL',
'action_share' => 'Share Link',
'share_title' => 'Share Link',
'share_description' => 'Share this short link via:',
'share_copy' => 'Copy',
'share_copied' => 'Short link copied to clipboard!',
// Stats Page
'stats_title' => 'Statistics',
@@ -175,9 +182,120 @@ return [
'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_verify' => 'Verify file',
'settings_maxmind_verify_ok' => '✅ File found & readable',
'settings_maxmind_verify_fail' => '❌ File not found or not readable',
'settings_maxmind_verify_empty' => 'Please enter a path first.',
'settings_ga4_api_secret' => 'Measurement Protocol API Secret',
'settings_ga4_api_secret_helper' => 'Generate this in GA4 → Admin → Data Streams → your stream → Measurement Protocol API secrets.',
'settings_ga4_firebase_app_id' => 'Firebase App ID (optional)',
'settings_ga4_firebase_app_id_helper' => 'Required only if tracking a Firebase / app stream. Leave empty for standard web GA4 streams.',
'settings_ga4_verify' => 'Test connection',
'settings_ga4_verify_ok' => '✅ API Secret is valid — GA4 connection successful',
'settings_ga4_verify_fail' => '❌ Invalid API Secret — GA4 rejected the request',
'settings_ga4_verify_empty' => 'Please enter an API Secret first.',
'settings_ga4_verify_error' => '⚠️ Connection error — could not reach GA4',
'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.',
// 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!',
// Stats View & Export Localization
'stats_filter_visited_from' => 'Visited From',
'stats_filter_visited_until' => 'Visited Until',
'stats_action_export' => 'Export CSV',
'stats_csv_time' => 'Time',
'stats_csv_ip' => 'IP Address',
'stats_csv_country' => 'Country',
'stats_csv_device' => 'Device',
'stats_csv_browser' => 'Browser',
'stats_csv_os' => 'OS',
'stats_csv_referer' => 'Referer',
// Bulk Actions Localization
'action_enable_selected' => 'Enable selected',
'action_disable_selected' => 'Disable selected',
// Form Sections Localization
'form_section_options' => 'Options',
'form_section_notes' => 'Internal Notes',
'form_section_tracking' => 'Visit Tracking',
'form_section_tracked_fields' => 'Tracked Fields',
'form_section_analytics' => 'Third-Party Analytics',
// New Dashboard Analytics Keys
'stats_card_top_source' => 'Top UTM Source',
'stats_card_top_country' => 'Top Country',
'stats_filter_date_range' => 'Date Range',
'stats_preset_24_hours' => 'Last 24 Hours',
'stats_preset_7_days' => 'Last 7 Days',
'stats_preset_30_days' => 'Last 30 Days',
'stats_preset_90_days' => 'Last 90 Days',
'stats_preset_custom' => 'Custom Range',
'stats_breakdown_cities' => 'Top Cities',
'stats_no_city_data' => 'No city data.',
'stats_breakdown_utm_source' => 'Campaign Sources',
'stats_breakdown_utm_medium' => 'Campaign Mediums',
'stats_breakdown_utm_campaign' => 'Campaign Names',
'stats_no_utm_data' => 'No UTM data recorded.',
// New Targeting & Security Tab and fields
'tab_targeting' => 'Targeting & Security',
'security_section_title' => 'Security Controls',
'password' => 'Access Password',
'password_helper' => 'Require visitors to enter a password before being redirected.',
'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',
'targeting_type_none' => 'None (Direct Redirect)',
'targeting_type_device' => 'Device-Based Redirects',
'targeting_type_country' => 'Country-Based (Geo-IP) Redirects',
'targeting_type_rotation' => 'A/B Split Rotation',
'device_targeting_rules' => 'Device Rules',
'country_targeting_rules' => 'Country 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',
'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 Settings Page Fields
'settings_tab_advanced' => 'Performance & Security',
'settings_section_aggregation' => 'High-Traffic Log Management',
'settings_retention_days' => 'Raw Log Retention Period',
'settings_retention_days_helper' => 'Select the duration for which you want to keep detailed raw click logs before they are deleted.',
'settings_aggregation_enabled' => 'Enable Automatic Daily Pruning & Aggregation',
'settings_aggregation_enabled_helper' => 'When enabled, the plugin automatically registers a daily task in the scheduler (at 02:00) to aggregate visits into daily statistics and delete raw logs older than the chosen retention period.',
'retention_30_days' => '30 Days',
'retention_60_days' => '60 Days',
'retention_90_days' => '90 Days',
'retention_180_days' => '180 Days (Half Year)',
'retention_365_days' => '365 Days (1 Year)',
'retention_730_days' => '2 Years',
'settings_section_rate_limiting' => 'Rate Limiting / Bot Protection',
'settings_rate_limiting_enabled' => 'Enable Limit Protection',
'settings_rate_limiting_enabled_helper' => 'Limit the rate of redirects per client IP address.',
'settings_rate_limiting_max_attempts' => 'Max Redirects Allowed',
'settings_rate_limiting_max_attempts_helper' => 'Maximum allowed redirection requests within the decay window.',
'settings_rate_limiting_decay_seconds' => 'Decay Window (Seconds)',
'settings_rate_limiting_decay_seconds_helper' => 'Time period for the rate limiter.',
// Views
'password_title' => 'Password Required',
'password_description' => 'This link is password-protected. Please enter the correct password to continue.',
'password_placeholder' => 'Enter password',
'password_btn_unlock' => 'Unlock & Redirect',
'password_error' => 'Incorrect password.',
'warning_title' => 'Security Redirect Warning',
'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',
];

View File

@@ -106,6 +106,13 @@ return [
'action_stats' => 'Statystyki',
'action_copy' => 'Kopiuj link',
'action_qr' => 'Kod QR',
'action_edit' => 'Edytuj URL',
'action_delete' => 'Usuń URL',
'action_share' => 'Udostępnij link',
'share_title' => 'Udostępnij Link',
'share_description' => 'Udostępnij ten krótki link przez:',
'share_copy' => 'Skopiuj',
'share_copied' => 'Krótki link skopiowany do schowka!',
// Stats Page
'stats_title' => 'Statystyki',
@@ -176,9 +183,120 @@ return [
'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_verify' => 'Weryfikuj plik',
'settings_maxmind_verify_ok' => '✅ Plik znaleziony i dostępny',
'settings_maxmind_verify_fail' => '❌ Plik nie istnieje lub jest niedostępny',
'settings_maxmind_verify_empty' => 'Najpierw podaj ścieżkę do pliku.',
'settings_ga4_api_secret' => 'Klucz API Measurement Protocol',
'settings_ga4_api_secret_helper' => 'Wygeneruj w GA4 → Admin → Strumienie danych → twój strumień → Klucze API Measurement Protocol.',
'settings_ga4_firebase_app_id' => 'Firebase App ID (opcjonalne)',
'settings_ga4_firebase_app_id_helper' => 'Wymagane tylko przy śledzeniu strumienia Firebase/aplikacji. Zostaw puste dla standardowych strumieni GA4.',
'settings_ga4_verify' => 'Testuj połączenie',
'settings_ga4_verify_ok' => '✅ Klucz API jest prawidłowy — połączenie z GA4 udane',
'settings_ga4_verify_fail' => '❌ Nieprawidłowy klucz API — GA4 odrzuciło żądanie',
'settings_ga4_verify_empty' => 'Najpierw wprowadź klucz API.',
'settings_ga4_verify_error' => '⚠️ Błąd połączenia — nie można nawiązać kontaktu z GA4',
'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.',
// 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!',
// Stats View & Export Localization
'stats_filter_visited_from' => 'Odwiedzono od',
'stats_filter_visited_until' => 'Odwiedzono do',
'stats_action_export' => 'Eksportuj CSV',
'stats_csv_time' => 'Czas',
'stats_csv_ip' => 'Adres IP',
'stats_csv_country' => 'Kraj',
'stats_csv_device' => 'Urządzenie',
'stats_csv_browser' => 'Przeglądarka',
'stats_csv_os' => 'System operacyjny',
'stats_csv_referer' => 'Źródło (Referer)',
// Bulk Actions Localization
'action_enable_selected' => 'Włącz zaznaczone',
'action_disable_selected' => 'Wyłącz zaznaczone',
// Form Sections Localization
'form_section_options' => 'Opcje',
'form_section_notes' => 'Wewnętrzne notatki',
'form_section_tracking' => 'Śledzenie wizyt',
'form_section_tracked_fields' => 'Śledzone pola',
'form_section_analytics' => 'Zewnętrzne systemy analityczne',
// New Dashboard Analytics Keys
'stats_card_top_source' => 'Główne źródło UTM',
'stats_card_top_country' => 'Główny kraj',
'stats_filter_date_range' => 'Zakres dat',
'stats_preset_24_hours' => 'Ostatnie 24 godziny',
'stats_preset_7_days' => 'Ostatnie 7 dni',
'stats_preset_30_days' => 'Ostatnie 30 dni',
'stats_preset_90_days' => 'Ostatnie 90 dni',
'stats_preset_custom' => 'Własny zakres',
'stats_breakdown_cities' => 'Najpopularniejsze miasta',
'stats_no_city_data' => 'Brak danych o miastach.',
'stats_breakdown_utm_source' => 'Źródła kampanii',
'stats_breakdown_utm_medium' => 'Media kampanii',
'stats_breakdown_utm_campaign' => 'Nazwy kampanii',
'stats_no_utm_data' => 'Brak danych o parametrach UTM.',
// New Targeting & Security Tab and fields
'tab_targeting' => 'Targetowanie i bezpieczeństwo',
'security_section_title' => 'Kontrola bezpieczeństwa',
'password' => 'Hasło dostępu',
'password_helper' => 'Wymagaj od odwiedzających wprowadzenia hasła przed przekierowaniem.',
'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',
'targeting_type_none' => 'Brak (Bezpośrednie przekierowanie)',
'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)',
'device_targeting_rules' => 'Reguły urządzeń',
'country_targeting_rules' => 'Reguły krajó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',
'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 Settings Page Fields
'settings_tab_advanced' => 'Wydajność i bezpieczeństwo',
'settings_section_aggregation' => 'Zarządzanie logami o dużym natężeniu ruchu',
'settings_retention_days' => 'Czas retencji surowych logów',
'settings_retention_days_helper' => 'Wybierz, przez jaki okres czasu chcesz przechowywać szczegółowe logi pojedynczych wizyt przed ich usunięciem.',
'settings_aggregation_enabled' => 'Włącz automatyczną agregację i czyszczenie logów',
'settings_aggregation_enabled_helper' => 'Gdy włączone, wtyczka automatycznie rejestruje zadanie w harmonogramie (codziennie o 02:00), aby zsumować wizyty do statystyk dziennych i usunąć surowe logi starsze niż wybrany okres retencji.',
'retention_30_days' => '30 dni',
'retention_60_days' => '60 dni',
'retention_90_days' => '90 dni',
'retention_180_days' => '180 dni (pół roku)',
'retention_365_days' => '365 dni (rok)',
'retention_730_days' => '2 lata',
'settings_section_rate_limiting' => 'Ograniczanie częstotliwości żądań / Ochrona przed botami',
'settings_rate_limiting_enabled' => 'Włącz ochronę limitów',
'settings_rate_limiting_enabled_helper' => 'Ograniczaj liczbę przekierowań na adres IP klienta.',
'settings_rate_limiting_max_attempts' => 'Maksymalna dopuszczalna liczba przekierowań',
'settings_rate_limiting_max_attempts_helper' => 'Maksymalna liczba żądań w oknie wygasania.',
'settings_rate_limiting_decay_seconds' => 'Okno wygasania (sekundy)',
'settings_rate_limiting_decay_seconds_helper' => 'Przedział czasowy dla ograniczenia liczby żądań.',
// Views
'password_title' => 'Wymagane hasło',
'password_description' => 'Ten link jest chroniony hasłem. Wprowadź poprawne hasło, aby kontynuować.',
'password_placeholder' => 'Wpisz hasło',
'password_btn_unlock' => 'Odblokuj i przekieruj',
'password_error' => 'Nieprawidłowe hasło.',
'warning_title' => 'Ostrzeżenie o przekierowaniu',
'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óć',
];

View File

@@ -0,0 +1,101 @@
<!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.password_title') ?? 'Password Protected' }}</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
$logoPath = function_exists('setting') ? setting('logo_path') : null;
$logoUrl = $logoPath ? \Illuminate\Support\Facades\Storage::disk('public')->url($logoPath) : null;
$siteName = config('app.name', 'Laravel');
@endphp
{{-- Main Sign-in 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">
@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
<p class="text-xl font-medium text-neutral-900 dark:text-white mt-2">
{{ __('filament-short-url::default.password_title') ?? 'Password Protected' }}
</p>
<p class="text-sm text-neutral-400 dark:text-neutral-500 mt-1">
{{ __('filament-short-url::default.password_description') ?? 'This link is password-protected. Please enter the correct password to continue.' }}
</p>
</div>
<form method="POST" class="w-full flex flex-col gap-4">
@csrf
{{-- Password input styled exactly like Custom Login --}}
<div class="flex flex-col gap-1.5 w-full">
<div class="relative flex flex-col justify-center px-3 py-2 w-full rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 focus-within:ring-2 focus-within:ring-neutral-900 dark:focus-within:ring-white transition duration-200">
<label for="password" class="text-[10px] font-bold text-neutral-400 dark:text-neutral-500 uppercase tracking-wide select-none">
{{ __('filament-short-url::default.password_placeholder') ?? 'Password' }}
</label>
<input
type="password"
id="password"
name="password"
placeholder="••••••••"
required
autofocus
class="w-full bg-transparent text-sm text-neutral-900 dark:text-white placeholder-neutral-300 dark:placeholder-neutral-700 focus:outline-none py-0.5 mt-0.5"
/>
</div>
@if ($errors->has('password'))
<span class="text-xs text-red-500 px-1 mt-0.5">{{ $errors->first('password') }}</span>
@endif
</div>
{{-- Submit button --}}
<button
type="submit"
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 shadow-sm flex justify-center items-center gap-2"
>
<span>{{ __('filament-short-url::default.password_btn_unlock') ?? 'Unlock & Redirect' }}</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="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
</button>
</form>
</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>

View File

@@ -108,6 +108,15 @@
background-color:#f3f4f6;
}
@keyframes qr-spin { to { transform: rotate(360deg); } }
.qr-margin-top {
margin-top: 12px !important;
}
.qr-space-y > div {
margin-top: 12px !important;
}
.qr-space-y > div:first-child {
margin-top: 0 !important;
}
</style>
{{-- Preload QR library as soon as the tab renders --}}
@@ -245,7 +254,7 @@
{{-- Size & Margin --}}
<div class="qr-section">
<div class="grid grid-cols-2 gap-3">
<div class="grid grid-cols-2 gap-3" style="display:grid;grid-template-columns:repeat(2, minmax(0, 1fr));gap:12px">
<div>
<span class="qr-label">{{ __('filament-short-url::default.qr_label_size') }}</span>
<input type="number" x-model.number="size" min="100" max="1000" step="10" class="qr-input-num" />
@@ -279,7 +288,7 @@
<span class="qr-label">{{ __('filament-short-url::default.qr_label_foreground_color') }}</span>
{{-- Mode radio --}}
<div class="flex gap-2">
<div class="flex gap-2" style="display:flex;gap:8px">
<button type="button"
:class="colorMode === 'solid' ? 'active' : ''"
class="qr-radio-option flex-1 justify-center text-center"
@@ -297,9 +306,9 @@
</div>
{{-- Single color picker --}}
<div x-show="colorMode === 'solid'" x-transition style="display:block" class="mt-3">
<div x-show="colorMode === 'solid'" x-transition style="display:block" class="mt-3 qr-margin-top">
<span class="qr-label">{{ __('filament-short-url::default.qr_label_color') }}</span>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2" style="display:flex;align-items:center;gap:8px">
<div class="qr-color-swatch qr-color-picker" :style="'background:'+fgColor">
<input type="color" x-model="fgColor" />
</div>
@@ -311,11 +320,11 @@
</div>
{{-- Gradient pickers --}}
<div x-show="colorMode === 'gradient'" x-transition style="display:none" class="mt-3 space-y-3">
<div class="grid grid-cols-2 gap-2">
<div x-show="colorMode === 'gradient'" x-transition style="display:none" class="mt-3 space-y-3 qr-space-y qr-margin-top">
<div class="grid grid-cols-2 gap-2" style="display:grid;grid-template-columns:repeat(2, minmax(0, 1fr));gap:8px">
<div>
<span class="qr-label">{{ __('filament-short-url::default.qr_label_from') }}</span>
<div class="flex items-center gap-1.5">
<div class="flex items-center gap-1.5" style="display:flex;align-items:center;gap:6px">
<div class="qr-color-swatch qr-color-picker" :style="'background:'+gradientFrom">
<input type="color" x-model="gradientFrom" />
</div>
@@ -327,7 +336,7 @@
</div>
<div>
<span class="qr-label">{{ __('filament-short-url::default.qr_label_to') }}</span>
<div class="flex items-center gap-1.5">
<div class="flex items-center gap-1.5" style="display:flex;align-items:center;gap:6px">
<div class="qr-color-swatch qr-color-picker" :style="'background:'+gradientTo">
<input type="color" x-model="gradientTo" />
</div>
@@ -351,7 +360,7 @@
{{-- Background --}}
<div class="qr-section">
<span class="qr-label">{{ __('filament-short-url::default.qr_label_background') }}</span>
<div class="flex items-center justify-between">
<div class="flex items-center justify-between" style="display:flex;align-items:center;justify-content:space-between">
<span style="font-size:13px;font-weight:500;color:#374151" class="dark:text-gray-300">{{ __('filament-short-url::default.qr_label_transparent') }}</span>
<button type="button"
:class="bgTransparent ? 'on' : 'off'"
@@ -360,9 +369,9 @@
<span class="qr-toggle-thumb"></span>
</button>
</div>
<div x-show="!bgTransparent" x-transition style="display:block" class="mt-3">
<div x-show="!bgTransparent" x-transition style="display:block;margin-top:12px" class="mt-3 qr-margin-top">
<span class="qr-label">{{ __('filament-short-url::default.qr_label_color') }}</span>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2" style="display:flex;align-items:center;gap:8px">
<div class="qr-color-swatch qr-color-picker" :style="'background:'+bgColor">
<input type="color" x-model="bgColor" />
</div>
@@ -372,8 +381,8 @@
maxlength="7" placeholder="#ffffff" />
</div>
</div>
<div x-show="bgTransparent" x-transition style="display:none" class="mt-3">
<div class="qr-checker flex h-9 w-full items-center justify-center rounded-lg border border-dashed border-gray-300" style="font-size:11px;color:#9ca3af;font-weight:600">
<div x-show="bgTransparent" x-transition style="display:none;margin-top:12px" class="mt-3 qr-margin-top">
<div class="qr-checker flex h-9 w-full items-center justify-center rounded-lg border border-dashed border-gray-300" style="display:flex;align-items:center;justify-content:center;font-size:11px;color:#9ca3af;font-weight:600">
TRANSPARENT
</div>
</div>
@@ -381,7 +390,7 @@
{{-- Eye Config --}}
<div class="qr-section">
<div class="flex items-center justify-between">
<div class="flex items-center justify-between" style="display:flex;align-items:center;justify-content:space-between">
<span class="qr-label" style="margin-bottom:0">{{ __('filament-short-url::default.qr_label_eye_config') }}</span>
<button type="button"
:class="eyeConfigEnabled ? 'on' : 'off'"
@@ -390,7 +399,7 @@
<span class="qr-toggle-thumb"></span>
</button>
</div>
<div x-show="eyeConfigEnabled" x-transition style="display:none" class="mt-3 space-y-3">
<div x-show="eyeConfigEnabled" x-transition style="display:none" class="mt-3 space-y-3 qr-space-y qr-margin-top">
<div>
<span class="qr-label">{{ __('filament-short-url::default.qr_label_eye_square_style') }}</span>
<select x-model="eyeSquareStyle" class="qr-select">
@@ -408,7 +417,7 @@
</div>
<div>
<span class="qr-label">{{ __('filament-short-url::default.qr_label_eye_color') }}</span>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2" style="display:flex;align-items:center;gap:8px">
<div class="qr-color-swatch qr-color-picker" :style="'background:'+eyeColor">
<input type="color" x-model="eyeColor" />
</div>
@@ -427,9 +436,9 @@
<div style="display:flex;flex-direction:column;min-width:0;height:100%">
{{-- Top bar --}}
<div class="mb-4 flex items-center justify-between">
<div class="mb-4 flex items-center justify-between" style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
<span style="font-size:13px;font-weight:600;color:#9ca3af">{{ __('filament-short-url::default.qr_label_preview') }}</span>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2" style="display:flex;align-items:center;gap:8px">
<button type="button" x-on:click="download('png')" class="qr-dl-btn">
<svg style="width:13px;height:13px" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"/>

View File

@@ -16,18 +16,48 @@
</x-filament::tabs.item>
</x-filament::tabs>
<div class="space-y-6">
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlStatsOverview::class, ['record' => $record])
@php
$dateFrom = $this->filterData['date_from'] ?? null;
$dateTo = $this->filterData['date_to'] ?? null;
$isCustom = ($this->filterData['preset'] ?? '') === 'custom';
@endphp
<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])
</div>
<div>
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsRightBreakdown::class, ['record' => $record])
<div class="space-y-6">
<div class="flex justify-end items-center">
<div class="w-full {{ $isCustom ? 'max-w-xl' : 'max-w-[200px]' }}">
<form>
{{ $this->form }}
</form>
</div>
</div>
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsBottomBreakdown::class, ['record' => $record])
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlStatsOverview::class, [
'record' => $record,
'dateFrom' => $dateFrom,
'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\ShortUrlVisitsBottomBreakdown::class, [
'record' => $record,
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
], key('stats-bottom-breakdown-' . $dateFrom . '-' . $dateTo))
</div>
</x-filament-panels::page>

View File

@@ -0,0 +1,113 @@
<!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.warning_title') ?? 'Redirect Warning' }}</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
$logoPath = function_exists('setting') ? setting('logo_path') : null;
$logoUrl = $logoPath ? \Illuminate\Support\Facades\Storage::disk('public')->url($logoPath) : null;
$siteName = config('app.name', 'Laravel');
@endphp
{{-- Main Warning 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">
@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
<p class="text-xl font-medium text-neutral-900 dark:text-white mt-2">
{{ __('filament-short-url::default.warning_title') ?? 'Security Redirect Warning' }}
</p>
<p class="text-sm text-neutral-400 dark:text-neutral-500 mt-1">
{{ __('filament-short-url::default.warning_description') ?? 'You are leaving this secure portal and being redirected to an external target link. Please ensure you trust the address below:' }}
</p>
</div>
<div class="w-full flex flex-col gap-4">
{{-- Destination URL Container styled exactly like Custom Login input box --}}
<div class="flex flex-col gap-1.5 w-full">
<div class="relative flex items-center justify-between px-3 py-3 w-full rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 transition duration-200">
<div class="flex-grow flex flex-col justify-center min-w-0 pr-4">
<span class="text-[10px] font-bold text-neutral-400 dark:text-neutral-500 uppercase tracking-wide select-none">
Destination URL
</span>
<p id="dest-url-text" class="break-all text-xs font-mono font-semibold text-rose-600 dark:text-rose-400 mt-1 select-all">
{{ $destinationUrl }}
</p>
</div>
<!-- Quick Copy Button -->
<button onclick="
const text = document.getElementById('dest-url-text').innerText;
navigator.clipboard.writeText(text);
const btn = this;
const origHtml = btn.innerHTML;
btn.innerHTML = `<svg class='h-4 w-4 text-emerald-500' fill='none' viewBox='0 0 24 24' stroke='currentColor' stroke-width='3.5'><path stroke-linecap='round' stroke-linejoin='round' d='M5 13l4 4L19 7' /></svg>`;
setTimeout(() => { btn.innerHTML = origHtml; }, 2000);
" class="flex-shrink-0 p-2 rounded-lg border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-950 text-neutral-400 hover:text-neutral-600 dark:text-neutral-500 dark:hover:text-neutral-300 transition-colors focus:outline-none" title="Copy URL">
<svg class="h-4 w-4 pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</button>
</div>
</div>
{{-- Action Buttons --}}
<div class="flex flex-col gap-3 w-full mt-2">
<!-- Continue Button (Primary Accent) -->
<a href="{{ request()->fullUrlWithQuery(['confirmed' => 1]) }}"
class="w-full py-3.5 rounded-xl bg-rose-600 hover:bg-rose-500 dark:bg-rose-500 dark:hover:bg-rose-400 text-white font-semibold text-sm transition duration-200 shadow-sm flex justify-center items-center gap-2 text-center">
<span>{{ __('filament-short-url::default.warning_btn_continue') ?? 'Continue to Destination' }}</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="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
</a>
<!-- Go Back Button (Secondary) -->
<button onclick="window.history.back()"
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-900 dark:text-white font-semibold text-sm transition duration-200 flex justify-center items-center gap-2 shadow-sm">
{{ __('filament-short-url::default.warning_btn_back') ?? 'Go Back' }}
</button>
</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 }} Inc. All rights reserved.</span>
</div>
</body>
</html>

View File

@@ -27,118 +27,231 @@
@endphp
<x-filament-widgets::widget>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div class="space-y-6">
{{-- Row 1: Devices, Browsers, Operating Systems --}}
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
{{-- Devices --}}
<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-emerald-50 text-emerald-500 dark:bg-emerald-950/50 dark:text-emerald-400">
<x-filament::icon icon="heroicon-o-device-phone-mobile" class="h-5 w-5" />
{{-- Devices --}}
<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-emerald-50 text-emerald-500 dark:bg-emerald-950/50 dark:text-emerald-400">
<x-filament::icon icon="heroicon-o-device-phone-mobile" 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_devices') }}</h3>
</div>
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_devices') }}</h3>
</div>
<div class="space-y-4">
@forelse ($visitsByDevice as $device => $count)
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
<div class="flex items-center gap-3">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gray-50 text-gray-500 dark:bg-gray-800 dark:text-gray-400">
<x-filament::icon :icon="$deviceIcons[$device] ?? 'heroicon-m-question-mark-circle'" class="h-4 w-4" />
<div class="space-y-4">
@forelse ($visitsByDevice as $device => $count)
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
<div class="flex items-center gap-3">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gray-50 text-gray-500 dark:bg-gray-800 dark:text-gray-400">
<x-filament::icon :icon="$deviceIcons[$device] ?? 'heroicon-m-question-mark-circle'" class="h-4 w-4" />
</div>
<div class="flex-1 min-w-0">
<div class="flex justify-between text-sm">
<span class="capitalize font-medium text-gray-700 dark:text-gray-300 truncate">{{ $device }}</span>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">{{ $pct }}%</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-emerald-500 transition-all duration-500" style="width: {{ $pct }}%"></div>
</div>
</div>
</div>
<div class="flex-1 min-w-0">
<div class="flex justify-between text-sm">
<span class="capitalize font-medium text-gray-700 dark:text-gray-300 truncate">{{ $device }}</span>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">{{ $pct }}%</span>
@empty
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_device_data') }}</p>
@endforelse
</div>
</div>
{{-- Browsers --}}
<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-amber-50 text-amber-500 dark:bg-amber-950/50 dark:text-amber-400">
<x-filament::icon icon="heroicon-o-globe-asia-australia" 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_browsers') }}</h3>
</div>
<div class="space-y-3.5">
@forelse ($visitsByBrowser as $browser => $count)
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2 min-w-0">
<x-filament::icon :icon="$browserIcons[$browser] ?? 'heroicon-m-globe-alt'" class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" />
<span class="font-medium text-gray-700 dark:text-gray-300 truncate">{{ $browser }}</span>
</div>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0 ml-2">
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
</span>
</div>
@empty
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_browser_data') }}</p>
@endforelse
</div>
</div>
{{-- Operating Systems --}}
<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-blue-50 text-blue-500 dark:bg-blue-950/50 dark:text-blue-400">
<x-filament::icon icon="heroicon-o-computer-desktop" 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_os') }}</h3>
</div>
<div class="space-y-3.5">
@forelse ($visitsByOs as $os => $count)
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2 min-w-0">
<x-filament::icon :icon="$osIcons[$os] ?? 'heroicon-m-computer-desktop'" class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" />
<span class="font-medium text-gray-700 dark:text-gray-300 truncate">{{ $os }}</span>
</div>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0 ml-2">
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
</span>
</div>
@empty
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_os_data') }}</p>
@endforelse
</div>
</div>
</div>
{{-- Row 2: Referrers, Cities --}}
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
{{-- Referers --}}
<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-purple-50 text-purple-500 dark:bg-purple-950/50 dark:text-purple-400">
<x-filament::icon icon="heroicon-o-link" 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_referers') }}</h3>
</div>
<div class="space-y-3">
@forelse ($visitsByReferer as $referer => $count)
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
<div class="flex items-center justify-between text-sm gap-4">
<div class="flex items-center gap-2 min-w-0 flex-1">
<x-filament::icon icon="heroicon-m-link" class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" />
@if ($referer === 'Direct')
<span class="font-medium text-gray-500 dark:text-gray-400 truncate">{{ $referer }}</span>
@else
<a href="https://{{ $referer }}" target="_blank" rel="noopener"
class="truncate text-indigo-600 hover:underline dark:text-indigo-400 font-medium">
{{ $referer }}
</a>
@endif
</div>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0 ml-2">
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
</span>
</div>
@empty
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_referer_data') }}</p>
@endforelse
</div>
</div>
{{-- Cities --}}
<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-emerald-50 text-emerald-500 dark:bg-emerald-950/50 dark:text-emerald-400">
<x-filament::icon icon="heroicon-o-map-pin" 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_cities') }}</h3>
</div>
<div class="space-y-3">
@forelse ($visitsByCity as $city => $count)
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
<div>
<div class="flex items-center justify-between text-sm">
<span class="font-medium text-gray-700 dark:text-gray-300">{{ $city }}</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-emerald-500 transition-all duration-500" style="width: {{ $pct }}%"></div>
</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_device_data') }}</p>
@endforelse
</div>
</div>
{{-- Browsers --}}
<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-amber-50 text-amber-500 dark:bg-amber-950/50 dark:text-amber-400">
<x-filament::icon icon="heroicon-o-globe-asia-australia" class="h-5 w-5" />
@empty
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_city_data') }}</p>
@endforelse
</div>
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_browsers') }}</h3>
</div>
<div class="space-y-3.5">
@forelse ($visitsByBrowser as $browser => $count)
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2 min-w-0">
<x-filament::icon :icon="$browserIcons[$browser] ?? 'heroicon-m-globe-alt'" class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" />
<span class="font-medium text-gray-700 dark:text-gray-300 truncate">{{ $browser }}</span>
</div>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0 ml-2">
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
</span>
</div>
@empty
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_browser_data') }}</p>
@endforelse
</div>
</div>
{{-- Operating Systems --}}
<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-blue-50 text-blue-500 dark:bg-blue-950/50 dark:text-blue-400">
<x-filament::icon icon="heroicon-o-computer-desktop" class="h-5 w-5" />
{{-- UTM Campaigns Section --}}
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
{{-- UTM Sources --}}
<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-rose-50 text-rose-500 dark:bg-rose-950/50 dark:text-rose-400">
<x-filament::icon icon="heroicon-o-megaphone" 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_utm_source') }}</h3>
</div>
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_os') }}</h3>
</div>
<div class="space-y-3.5">
@forelse ($visitsByOs as $os => $count)
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2 min-w-0">
<x-filament::icon :icon="$osIcons[$os] ?? 'heroicon-m-computer-desktop'" class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" />
<span class="font-medium text-gray-700 dark:text-gray-300 truncate">{{ $os }}</span>
<div class="space-y-3.5">
@forelse ($utmSources as $source => $count)
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
<div class="flex items-center justify-between text-sm">
<span class="font-medium text-gray-700 dark:text-gray-300 truncate mr-2">{{ $source }}</span>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0">
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
</span>
</div>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0 ml-2">
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
</span>
</div>
@empty
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_os_data') }}</p>
@endforelse
</div>
</div>
{{-- Referers --}}
<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-purple-50 text-purple-500 dark:bg-purple-950/50 dark:text-purple-400">
<x-filament::icon icon="heroicon-o-link" class="h-5 w-5" />
@empty
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_utm_data') }}</p>
@endforelse
</div>
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_referers') }}</h3>
</div>
<div class="space-y-3">
@forelse ($visitsByReferer as $referer => $count)
<div class="flex items-center justify-between text-sm gap-4">
<div class="flex items-center gap-2 min-w-0 flex-1">
<x-filament::icon icon="heroicon-m-link" class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" />
<a href="{{ $referer }}" target="_blank" rel="noopener"
class="truncate text-indigo-600 hover:underline dark:text-indigo-400 font-medium">
{{ $referer }}
</a>
</div>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0">
{{ number_format($count) }}
</span>
</div>
@empty
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_referer_data') }}</p>
@endforelse
</div>
</div>
{{-- UTM Mediums --}}
<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-teal-50 text-teal-500 dark:bg-teal-950/50 dark:text-teal-400">
<x-filament::icon icon="heroicon-o-tag" 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_utm_medium') }}</h3>
</div>
<div class="space-y-3.5">
@forelse ($utmMediums as $medium => $count)
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
<div class="flex items-center justify-between text-sm">
<span class="font-medium text-gray-700 dark:text-gray-300 truncate mr-2">{{ $medium }}</span>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0">
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
</span>
</div>
@empty
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_utm_data') }}</p>
@endforelse
</div>
</div>
{{-- UTM Campaigns --}}
<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-cyan-50 text-cyan-500 dark:bg-cyan-950/50 dark:text-cyan-400">
<x-filament::icon icon="heroicon-o-flag" 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_utm_campaign') }}</h3>
</div>
<div class="space-y-3.5">
@forelse ($utmCampaigns as $campaign => $count)
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
<div class="flex items-center justify-between text-sm">
<span class="font-medium text-gray-700 dark:text-gray-300 truncate mr-2">{{ $campaign }}</span>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0">
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
</span>
</div>
@empty
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_utm_data') }}</p>
@endforelse
</div>
</div>
</div>
</div>
</x-filament-widgets::widget>

View File

@@ -3,10 +3,11 @@
use Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlRedirectController;
use Illuminate\Support\Facades\Route;
Route::get(
Route::match(
['GET', 'POST'],
config('filament-short-url.route_prefix', 's').'/{key}',
ShortUrlRedirectController::class
)
->name('short-url.redirect')
->where('key', '[a-zA-Z0-9_-]+')
->middleware('throttle:120,1');
->middleware(config('filament-short-url.middleware', ['web', 'throttle:120,1']));

View File

@@ -0,0 +1,134 @@
<?php
namespace Bjanczak\FilamentShortUrl\Console\Commands;
use Bjanczak\FilamentShortUrl\Models\ShortUrlDailyStats;
use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class AggregateAndPruneVisitsCommand extends Command
{
/** @var string */
protected $signature = 'short-url:aggregate-and-prune';
/** @var string */
protected $description = 'Aggregate past days visits into daily stats and prune old raw visit logs';
public function handle(): int
{
$today = Carbon::today()->toDateString();
$driver = DB::connection()->getDriverName();
$dateExpression = match ($driver) {
'pgsql' => 'visited_at::date',
'sqlsrv' => 'CAST(visited_at AS DATE)',
default => 'DATE(visited_at)',
};
// 1. Find all unique dates before today that have visits (optimized range and compatible DATE extract)
$dates = ShortUrlVisit::where('visited_at', '<', $today)
->selectRaw("{$dateExpression} as visit_date")
->distinct()
->pluck('visit_date')
->toArray();
if (empty($dates)) {
$this->info('No historical visits to aggregate.');
} else {
$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 = [];
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;
if (! isset($statsByUrl[$urlId])) {
$statsByUrl[$urlId] = [
'total' => 0,
'ip_hashes' => [],
'device_stats' => [],
'browser_stats' => [],
'os_stats' => [],
'country_stats' => [],
'city_stats' => [],
'referer_stats' => [],
'utm_source_stats' => [],
'utm_medium_stats' => [],
'utm_campaign_stats' => [],
];
}
$s = &$statsByUrl[$urlId];
$s['total']++;
if ($visit->ip_hash) {
$s['ip_hashes'][$visit->ip_hash] = true;
}
$inc = function (?string $value, string $key) use (&$s): void {
if ($value) {
$s[$key][$value] = ($s[$key][$value] ?? 0) + 1;
}
};
$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;
}
}
});
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'],
]);
}
$this->info("Aggregated stats for {$date}.");
}
}
// 2. Prune old visits if enabled
if (config('filament-short-url.pruning.enabled', true)) {
$retentionDays = (int) config('filament-short-url.pruning.retention_days', 90);
$cutoff = Carbon::now()->subDays($retentionDays)->toDateTimeString();
$deleted = ShortUrlVisit::where('visited_at', '<', $cutoff)->delete();
$this->info("Successfully pruned {$deleted} raw visit records older than {$retentionDays} days.");
}
return 0;
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace Bjanczak\FilamentShortUrl\Console\Commands;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
class SyncBufferedCountersCommand extends Command
{
/** @var string */
protected $signature = 'short-url:sync-counters';
/** @var string */
protected $description = 'Sync buffered short URL visit counters from cache to the database';
public function handle(): int
{
$prefix = config('filament-short-url.counter_buffering.cache_key_prefix', 'filament-short-url:buffer:');
$dirtyKey = "{$prefix}dirty_ids";
// Pull the list atomically to avoid race conditions with incoming clicks
if (Cache::getDefaultDriver() === 'redis' && class_exists(Redis::class)) {
$tempKey = "{$dirtyKey}:temp:".time();
try {
Redis::rename($dirtyKey, $tempKey);
$dirtyIds = Redis::smembers($tempKey);
Redis::del($tempKey);
} catch (\Throwable) {
// If key does not exist or rename fails, fallback
$dirtyIds = [];
}
} else {
$dirtyIds = Cache::pull($dirtyKey, []);
}
if (empty($dirtyIds)) {
$this->info('No buffered counters to synchronize.');
return 0;
}
$dirtyIds = array_unique(array_filter($dirtyIds));
$processed = 0;
foreach ($dirtyIds as $id) {
$totalKey = "{$prefix}total:{$id}";
$uniqueKey = "{$prefix}unique:{$id}";
$totalDelta = (int) Cache::pull($totalKey, 0);
$uniqueDelta = (int) Cache::pull($uniqueKey, 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++;
}
}
$this->info("Successfully synchronized counters for {$processed} short URLs.");
return 0;
}
}

View File

@@ -3,6 +3,7 @@
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlGlobalOverview;
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
use Filament\Actions\Action;
use Filament\Actions\CreateAction;
@@ -40,6 +41,8 @@ class ListShortUrls extends ManageRecords
protected function getHeaderWidgets(): array
{
return [];
return [
ShortUrlGlobalOverview::class,
];
}
}

View File

@@ -12,11 +12,13 @@ use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Resources\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\Http;
class ShortUrlSettingsPage extends Page implements HasForms
{
@@ -41,10 +43,17 @@ class ShortUrlSettingsPage extends Page implements HasForms
'geo_ip_driver' => $mgr->get('geo_ip_driver', 'headers'),
'geo_ip_cache_ttl' => $mgr->get('geo_ip_cache_ttl', 86400),
'geo_ip_timeout' => $mgr->get('geo_ip_timeout', 3),
'maxmind_database_path' => $mgr->get('maxmind_database_path', database_path('geoip/GeoLite2-Country.mmdb')),
'maxmind_database_path' => $mgr->get('maxmind_database_path', storage_path('geoip/GeoLite2-Country.mmdb')),
'queue_connection' => $mgr->get('queue_connection', 'sync'),
'ga4_api_secret' => $mgr->get('ga4_api_secret'),
'ga4_firebase_app_id' => $mgr->get('ga4_firebase_app_id'),
'counter_buffering_enabled' => $mgr->get('counter_buffering_enabled', false),
'trust_cdn_headers' => $mgr->get('trust_cdn_headers', false),
'pruning_enabled' => $mgr->get('pruning_enabled', true),
'pruning_retention_days' => $mgr->get('pruning_retention_days', 90),
'rate_limiting_enabled' => $mgr->get('rate_limiting_enabled', false),
'rate_limiting_max_attempts' => $mgr->get('rate_limiting_max_attempts', 60),
'rate_limiting_decay_seconds' => $mgr->get('rate_limiting_decay_seconds', 60),
]);
}
@@ -93,6 +102,12 @@ class ShortUrlSettingsPage extends Page implements HasForms
->minValue(0)
->suffix('s')
->required(),
Toggle::make('trust_cdn_headers')
->label(__('filament-short-url::default.settings_trust_cdn_headers'))
->helperText(__('filament-short-url::default.settings_trust_cdn_headers_helper'))
->columnSpanFull()
->inline(false),
]),
Section::make(__('filament-short-url::default.settings_section_queue'))
@@ -111,6 +126,14 @@ class ShortUrlSettingsPage extends Page implements HasForms
})
->required(),
]),
Section::make(__('filament-short-url::default.settings_section_buffering'))
->schema([
Toggle::make('counter_buffering_enabled')
->label(__('filament-short-url::default.settings_buffering_enabled'))
->helperText(__('filament-short-url::default.settings_buffering_helper'))
->inline(false),
]),
]),
// ── Geo-IP ───────────────────────────────────────────
@@ -166,7 +189,39 @@ class ShortUrlSettingsPage extends Page implements HasForms
->label(__('filament-short-url::default.settings_maxmind_path'))
->helperText(__('filament-short-url::default.settings_maxmind_path_helper'))
->columnSpanFull()
->placeholder(database_path('geoip/GeoLite2-Country.mmdb'))
->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 ?? '');
if (empty($path)) {
Notification::make()
->title(__('filament-short-url::default.settings_maxmind_verify_empty'))
->warning()
->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'),
]),
]),
@@ -183,15 +238,134 @@ class ShortUrlSettingsPage extends Page implements HasForms
->helperText(__('filament-short-url::default.settings_ga4_api_secret_helper'))
->password()
->revealable()
->live()
->placeholder('••••••••••••••••••••'),
// Firebase App ID only when API Secret is set
// Firebase App ID — always visible
TextInput::make('ga4_firebase_app_id')
->label(__('filament-short-url::default.settings_ga4_firebase_app_id'))
->helperText(__('filament-short-url::default.settings_ga4_firebase_app_id_helper'))
->placeholder('1:1234567890:android:abcdef123456')
->visible(fn (Get $get): bool => filled($get('ga4_api_secret'))),
->placeholder('1:1234567890:android:abcdef123456'),
// GA4 connection verify button
Actions::make([
Action::make('verifyGa4ApiSecret')
->label(__('filament-short-url::default.settings_ga4_verify'))
->icon('heroicon-o-signal')
->color('gray')
->action(function (Get $get): void {
$secret = trim($get('ga4_api_secret') ?? '');
if (empty($secret)) {
Notification::make()
->title(__('filament-short-url::default.settings_ga4_verify_empty'))
->warning()
->send();
return;
}
try {
// GA4 debug endpoint — a valid api_secret returns event-level
// validation messages; an invalid one returns auth errors.
$response = Http::timeout(5)
->withHeaders(['Content-Type' => 'application/json'])
->post(
'https://www.google-analytics.com/debug/mp/collect?measurement_id=G-XXXXXXXXXX&api_secret='.urlencode($secret),
[
'client_id' => 'short-url-plugin-verify',
'events' => [
['name' => 'page_view', 'params' => []],
],
]
);
$body = $response->json();
$messages = $body['validationMessages'] ?? [];
// A valid secret returns event-level messages, not auth errors
$hasAuthError = collect($messages)->contains(fn ($m) => str_contains(
strtolower($m['description'] ?? ''),
'api_secret'
));
if ($hasAuthError || $response->status() === 401) {
Notification::make()
->title(__('filament-short-url::default.settings_ga4_verify_fail'))
->danger()
->send();
} else {
Notification::make()
->title(__('filament-short-url::default.settings_ga4_verify_ok'))
->success()
->send();
}
} catch (\Throwable $e) {
Notification::make()
->title(__('filament-short-url::default.settings_ga4_verify_error'))
->body($e->getMessage())
->danger()
->send();
}
}),
]),
]),
]),
// ── Advanced & Security ──────────────────────────────
Tab::make(__('filament-short-url::default.settings_tab_advanced'))
->icon('heroicon-o-adjustments-horizontal')
->schema([
Section::make(__('filament-short-url::default.settings_section_aggregation'))
->columns(2)
->schema([
Toggle::make('pruning_enabled')
->label(__('filament-short-url::default.settings_aggregation_enabled'))
->helperText(__('filament-short-url::default.settings_aggregation_enabled_helper'))
->default(true)
->live()
->inline(false),
Select::make('pruning_retention_days')
->label(__('filament-short-url::default.settings_retention_days'))
->helperText(__('filament-short-url::default.settings_retention_days_helper'))
->options([
30 => __('filament-short-url::default.retention_30_days'),
60 => __('filament-short-url::default.retention_60_days'),
90 => __('filament-short-url::default.retention_90_days'),
180 => __('filament-short-url::default.retention_180_days'),
365 => __('filament-short-url::default.retention_365_days'),
730 => __('filament-short-url::default.retention_730_days'),
])
->required()
->visible(fn (Get $get): bool => (bool) $get('pruning_enabled')),
]),
Section::make(__('filament-short-url::default.settings_section_rate_limiting'))
->columns(3)
->schema([
Toggle::make('rate_limiting_enabled')
->label(__('filament-short-url::default.settings_rate_limiting_enabled'))
->helperText(__('filament-short-url::default.settings_rate_limiting_enabled_helper'))
->default(false)
->live()
->inline(false),
TextInput::make('rate_limiting_max_attempts')
->label(__('filament-short-url::default.settings_rate_limiting_max_attempts'))
->helperText(__('filament-short-url::default.settings_rate_limiting_max_attempts_helper'))
->numeric()
->minValue(1)
->required()
->visible(fn (Get $get): bool => (bool) $get('rate_limiting_enabled')),
TextInput::make('rate_limiting_decay_seconds')
->label(__('filament-short-url::default.settings_rate_limiting_decay_seconds'))
->helperText(__('filament-short-url::default.settings_rate_limiting_decay_seconds_helper'))
->numeric()
->minValue(1)
->suffix('s')
->required()
->visible(fn (Get $get): bool => (bool) $get('rate_limiting_enabled')),
]),
]),
]),

View File

@@ -112,31 +112,38 @@ class ViewShortUrlLogs extends Page implements HasForms, HasTable
Tables\Filters\Filter::make('visited_at')
->form([
DatePicker::make('visited_from')
->label('Visited From'),
->label(__('filament-short-url::default.stats_filter_visited_from')),
DatePicker::make('visited_until')
->label('Visited Until'),
->label(__('filament-short-url::default.stats_filter_visited_until')),
])
->query(function ($query, array $data) {
return $query
->when($data['visited_from'], fn ($q, $date) => $q->whereDate('visited_at', '>=', $date))
->when($data['visited_until'], fn ($q, $date) => $q->whereDate('visited_at', '<=', $date));
->when($data['visited_from'], fn ($q, $date) => $q->where('visited_at', '>=', $date.' 00:00:00'))
->when($data['visited_until'], fn ($q, $date) => $q->where('visited_at', '<=', $date.' 23:59:59'));
}),
])
->headerActions([
Action::make('export_csv')
->label('Export CSV')
->label(__('filament-short-url::default.stats_action_export'))
->icon('heroicon-o-arrow-down-tray')
->color('gray')
->action(function () {
return response()->streamDownload(function () {
->action(function (HasTable $livewire) {
return response()->streamDownload(function () use ($livewire) {
$handle = fopen('php://output', 'w');
// Add UTF-8 BOM for Microsoft Excel
fprintf($handle, chr(0xEF).chr(0xBB).chr(0xBF));
fputcsv($handle, ['Time', 'IP Address', 'Country', 'Device', 'Browser', 'OS', 'Referer']);
fputcsv($handle, [
__('filament-short-url::default.stats_csv_time'),
__('filament-short-url::default.stats_csv_ip'),
__('filament-short-url::default.stats_csv_country'),
__('filament-short-url::default.stats_csv_device'),
__('filament-short-url::default.stats_csv_browser'),
__('filament-short-url::default.stats_csv_os'),
__('filament-short-url::default.stats_csv_referer'),
]);
ShortUrlVisit::query()
->where('short_url_id', $this->record->id)
$livewire->getFilteredTableQuery()
->orderBy('visited_at', 'desc')
->chunk(200, function ($visits) use ($handle) {
foreach ($visits as $visit) {

View File

@@ -5,9 +5,12 @@ namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Filament\Actions\Action;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Resources\Pages\Page;
use Filament\Schemas\Schema;
class ViewShortUrlStats extends Page implements HasForms
{
@@ -23,10 +26,80 @@ class ViewShortUrlStats extends Page implements HasForms
public int $totalVisits = 0;
public ?array $filterData = [];
public function mount(ShortUrl $record): void
{
$this->record = $record;
$this->totalVisits = $record->getCachedStats()['totalVisits'] ?? 0;
$this->form->fill([
'preset' => '30_days',
'date_from' => now()->subDays(29)->format('Y-m-d'),
'date_to' => now()->format('Y-m-d'),
]);
if (empty($this->filterData)) {
$this->filterData = [
'preset' => '30_days',
'date_from' => now()->subDays(29)->format('Y-m-d'),
'date_to' => now()->format('Y-m-d'),
];
}
$this->totalVisits = $record->getCachedStats($this->filterData['date_from'], $this->filterData['date_to'])['totalVisits'] ?? 0;
}
public function form(Schema $schema): Schema
{
return $schema
->components([
Select::make('preset')
->hiddenLabel()
->options([
'24_hours' => __('filament-short-url::default.stats_preset_24_hours'),
'7_days' => __('filament-short-url::default.stats_preset_7_days'),
'30_days' => __('filament-short-url::default.stats_preset_30_days'),
'90_days' => __('filament-short-url::default.stats_preset_90_days'),
'custom' => __('filament-short-url::default.stats_preset_custom'),
])
->live()
->columnSpan(fn ($get) => $get('preset') === 'custom' ? 1 : 'full')
->afterStateUpdated(function ($state, $set) {
$to = now()->format('Y-m-d');
$from = match ($state) {
'24_hours' => now()->subDay()->format('Y-m-d'),
'7_days' => now()->subDays(6)->format('Y-m-d'),
'30_days' => now()->subDays(29)->format('Y-m-d'),
'90_days' => now()->subDays(89)->format('Y-m-d'),
'custom' => now()->subDays(29)->format('Y-m-d'),
default => null,
};
if ($from) {
$set('date_from', $from);
$set('date_to', $to);
}
}),
DatePicker::make('date_from')
->hiddenLabel()
->placeholder(__('filament-short-url::default.stats_filter_visited_from'))
->native(false)
->live()
->columnSpan(1)
->visible(fn ($get) => $get('preset') === 'custom')
->required(),
DatePicker::make('date_to')
->hiddenLabel()
->placeholder(__('filament-short-url::default.stats_filter_visited_until'))
->native(false)
->live()
->columnSpan(1)
->visible(fn ($get) => $get('preset') === 'custom')
->required(),
])
->columns(3)
->statePath('filterData');
}
protected function getHeaderWidgets(): array

View File

@@ -5,6 +5,7 @@ namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas;
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
use Filament\Actions\Action;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
@@ -24,6 +25,7 @@ class ShortUrlForm
return $schema->components([
Tabs::make()->tabs([
static::linkTab(),
static::targetingTab(),
static::trackingTab(),
static::qrDesignTab(),
])->columnSpanFull(),
@@ -105,7 +107,7 @@ class ShortUrlForm
->required(),
])->columns(2),
Section::make('Options')->schema([
Section::make(__('filament-short-url::default.form_section_options'))->schema([
Toggle::make('is_enabled')
->label(__('filament-short-url::default.status'))
->default(true)
@@ -129,7 +131,7 @@ class ShortUrlForm
->native(false),
])->columns(2),
Section::make('Internal Notes')->schema([
Section::make(__('filament-short-url::default.form_section_notes'))->schema([
Textarea::make('notes')
->label(__('filament-short-url::default.notes'))
->rows(3)
@@ -177,7 +179,7 @@ class ShortUrlForm
return Tab::make(__('filament-short-url::default.tab_tracking'))
->icon('heroicon-o-chart-bar')
->schema([
Section::make('Visit Tracking')
Section::make(__('filament-short-url::default.form_section_tracking'))
->schema([
Toggle::make('track_visits')
->label(__('filament-short-url::default.track_visits'))
@@ -187,7 +189,7 @@ class ShortUrlForm
->columnSpanFull(),
])->columns(1),
Section::make('Tracked Fields')
Section::make(__('filament-short-url::default.form_section_tracked_fields'))
->schema([
Toggle::make('track_ip_address')
->label(__('filament-short-url::default.track_ip'))
@@ -277,7 +279,7 @@ class ShortUrlForm
])
->columns(2),
Section::make('Third-Party Analytics')
Section::make(__('filament-short-url::default.form_section_analytics'))
->schema([
TextInput::make('ga_tracking_id')
->label(__('filament-short-url::default.ga_tracking_id'))
@@ -313,4 +315,130 @@ class ShortUrlForm
->dehydrated(false),
]);
}
private static function targetingTab(): Tab
{
return Tab::make(__('filament-short-url::default.tab_targeting'))
->icon('heroicon-o-shield-check')
->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),
Toggle::make('show_warning_page')
->label(__('filament-short-url::default.show_warning_page'))
->helperText(__('filament-short-url::default.show_warning_page_helper'))
->default(false)
->inline(false),
])->columns(2),
Section::make(__('filament-short-url::default.targeting_type'))
->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(),
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'),
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([
'PL' => 'Poland',
'US' => 'United States',
'GB' => 'United Kingdom',
'DE' => 'Germany',
'FR' => 'France',
'ES' => 'Spain',
'IT' => 'Italy',
'CA' => 'Canada',
'AU' => 'Australia',
'NL' => 'Netherlands',
'UA' => 'Ukraine',
'IE' => 'Ireland',
'BE' => 'Belgium',
'AT' => 'Austria',
'CH' => 'Switzerland',
'SE' => 'Sweden',
'NO' => 'Norway',
'DK' => 'Denmark',
'FI' => 'Finland',
'CZ' => 'Czech Republic',
'SK' => 'Slovakia',
'HU' => 'Hungary',
'RO' => 'Romania',
'GR' => 'Greece',
'PT' => 'Portugal',
'BR' => 'Brazil',
'MX' => 'Mexico',
'CN' => 'China',
'JP' => 'Japan',
'IN' => 'India',
])
->searchable()
->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'),
Repeater::make('targeting_rules.rotation')
->label(__('filament-short-url::default.rotation_targeting_rules'))
->schema([
TextInput::make('url')
->label(__('filament-short-url::default.rotation_url'))
->url()
->required()
->maxLength(2048),
TextInput::make('weight')
->label(__('filament-short-url::default.rotation_weight'))
->helperText(__('filament-short-url::default.rotation_weight_helper'))
->numeric()
->required()
->default(50),
])
->columns(2)
->visible(fn (Get $get): bool => $get('targeting_rules.type') === 'rotation'),
]),
]);
}
}

View File

@@ -2,19 +2,21 @@
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Tables;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Filament\Actions\Action;
use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Support\Enums\FontWeight;
use Filament\Tables\Columns\IconColumn;
use Filament\Forms;
use Filament\Tables\Columns\Layout\Stack;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\ToggleColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
use Illuminate\Support\HtmlString;
class ShortUrlsTable
{
@@ -22,60 +24,147 @@ class ShortUrlsTable
{
return $table
->columns([
TextColumn::make('url_key')
->label(__('filament-short-url::default.col_short_url'))
->getStateUsing(fn (ShortUrl $record): string => $record->getShortUrl())
->copyable()
->copyMessage(__('filament-short-url::default.qr_copied'))
->fontFamily('mono')
->weight(FontWeight::SemiBold)
->searchable(query: fn ($query, string $search) => $query->where('url_key', 'like', "%{$search}%")),
Stack::make([
// ROW 1: Favicon + Short URL with copy capability on click of text or icon (vertically centered!)
TextColumn::make('url_key')
->label(__('filament-short-url::default.col_short_url'))
->getStateUsing(static function (ShortUrl $record): HtmlString {
$shortUrl = e($record->getShortUrl());
$copiedMsg = e(__('filament-short-url::default.qr_copied'));
$tooltipCopy = e(__('filament-short-url::default.action_copy'));
$destHost = e(parse_url($record->destination_url, PHP_URL_HOST));
TextColumn::make('destination_url')
->label(__('filament-short-url::default.col_destination_url'))
->limit(45)
->tooltip(fn (ShortUrl $record): string => $record->destination_url)
->url(fn (ShortUrl $record): string => $record->destination_url, shouldOpenInNewTab: true)
->searchable(),
return new HtmlString(<<<HTML
<div onclick="
event.preventDefault();
event.stopPropagation();
const text = '{$shortUrl}';
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text);
} else {
const textCopyArea = document.createElement('textarea');
textCopyArea.value = text;
textCopyArea.style.position = 'fixed';
textCopyArea.style.left = '-999999px';
textCopyArea.style.top = '-999999px';
document.body.appendChild(textCopyArea);
textCopyArea.focus();
textCopyArea.select();
try {
document.execCommand('copy');
} catch (err) {}
textCopyArea.remove();
}
if (typeof FilamentNotification !== 'undefined') {
new FilamentNotification()
.title('{$copiedMsg}')
.success()
.send();
} else if (window.Alpine) {
window.Alpine.store('filament-notifications')?.send({
status: 'success',
title: '{$copiedMsg}'
});
}
" class="flex items-center gap-3 cursor-pointer w-fit">
<div class="flex items-center justify-center rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-1.5 flex-shrink-0 w-8 h-8">
<img src="https://icons.duckduckgo.com/ip2/{$destHost}.ico"
class="w-full h-full object-contain"
onerror="this.src='https://heroicons.com/24/outline/link.svg'" />
</div>
<span class="text-[#2a5bd7] text-[16px] font-bold leading-6 break-all line-clamp-1">
{$shortUrl}
</span>
<span title="{$tooltipCopy}" class="w-8 h-8 rounded-full flex items-center justify-center bg-[#f4f4f5] hover:bg-[#e4e4e7] dark:bg-gray-800 dark:hover:bg-gray-700 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors flex-shrink-0 focus:outline-none">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</span>
</div>
HTML
);
})
->searchable(query: fn ($query, string $search) => $query->where('url_key', 'like', "%{$search}%")),
TextColumn::make('total_visits')
->label(__('filament-short-url::default.col_total_visits'))
->numeric()
->badge()
->color('gray')
->sortable(),
// ROW 2: Sub-arrow + Destination URL
TextColumn::make('destination_url')
->label(__('filament-short-url::default.col_destination_url'))
->getStateUsing(fn ($record): HtmlString => new HtmlString('
<div class="flex items-center gap-1.5 text-gray-500 dark:text-gray-400 text-xs mt-2">
<svg class="w-3.5 h-3.5 flex-shrink-0 text-gray-400 dark:text-gray-500"
style="transform: scaleY(-1);"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2.5"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 15l6-6m0 0l-6-6m6 6H9a6 6 0 00-6 6v3" />
</svg>
<a href="'.e($record->destination_url).'"
target="_blank"
rel="noopener noreferrer"
class="truncate max-w-[50ch] hover:underline text-[#273144] text-[15px] leading-[16px] font-medium"
title="'.e($record->destination_url).'">
'.e($record->destination_url).'
</a>
</div>
'))
->searchable(),
TextColumn::make('unique_visits')
->label(__('filament-short-url::default.stats_card_unique'))
->numeric()
->badge()
->color('info')
->sortable(),
// ROW 3: Bottom Metadata (Total clicks / Unique clicks / Date added / Expiry / Redirect code)
TextColumn::make('metadata_badges')
->getStateUsing(fn ($record): HtmlString => new HtmlString('
<div class="flex flex-wrap items-center gap-2 text-xs text-gray-500 dark:text-gray-400 mt-3">
<!-- Clicks 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="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
</span>
ToggleColumn::make('is_enabled')
->label(__('filament-short-url::default.col_status'))
->sortable(),
<!-- Unique Clicks 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="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
</span>
IconColumn::make('track_visits')
->label(__('filament-short-url::default.track_visits'))
->boolean()
->trueIcon('heroicon-o-signal')
->falseIcon('heroicon-o-signal-slash')
->trueColor('success')
->falseColor('gray'),
<!-- Date Added 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="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').'
</span>
TextColumn::make('expires_at')
->label(__('filament-short-url::default.col_expires_at'))
->dateTime('d M Y')
->placeholder('Never')
->color(fn (ShortUrl $record): string => $record->isExpired() ? 'danger' : 'gray')
->sortable(),
<!-- Expiry / Single Use 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">
'.($record->expires_at ? '
<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').'
' : '
<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
').'
</span>
TextColumn::make('created_at')
->label(__('filament-short-url::default.col_created_at'))
->since()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
<!-- Redirect Type 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="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
</svg>
'.$record->redirect_status_code.' redirect
</span>
</div>
')),
]),
])
->filters([
TernaryFilter::make('is_enabled')
@@ -95,43 +184,150 @@ class ShortUrlsTable
'1' => 'Single-use',
]),
])
->recordActions([
->recordClasses(fn (): string => 'short-url-card')
->actions([
Action::make('share')
->label(__('filament-short-url::default.action_share'))
->icon('heroicon-o-share')
->color('gray')
->iconButton()
->tooltip(__('filament-short-url::default.action_share'))
->modalHeading(__('filament-short-url::default.share_title'))
->modalWidth('md')
->modalSubmitAction(false)
->modalCancelAction(false)
->form([
Forms\Components\Placeholder::make('platforms')
->label(__('filament-short-url::default.share_description'))
->content(fn (ShortUrl $record): HtmlString => new HtmlString('
<div class="flex items-center gap-6 overflow-x-auto pb-4 pt-1 scroll-smooth" style="scrollbar-width: thin; -ms-overflow-style: none;">
<!-- Messenger -->
<a href="fb-messenger://share/?link='.urlencode($record->getShortUrl()).'" target="_blank" rel="noopener noreferrer" class="flex flex-col items-center gap-2 group flex-shrink-0 cursor-pointer">
<div class="w-11 h-11 rounded-full bg-gradient-to-tr from-[#006aff] via-[#00b2ff] to-[#00d6ff] text-white flex items-center justify-center shadow-sm group-hover:scale-105 transition-transform duration-200">
<svg class="w-5.5 h-5.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2C6.48 2 2 6.14 2 11.25c0 2.9 1.45 5.48 3.73 7.08v3.67c0 .24.23.4.43.27l4.07-2.3c.57.16 1.17.25 1.77.25 5.52 0 10-4.14 10-9.25S17.52 2 12 2zm1.09 11.95l-2.43-2.6-4.73 2.6 5.19-5.52 2.47 2.63 4.7-2.63-5.2 5.52z"/>
</svg>
</div>
<span class="text-[11px] font-medium text-gray-500 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white transition-colors">Messenger</span>
</a>
<!-- Facebook -->
<a href="https://www.facebook.com/sharer/sharer.php?u='.urlencode($record->getShortUrl()).'" target="_blank" rel="noopener noreferrer" class="flex flex-col items-center gap-2 group flex-shrink-0 cursor-pointer">
<div class="w-11 h-11 rounded-full bg-[#1877f2] text-white flex items-center justify-center shadow-sm group-hover:scale-105 transition-transform duration-200">
<svg class="w-5.5 h-5.5" 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>
</div>
<span class="text-[11px] font-medium text-gray-550 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-white transition-colors">Facebook</span>
</a>
<!-- WhatsApp -->
<a href="https://api.whatsapp.com/send?text='.urlencode($record->getShortUrl()).'" target="_blank" rel="noopener noreferrer" class="flex flex-col items-center gap-2 group flex-shrink-0 cursor-pointer">
<div class="w-11 h-11 rounded-full bg-[#25d366] text-white flex items-center justify-center shadow-sm group-hover:scale-105 transition-transform duration-200">
<svg class="w-5.5 h-5.5" 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>
</div>
<span class="text-[11px] font-medium text-gray-500 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white transition-colors">WhatsApp</span>
</a>
<!-- Twitter/X -->
<a href="https://twitter.com/intent/tweet?url='.urlencode($record->getShortUrl()).'" target="_blank" rel="noopener noreferrer" class="flex flex-col items-center gap-2 group flex-shrink-0 cursor-pointer">
<div class="w-11 h-11 rounded-full bg-[#0f1419] text-white flex items-center justify-center shadow-sm group-hover:scale-105 transition-transform duration-200">
<svg class="w-5 h-5" 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>
</div>
<span class="text-[11px] font-medium text-gray-500 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white transition-colors">Twitter (X)</span>
</a>
<!-- LinkedIn -->
<a href="https://www.linkedin.com/sharing/share-offsite/?url='.urlencode($record->getShortUrl()).'" target="_blank" rel="noopener noreferrer" class="flex flex-col items-center gap-2 group flex-shrink-0 cursor-pointer">
<div class="w-11 h-11 rounded-full bg-[#0077b5] text-white flex items-center justify-center shadow-sm group-hover:scale-105 transition-transform duration-200">
<svg class="w-5.5 h-5.5" 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>
</div>
<span class="text-[11px] font-medium text-gray-500 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white transition-colors">LinkedIn</span>
</a>
<!-- Email -->
<a href="mailto:?body='.urlencode($record->getShortUrl()).'" target="_blank" rel="noopener noreferrer" class="flex flex-col items-center gap-2 group flex-shrink-0 cursor-pointer">
<div class="w-11 h-11 rounded-full bg-[#6b7280] text-white flex items-center justify-center shadow-sm group-hover:scale-105 transition-transform duration-200">
<svg class="w-5.5 h-5.5" 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>
</div>
<span class="text-[11px] font-medium text-gray-500 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white transition-colors">Email</span>
</a>
</div>
')),
Forms\Components\Placeholder::make('copy_field')
->label('')
->content(fn (ShortUrl $record): HtmlString => new HtmlString('
<div class="flex items-center gap-2 relative mt-2">
<input type="text"
readonly
value="'.e($record->getShortUrl()).'"
id="share_link_input_'.$record->id.'"
class="flex-1 min-w-0 block w-full px-3.5 py-2.5 rounded-lg border border-gray-300 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 text-gray-900 dark:text-gray-100 text-sm focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-400 dark:focus:border-primary-400 focus:outline-none">
<button onclick="
const input = document.getElementById(\'share_link_input_'.$record->id.'\');
input.select();
navigator.clipboard.writeText(input.value);
if (typeof FilamentNotification !== \'undefined\') {
new FilamentNotification()
.title(\''.e(__('filament-short-url::default.share_copied')).'\')
.success()
.send();
} else if (typeof Alpine !== \'undefined\') {
Alpine.store(\'filament-notifications\')?.send({
status: \'success\',
title: \''.e(__('filament-short-url::default.share_copied')).'\'
});
}
"
class="flex-shrink-0 inline-flex items-center justify-center gap-1.5 px-4 py-2.5 bg-gray-900 hover:bg-gray-800 dark:bg-gray-100 dark:hover:bg-white text-white dark:text-gray-950 font-semibold text-sm rounded-lg shadow-sm hover:shadow transition duration-200">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<span>'.e(__('filament-short-url::default.share_copy')).'</span>
</button>
</div>
')),
]),
Action::make('stats')
->label(__('filament-short-url::default.action_stats'))
->icon('heroicon-o-chart-bar')
->color('gray')
->size('sm')
->url(fn (ShortUrl $record): string => route(
'filament.admin.resources.short-urls.stats',
['record' => $record->id]
)),
Action::make('copy')
->label(__('filament-short-url::default.action_copy'))
->icon('heroicon-o-clipboard')
->color('gray')
->size('sm')
->action(fn () => null) // Copy happens client-side
->extraAttributes(fn (ShortUrl $record): array => [
'x-on:click' => 'navigator.clipboard.writeText("'.$record->getShortUrl().'")',
'title' => 'Copy short URL',
]),
->iconButton()
->tooltip(__('filament-short-url::default.action_stats'))
->url(fn (ShortUrl $record): string => ShortUrlResource::getUrl('stats', ['record' => $record])),
EditAction::make()
->icon('heroicon-o-pencil-square')
->size('sm')
->color('gray'),
->color('gray')
->iconButton()
->tooltip(__('filament-short-url::default.action_edit')),
DeleteAction::make()
->icon('heroicon-o-trash')
->color('gray')
->iconButton()
->tooltip(__('filament-short-url::default.action_delete')),
])
->contentGrid([
'md' => 1,
'xl' => 1,
])
->toolbarActions([
BulkActionGroup::make([
BulkAction::make('enable')
->label('Enable selected')
->label(__('filament-short-url::default.action_enable_selected'))
->icon('heroicon-o-check-circle')
->action(fn ($records) => $records->each->update(['is_enabled' => true]))
->deselectRecordsAfterCompletion(),
BulkAction::make('disable')
->label('Disable selected')
->label(__('filament-short-url::default.action_disable_selected'))
->icon('heroicon-o-x-circle')
->action(fn ($records) => $records->each->update(['is_enabled' => false]))
->deselectRecordsAfterCompletion(),

View File

@@ -0,0 +1,125 @@
<?php
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit;
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
class ShortUrlGlobalOverview extends BaseWidget
{
/** Disable automatic Livewire polling — cache is invalidated via model events instead. */
protected ?string $pollingInterval = null;
protected int|string|array $columnSpan = 'full';
/** Cache key for link counts (forever, busted on create/delete). */
public const LINKS_CACHE_KEY = 'filament-short-url:global-links';
/** Cache key for click trends (short TTL, time-based data). */
public const CLICKS_CACHE_KEY = 'filament-short-url:global-clicks';
protected function getStats(): array
{
$linkData = $this->getLinkStats();
$clickData = $this->getClickStats();
$clicksTrend = $clickData['prev7Clicks'] > 0
? round((($clickData['last7Clicks'] - $clickData['prev7Clicks']) / $clickData['prev7Clicks']) * 100, 1)
: ($clickData['last7Clicks'] > 0 ? 100.0 : 0.0);
$uniquesTrend = $clickData['prev7Uniques'] > 0
? round((($clickData['last7Uniques'] - $clickData['prev7Uniques']) / $clickData['prev7Uniques']) * 100, 1)
: ($clickData['last7Uniques'] > 0 ? 100.0 : 0.0);
return [
Stat::make(__('filament-short-url::default.navigation_label'), number_format($linkData['totalLinks']))
->description($linkData['activeLinks'].' active · '.$linkData['activeRatio'].'%')
->descriptionIcon('heroicon-m-link')
->icon('heroicon-o-link')
->color('gray'),
Stat::make(__('filament-short-url::default.stats_card_total'), number_format($clickData['totalVisits']))
->description(abs($clicksTrend).'% '.($clicksTrend >= 0 ? 'up' : 'down').' vs last 7 days')
->descriptionIcon($clicksTrend >= 0 ? 'heroicon-m-arrow-trending-up' : 'heroicon-m-arrow-trending-down')
->icon('heroicon-o-cursor-arrow-rays')
->color($clicksTrend >= 0 ? 'success' : 'danger'),
Stat::make(__('filament-short-url::default.stats_card_unique'), number_format($clickData['uniqueVisits']))
->description(abs($uniquesTrend).'% '.($uniquesTrend >= 0 ? 'up' : 'down').' vs last 7 days')
->descriptionIcon($uniquesTrend >= 0 ? 'heroicon-m-arrow-trending-up' : 'heroicon-m-arrow-trending-down')
->icon('heroicon-o-user-group')
->color($uniquesTrend >= 0 ? 'success' : 'danger'),
];
}
/**
* Link counts cached forever — invalidated only when a ShortUrl is created or deleted.
*
* @return array{totalLinks: int, activeLinks: int, activeRatio: int}
*/
protected function getLinkStats(): array
{
return Cache::rememberForever(self::LINKS_CACHE_KEY, function () {
$agg = ShortUrl::query()
->select([
DB::raw('COUNT(*) as total'),
DB::raw('SUM(CASE WHEN is_enabled THEN 1 ELSE 0 END) as active'),
])
->first();
$total = (int) ($agg->total ?? 0);
$active = (int) ($agg->active ?? 0);
return [
'totalLinks' => $total,
'activeLinks' => $active,
'activeRatio' => $total > 0 ? round(($active / $total) * 100) : 0,
];
});
}
/**
* Click/visit stats cached with a short TTL (time-based data changes frequently).
*
* @return array{totalVisits: int, uniqueVisits: int, last7Clicks: int, prev7Clicks: int, last7Uniques: int, prev7Uniques: int}
*/
protected function getClickStats(): array
{
$ttl = (int) config('filament-short-url.geo_ip.stats_cache_ttl', 300);
return Cache::remember(self::CLICKS_CACHE_KEY, $ttl, function () {
$agg = ShortUrl::query()
->select([
DB::raw('SUM(total_visits) as total_visits'),
DB::raw('SUM(unique_visits) as unique_visits'),
])
->first();
$last7Clicks = ShortUrlVisit::where('visited_at', '>=', now()->subDays(7))->count();
$prev7Clicks = ShortUrlVisit::where('visited_at', '>=', now()->subDays(14))
->where('visited_at', '<', now()->subDays(7))
->count();
$last7Uniques = ShortUrlVisit::where('visited_at', '>=', now()->subDays(7))
->distinct('ip_hash')
->count('ip_hash');
$prev7Uniques = ShortUrlVisit::whereBetween('visited_at', [now()->subDays(14), now()->subDays(7)])
->distinct('ip_hash')
->count('ip_hash');
return [
'totalVisits' => (int) ($agg->total_visits ?? 0),
'uniqueVisits' => (int) ($agg->unique_visits ?? 0),
'last7Clicks' => $last7Clicks,
'prev7Clicks' => $prev7Clicks,
'last7Uniques' => $last7Uniques,
'prev7Uniques' => $prev7Uniques,
];
});
}
}

View File

@@ -10,13 +10,27 @@ class ShortUrlStatsOverview extends BaseWidget
{
public ?ShortUrl $record = null;
public ?string $dateFrom = null;
public ?string $dateTo = null;
protected function getStats(): array
{
if (! $this->record) {
return [];
}
$stats = $this->record->getCachedStats();
$stats = $this->record->getCachedStats($this->dateFrom, $this->dateTo);
$topSource = 'Direct';
if (! empty($stats['utmSources'])) {
$topSource = array_key_first($stats['utmSources']);
}
$topCountry = '—';
if (! empty($stats['visitsByCountry'])) {
$topCountry = array_key_first($stats['visitsByCountry']);
}
return [
Stat::make(__('filament-short-url::default.stats_card_total'), number_format($stats['totalVisits'] ?? 0))
@@ -27,17 +41,13 @@ class ShortUrlStatsOverview extends BaseWidget
->icon('heroicon-o-user-group')
->color('info'),
Stat::make(__('filament-short-url::default.stats_card_today'), number_format($stats['visitsToday'] ?? 0))
->icon('heroicon-o-sun')
Stat::make(__('filament-short-url::default.stats_card_top_source'), $topSource)
->icon('heroicon-o-megaphone')
->color('warning'),
Stat::make(__('filament-short-url::default.stats_card_week'), number_format($stats['visitsThisWeek'] ?? 0))
->icon('heroicon-o-calendar-days')
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_month'), number_format($stats['visitsThisMonth'] ?? 0))
->icon('heroicon-o-calendar')
->color('gray'),
];
}
}

View File

@@ -9,6 +9,10 @@ class ShortUrlVisitsBottomBreakdown extends Widget
{
public ?ShortUrl $record = null;
public ?string $dateFrom = null;
public ?string $dateTo = null;
protected string $view = 'filament-short-url::widgets.visits-bottom-breakdown';
protected int|string|array $columnSpan = 'full';
@@ -29,17 +33,25 @@ class ShortUrlVisitsBottomBreakdown extends Widget
'visitsByBrowser' => [],
'visitsByOs' => [],
'visitsByReferer' => [],
'visitsByCity' => [],
'utmSources' => [],
'utmMediums' => [],
'utmCampaigns' => [],
'totalVisits' => 0,
];
}
$stats = $this->record->getCachedStats();
$stats = $this->record->getCachedStats($this->dateFrom, $this->dateTo);
return [
'visitsByDevice' => $stats['visitsByDevice'] ?? [],
'visitsByBrowser' => $stats['visitsByBrowser'] ?? [],
'visitsByOs' => $stats['visitsByOs'] ?? [],
'visitsByReferer' => $stats['visitsByReferer'] ?? [],
'visitsByCity' => $stats['visitsByCity'] ?? [],
'utmSources' => $stats['utmSources'] ?? [],
'utmMediums' => $stats['utmMediums'] ?? [],
'utmCampaigns' => $stats['utmCampaigns'] ?? [],
'totalVisits' => $stats['totalVisits'] ?? 0,
];
}

View File

@@ -9,6 +9,10 @@ class ShortUrlVisitsChart extends ChartWidget
{
public ?ShortUrl $record = null;
public ?string $dateFrom = null;
public ?string $dateTo = null;
protected ?string $maxHeight = '200px';
protected int|string|array $columnSpan = [
@@ -29,7 +33,7 @@ class ShortUrlVisitsChart extends ChartWidget
];
}
$stats = $this->record->getCachedStats();
$stats = $this->record->getCachedStats($this->dateFrom, $this->dateTo);
$visitsByDay = $stats['visitsByDay'] ?? [];
return [

View File

@@ -9,6 +9,10 @@ class ShortUrlVisitsRightBreakdown extends Widget
{
public ?ShortUrl $record = null;
public ?string $dateFrom = null;
public ?string $dateTo = null;
protected string $view = 'filament-short-url::widgets.visits-right-breakdown';
protected int|string|array $columnSpan = [
@@ -32,7 +36,7 @@ class ShortUrlVisitsRightBreakdown extends Widget
];
}
$stats = $this->record->getCachedStats();
$stats = $this->record->getCachedStats($this->dateFrom, $this->dateTo);
return [
'visitsByCountry' => $stats['visitsByCountry'] ?? [],

View File

@@ -2,11 +2,15 @@
namespace Bjanczak\FilamentShortUrl;
use Bjanczak\FilamentShortUrl\Console\Commands\SyncBufferedCountersCommand;
use Bjanczak\FilamentShortUrl\Services\GeoIpService;
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
use Bjanczak\FilamentShortUrl\Services\ShortUrlSettingsManager;
use Bjanczak\FilamentShortUrl\Services\ShortUrlTracker;
use Bjanczak\FilamentShortUrl\Services\UserAgentParser;
use Filament\Support\Assets\Css;
use Filament\Support\Facades\FilamentAsset;
use Illuminate\Console\Scheduling\Schedule;
use Spatie\LaravelPackageTools\Package;
use Spatie\LaravelPackageTools\PackageServiceProvider;
@@ -24,6 +28,13 @@ class FilamentShortUrlServiceProvider extends PackageServiceProvider
->hasMigrations([
'2024_01_01_000001_create_short_urls_table',
'2024_01_01_000002_create_short_url_visits_table',
'2026_06_01_000003_add_utm_city_referer_to_short_url_visits_table',
'2026_06_01_000004_add_targeting_and_security_to_short_urls_table',
'2026_06_01_000005_create_short_url_daily_stats_table',
])
->hasCommands([
SyncBufferedCountersCommand::class,
Console\Commands\AggregateAndPruneVisitsCommand::class,
])
->hasRoutes(['web']);
}
@@ -39,4 +50,24 @@ class FilamentShortUrlServiceProvider extends PackageServiceProvider
$this->app->singleton(ShortUrlService::class);
$this->app->singleton(ShortUrlTracker::class);
}
public function packageBooted(): void
{
FilamentAsset::register([
Css::make('filament-short-url', __DIR__.'/../resources/dist/filament-short-url.css'),
], package: 'janczakb/filament-short-url');
// Automatically register scheduled tasks in the application scheduler
$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');
}
if (config('filament-short-url.counter_buffering.enabled', false)) {
$schedule->command('short-url:sync-counters')->everyMinute();
}
});
}
}

View File

@@ -6,9 +6,11 @@ use Bjanczak\FilamentShortUrl\Jobs\TrackShortUrlVisitJob;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Bjanczak\FilamentShortUrl\Services\ClientIpExtractor;
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\MessageBag;
use Symfony\Component\HttpFoundation\Response;
class ShortUrlRedirectController extends Controller
{
@@ -16,7 +18,7 @@ class ShortUrlRedirectController extends Controller
private readonly ShortUrlService $service,
) {}
public function __invoke(Request $request, string $key): RedirectResponse
public function __invoke(Request $request, string $key): Response
{
$shortUrl = ShortUrl::findByKey($key);
@@ -30,10 +32,74 @@ class ShortUrlRedirectController extends Controller
abort(410);
}
// 1. 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);
$ipAddress = ClientIpExtractor::getIp($request);
$limiterKey = "short_url_limit:{$key}:".$ipAddress;
if (RateLimiter::tooManyAttempts($limiterKey, $maxAttempts)) {
$retryAfter = RateLimiter::availableIn($limiterKey);
abort(429, 'Too many requests. Please try again in '.$retryAfter.' seconds.', [
'Retry-After' => $retryAfter,
]);
}
RateLimiter::hit($limiterKey, $decaySeconds);
}
// 2. Password Protection Check
if (! empty($shortUrl->password)) {
$sessionKey = "short-url-auth-{$shortUrl->id}";
if (! session()->get($sessionKey)) {
if ($request->isMethod('POST')) {
$submitted = $request->input('password');
if ($submitted === $shortUrl->password) {
session()->put($sessionKey, true);
return redirect()->to($request->fullUrl());
}
$errors = new MessageBag([
'password' => __('filament-short-url::default.password_error') ?? 'Incorrect password.',
]);
return response(view('filament-short-url::password-prompt', ['errors' => $errors]))
->header('Content-Type', 'text/html');
}
return response(view('filament-short-url::password-prompt'))
->header('Content-Type', 'text/html');
}
}
// 3. Resolve Destination URL (evaluating targeting rules)
$destination = $shortUrl->resolveDestinationUrl($request);
// Forward query parameters if configured
if ($shortUrl->forward_query_params) {
$queryParams = $request->query();
// Remove routing/auth parameters so they don't leak to destination
unset($queryParams['confirmed'], $queryParams['password']);
if (! empty($queryParams)) {
$separator = str_contains($destination, '?') ? '&' : '?';
$destination .= $separator.http_build_query($queryParams);
}
}
// 4. 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
if ($shortUrl->track_visits) {
$connection = config('filament-short-url.queue_connection');
$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,
@@ -41,12 +107,18 @@ class ShortUrlRedirectController extends Controller
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'),
);
if ($connection && $connection !== 'default') {
if ($connection) {
dispatch($job->onConnection($connection));
} else {
dispatch($job);
dispatch($job->onConnection('sync'));
}
}
@@ -60,10 +132,11 @@ class ShortUrlRedirectController extends Controller
if ($affected === 0) {
abort(410);
}
// Manually forget cache since DB-level update does not trigger Eloquent events
cache()->forget("filament-short-url:{$shortUrl->url_key}");
}
$redirectUrl = $this->service->resolveRedirectUrl($shortUrl, $request);
return redirect()->away($redirectUrl, $shortUrl->redirect_status_code);
return redirect()->away($destination, $shortUrl->redirect_status_code);
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace Bjanczak\FilamentShortUrl\Jobs;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
class IncrementVisitJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public function __construct(
public readonly int $shortUrlId,
public readonly bool $isUnique = false,
) {
$this->onQueue(config('filament-short-url.queue_name', 'default'));
}
public function handle(): void
{
$shortUrl = ShortUrl::find($this->shortUrlId);
if (! $shortUrl) {
return;
}
$shortUrl->newQuery()
->where('id', $shortUrl->id)
->increment(
'total_visits',
1,
$this->isUnique ? ['unique_visits' => DB::raw('unique_visits + 1')] : []
);
}
}

View File

@@ -41,6 +41,12 @@ class TrackShortUrlVisitJob implements ShouldQueue
public readonly string $userAgent,
public readonly ?string $refererUrl,
public readonly ?string $countryCode = null,
public readonly ?string $city = null,
public readonly ?string $utmSource = null,
public readonly ?string $utmMedium = null,
public readonly ?string $utmCampaign = null,
public readonly ?string $utmTerm = null,
public readonly ?string $utmContent = null,
) {
$this->onQueue(config('filament-short-url.queue_name', 'default'));
}
@@ -62,7 +68,19 @@ class TrackShortUrlVisitJob implements ShouldQueue
]);
$countryCode = isset($this->countryCode) ? $this->countryCode : null;
$visit = $tracker->record($shortUrl, $request, $countryCode);
$city = isset($this->city) ? $this->city : null;
$visit = $tracker->record(
shortUrl: $shortUrl,
request: $request,
preResolvedCountryCode: $countryCode,
preResolvedCity: $city,
utmSource: $this->utmSource,
utmMedium: $this->utmMedium,
utmCampaign: $this->utmCampaign,
utmTerm: $this->utmTerm,
utmContent: $this->utmContent,
);
// Null means bot/crawler — nothing to dispatch or report
if ($visit === null) {

View File

@@ -2,15 +2,22 @@
namespace Bjanczak\FilamentShortUrl\Models;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlGlobalOverview;
use Bjanczak\FilamentShortUrl\Jobs\IncrementVisitJob;
use Bjanczak\FilamentShortUrl\Services\ClientIpExtractor;
use Bjanczak\FilamentShortUrl\Services\GeoIpService;
use Bjanczak\FilamentShortUrl\Services\ShortUrlBuilder;
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
use Bjanczak\FilamentShortUrl\Services\UserAgentParser;
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\HasMany;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
/**
* @property int $id
@@ -66,6 +73,9 @@ class ShortUrl extends Model
'track_referer_url',
'qr_options',
'ga_tracking_id',
'password',
'show_warning_page',
'targeting_rules',
'total_visits',
'unique_visits',
];
@@ -84,6 +94,8 @@ class ShortUrl extends Model
'track_device_type' => 'boolean',
'track_referer_url' => 'boolean',
'qr_options' => 'array',
'show_warning_page' => 'boolean',
'targeting_rules' => 'array',
'activated_at' => 'datetime',
'deactivated_at' => 'datetime',
'expires_at' => 'datetime',
@@ -96,6 +108,11 @@ class ShortUrl extends Model
return $this->hasMany(ShortUrlVisit::class, 'short_url_id');
}
public function dailyStats(): HasMany
{
return $this->hasMany(ShortUrlDailyStats::class, 'short_url_id');
}
// ─── Scopes ──────────────────────────────────────────────────────────────
public function scopeEnabled(Builder $query): Builder
@@ -140,12 +157,18 @@ class ShortUrl extends Model
}
/**
* Bust the cache when the model is saved or deleted.
* Bust the redirect cache when the model is saved or deleted.
* Also invalidate the global overview link-count cache (forever cache)
* on creation or deletion — the count changes only in those cases.
*/
protected static function booted(): void
{
static::saved(fn (self $m) => cache()->forget("filament-short-url:{$m->url_key}"));
static::deleted(fn (self $m) => cache()->forget("filament-short-url:{$m->url_key}"));
// 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> */
@@ -236,12 +259,103 @@ class ShortUrl extends Model
return rtrim(config('app.url'), '/')."/{$prefix}/{$this->url_key}";
}
/**
* Resolve the targeted destination URL based on request headers/context.
*/
public function resolveDestinationUrl(Request $request): string
{
$rules = $this->targeting_rules;
if (empty($rules)) {
return $this->destination_url;
}
$type = $rules['type'] ?? 'none';
if ($type === 'device') {
$parser = app(UserAgentParser::class);
$deviceType = $parser->parse($request->userAgent() ?? '')['device_type'];
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 ($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;
}
/**
* Atomically increment visit counters — single query when unique,
* to avoid race conditions and two round-trips.
* to avoid race conditions and two round-trips. Supports write-back caching.
*/
public function incrementVisits(bool $isUnique = 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}");
if ($isUnique) {
cache()->increment("{$prefix}unique:{$this->id}");
}
Redis::sadd("{$prefix}dirty_ids", $this->id);
return;
} catch (\Throwable) {
// Fallback to queue job below
}
}
// 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'));
return;
}
$this->newQuery()
->where('id', $this->id)
->increment('total_visits', 1, $isUnique ? ['unique_visits' => DB::raw('unique_visits + 1')] : []);
@@ -252,98 +366,304 @@ class ShortUrl extends Model
*
* @return array<string, mixed>
*/
public function getCachedStats(): array
public function getCachedStats(?string $dateFrom = null, ?string $dateTo = null): array
{
$dateFromClean = $dateFrom ? Carbon::parse($dateFrom)->toDateString() : null;
$dateToClean = $dateTo ? Carbon::parse($dateTo)->toDateString() : null;
$cacheTtl = (int) config('filament-short-url.geo_ip.stats_cache_ttl', 300);
$cacheKey = "short_url_stats_{$this->id}";
$cacheKey = "short_url_stats_{$this->id}_".($dateFromClean ?: 'all').'_'.($dateToClean ?: 'all');
return cache()->remember($cacheKey, $cacheTtl, function () {
$visits = $this->visits();
return cache()->remember($cacheKey, $cacheTtl, function () use ($dateFromClean, $dateToClean) {
$today = Carbon::today()->toDateString();
$totalVisits = (int) ($this->total_visits ?? 0);
$uniqueVisits = (int) ($this->unique_visits ?? 0);
$visitsToday = (clone $visits)->where('visited_at', '>=', today()->startOfDay())->count();
$visitsThisWeek = (clone $visits)->where('visited_at', '>=', now()->startOfWeek())->count();
$visitsThisMonth = (clone $visits)->where('visited_at', '>=', now()->startOfMonth())->count();
// 1. Fetch daily stats (aggregated historical data)
$dailyQuery = $this->dailyStats()->where('date', '<', $today);
if ($dateFromClean) {
$dailyQuery->where('date', '>=', $dateFromClean);
}
if ($dateToClean && $dateToClean < $today) {
$dailyQuery->where('date', '<=', $dateToClean);
}
$dailyStatsRows = $dailyQuery->get();
// Visits by day — last 30 days
$visitsByDayRaw = (clone $visits)
->where('visited_at', '>=', now()->subDays(29)->startOfDay())
->selectRaw('DATE(visited_at) as date, COUNT(*) as count')
->groupBy('date')
->orderBy('date')
->pluck('count', 'date')
->toArray();
$visitsByDay = [];
for ($i = 29; $i >= 0; $i--) {
$date = now()->subDays($i)->format('Y-m-d');
$visitsByDay[$date] = $visitsByDayRaw[$date] ?? 0;
// 2. Fetch raw visits for today (if within date range)
$includeToday = ($dateToClean === null || $dateToClean >= $today);
if ($dateFromClean && $dateFromClean > $today) {
$includeToday = false;
}
// Top countries
$visitsByCountry = (clone $visits)
->whereNotNull('country')
->selectRaw('country, country_code, COUNT(*) as count')
->groupBy('country', 'country_code')
->orderByDesc('count')
->limit(10)
->get()
->mapWithKeys(fn ($row) => [$row->country => $row->count])
->toArray();
$rawVisits = [];
if ($includeToday) {
$rawVisits = $this->visits()->where('visited_at', '>=', $today.' 00:00:00')->get();
}
// Device types
$visitsByDevice = (clone $visits)
->whereNotNull('device_type')
->selectRaw('device_type, COUNT(*) as count')
->groupBy('device_type')
->orderByDesc('count')
->pluck('count', 'device_type')
->toArray();
// Helper to merge associative stats arrays
$mergeStats = function (array $base, ?array $additional): array {
if (empty($additional)) {
return $base;
}
foreach ($additional as $key => $val) {
$base[$key] = ($base[$key] ?? 0) + $val;
}
// Browsers
$visitsByBrowser = (clone $visits)
->whereNotNull('browser')
->selectRaw('browser, COUNT(*) as count')
->groupBy('browser')
->orderByDesc('count')
->limit(8)
->pluck('count', 'browser')
->toArray();
return $base;
};
// Operating systems
$visitsByOs = (clone $visits)
->whereNotNull('operating_system')
->selectRaw('operating_system, COUNT(*) as count')
->groupBy('operating_system')
->orderByDesc('count')
->limit(8)
->pluck('count', 'operating_system')
->toArray();
// Initialize metrics
$totalVisits = 0;
$uniqueVisitsCount = 0;
$visitsToday = count($rawVisits);
$visitsThisWeek = 0;
$visitsThisMonth = 0;
// Top referers
$visitsByReferer = (clone $visits)
->whereNotNull('referer_url')
->selectRaw('referer_url, COUNT(*) as count')
->groupBy('referer_url')
->orderByDesc('count')
->limit(10)
->pluck('count', 'referer_url')
->toArray();
$visitsByCountry = [];
$visitsByCity = [];
$visitsByDevice = [];
$visitsByBrowser = [];
$visitsByOs = [];
$visitsByReferer = [];
$utmSources = [];
$utmMediums = [];
$utmCampaigns = [];
// Sum up daily stats
$startOfWeek = now()->startOfWeek()->toDateString();
$startOfMonth = now()->startOfMonth()->toDateString();
foreach ($dailyStatsRows as $row) {
$totalVisits += $row->visits_count;
$uniqueVisitsCount += $row->unique_visits_count;
$rowDate = $row->date->toDateString();
if ($rowDate >= $startOfWeek) {
$visitsThisWeek += $row->visits_count;
}
if ($rowDate >= $startOfMonth) {
$visitsThisMonth += $row->visits_count;
}
$visitsByCountry = $mergeStats($visitsByCountry, $row->country_stats);
$visitsByCity = $mergeStats($visitsByCity, $row->city_stats);
$visitsByDevice = $mergeStats($visitsByDevice, $row->device_stats);
$visitsByBrowser = $mergeStats($visitsByBrowser, $row->browser_stats);
$visitsByOs = $mergeStats($visitsByOs, $row->os_stats);
$visitsByReferer = $mergeStats($visitsByReferer, $row->referer_stats);
$utmSources = $mergeStats($utmSources, $row->utm_source_stats);
$utmMediums = $mergeStats($utmMediums, $row->utm_medium_stats);
$utmCampaigns = $mergeStats($utmCampaigns, $row->utm_campaign_stats);
}
// Combine today's raw visits
if ($includeToday) {
$totalVisits += count($rawVisits);
$uniqueVisitsCount += count(array_unique(array_filter($rawVisits->pluck('ip_hash')->toArray())));
$visitsThisWeek += count($rawVisits);
$visitsThisMonth += count($rawVisits);
foreach ($rawVisits as $visit) {
if ($visit->country) {
$visitsByCountry[$visit->country] = ($visitsByCountry[$visit->country] ?? 0) + 1;
}
if ($visit->city) {
$cityKey = "{$visit->city} ({$visit->country_code})";
$visitsByCity[$cityKey] = ($visitsByCity[$cityKey] ?? 0) + 1;
}
if ($visit->device_type) {
$visitsByDevice[$visit->device_type] = ($visitsByDevice[$visit->device_type] ?? 0) + 1;
}
if ($visit->browser) {
$visitsByBrowser[$visit->browser] = ($visitsByBrowser[$visit->browser] ?? 0) + 1;
}
if ($visit->operating_system) {
$visitsByOs[$visit->operating_system] = ($visitsByOs[$visit->operating_system] ?? 0) + 1;
}
$refererHost = $visit->referer_host ?: 'Direct';
$visitsByReferer[$refererHost] = ($visitsByReferer[$refererHost] ?? 0) + 1;
if ($visit->utm_source) {
$utmSources[$visit->utm_source] = ($utmSources[$visit->utm_source] ?? 0) + 1;
}
if ($visit->utm_medium) {
$utmMediums[$visit->utm_medium] = ($utmMediums[$visit->utm_medium] ?? 0) + 1;
}
if ($visit->utm_campaign) {
$utmCampaigns[$visit->utm_campaign] = ($utmCampaigns[$visit->utm_campaign] ?? 0) + 1;
}
}
}
// Build visitsByDay timeline
$chartFrom = $dateFromClean ? Carbon::parse($dateFromClean) : now()->subDays(29)->startOfDay();
$chartTo = $dateToClean ? Carbon::parse($dateToClean) : now()->endOfDay();
$daysDiff = (int) $chartFrom->diffInDays($chartTo);
$visitsByDay = [];
if ($daysDiff > 90) {
// Group by month
foreach ($dailyStatsRows as $row) {
$m = $row->date->format('Y-m');
$visitsByDay[$m] = ($visitsByDay[$m] ?? 0) + $row->visits_count;
}
if ($includeToday) {
$mToday = Carbon::parse($today)->format('Y-m');
$visitsByDay[$mToday] = ($visitsByDay[$mToday] ?? 0) + count($rawVisits);
}
} else {
// Initialize timeline with zeros
for ($i = $daysDiff; $i >= 0; $i--) {
$d = (clone $chartTo)->subDays($i)->format('Y-m-d');
$visitsByDay[$d] = 0;
}
// Fill daily stats
foreach ($dailyStatsRows as $row) {
$d = $row->date->format('Y-m-d');
if (isset($visitsByDay[$d])) {
$visitsByDay[$d] = $row->visits_count;
}
}
// Fill today
if ($includeToday && isset($visitsByDay[$today])) {
$visitsByDay[$today] = count($rawVisits);
}
}
// Sort distributions descending
arsort($visitsByCountry);
arsort($visitsByCity);
arsort($visitsByDevice);
arsort($visitsByBrowser);
arsort($visitsByOs);
arsort($visitsByReferer);
arsort($utmSources);
arsort($utmMediums);
arsort($utmCampaigns);
return [
'totalVisits' => $totalVisits,
'uniqueVisits' => $uniqueVisits,
'uniqueVisits' => $uniqueVisitsCount,
'visitsToday' => $visitsToday,
'visitsThisWeek' => $visitsThisWeek,
'visitsThisMonth' => $visitsThisMonth,
'visitsByDay' => $visitsByDay,
'visitsByCountry' => $visitsByCountry,
'visitsByCountry' => array_slice($visitsByCountry, 0, 10, true),
'visitsByCity' => array_slice($visitsByCity, 0, 10, true),
'visitsByDevice' => $visitsByDevice,
'visitsByBrowser' => $visitsByBrowser,
'visitsByOs' => $visitsByOs,
'visitsByReferer' => $visitsByReferer,
'visitsByBrowser' => array_slice($visitsByBrowser, 0, 8, true),
'visitsByOs' => array_slice($visitsByOs, 0, 8, true),
'visitsByReferer' => array_slice($visitsByReferer, 0, 10, true),
'utmSources' => array_slice($utmSources, 0, 8, true),
'utmMediums' => array_slice($utmMediums, 0, 8, true),
'utmCampaigns' => array_slice($utmCampaigns, 0, 8, true),
];
});
}
/**
* Cache properties to hold preloaded buffered visits for the current request.
*/
protected static ?array $bufferedTotalVisits = null;
protected static ?array $bufferedUniqueVisits = 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.
*/
protected static function loadAllBufferedVisits(): void
{
if (static::$bufferedTotalVisits !== null) {
return;
}
static::$bufferedTotalVisits = [];
static::$bufferedUniqueVisits = [];
if (! config('filament-short-url.counter_buffering.enabled', false)) {
return;
}
$prefix = config('filament-short-url.counter_buffering.cache_key_prefix', 'filament-short-url:buffer:');
$dirtyKey = "{$prefix}dirty_ids";
// 1. Fetch the list of dirty IDs (URLs with pending buffered clicks) in one query
$dirtyIds = [];
try {
if (cache()->getDefaultDriver() === 'redis' && class_exists(Redis::class)) {
$dirtyIds = Redis::smembers($dirtyKey);
} else {
$dirtyIds = cache()->get($dirtyKey, []);
}
} catch (\Throwable) {
// Fallback
}
if (empty($dirtyIds)) {
return;
}
$dirtyIds = array_unique(array_filter((array) $dirtyIds));
// 2. Build array of keys to fetch in a single cache store read
$totalKeys = [];
$uniqueKeys = [];
foreach ($dirtyIds as $id) {
$totalKeys[$id] = "{$prefix}total:{$id}";
$uniqueKeys[$id] = "{$prefix}unique:{$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));
foreach ($totalKeys as $id => $key) {
static::$bufferedTotalVisits[$id] = (int) ($totals[$key] ?? 0);
}
foreach ($uniqueKeys as $id => $key) {
static::$bufferedUniqueVisits[$id] = (int) ($uniques[$key] ?? 0);
}
} catch (\Throwable) {
// Fallback
}
}
/**
* Get the total visits count, merging the database value with any buffered clicks in cache.
* Prevents database N+1 queries.
*/
public function getTotalVisitsAttribute(): int
{
$dbValue = $this->attributes['total_visits'] ?? 0;
if (! config('filament-short-url.counter_buffering.enabled', false)) {
return $dbValue;
}
static::loadAllBufferedVisits();
$buffered = static::$bufferedTotalVisits[$this->id] ?? 0;
return $dbValue + $buffered;
}
/**
* Get the unique visits count, merging the database value with any buffered clicks in cache.
* Prevents database N+1 queries.
*/
public function getUniqueVisitsAttribute(): int
{
$dbValue = $this->attributes['unique_visits'] ?? 0;
if (! config('filament-short-url.counter_buffering.enabled', false)) {
return $dbValue;
}
static::loadAllBufferedVisits();
$buffered = static::$bufferedUniqueVisits[$this->id] ?? 0;
return $dbValue + $buffered;
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace Bjanczak\FilamentShortUrl\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
/**
* @property int $id
* @property int $short_url_id
* @property Carbon $date
* @property int $visits_count
* @property int $unique_visits_count
* @property array|null $device_stats
* @property array|null $browser_stats
* @property array|null $os_stats
* @property array|null $country_stats
* @property array|null $city_stats
* @property array|null $referer_stats
* @property array|null $utm_source_stats
* @property array|null $utm_medium_stats
* @property array|null $utm_campaign_stats
* @property Carbon $created_at
* @property Carbon $updated_at
*/
class ShortUrlDailyStats extends Model
{
protected $table = 'short_url_daily_stats';
protected $fillable = [
'short_url_id',
'date',
'visits_count',
'unique_visits_count',
'device_stats',
'browser_stats',
'os_stats',
'country_stats',
'city_stats',
'referer_stats',
'utm_source_stats',
'utm_medium_stats',
'utm_campaign_stats',
];
/** @var array<string, string> */
protected $casts = [
'date' => 'date',
'visits_count' => 'integer',
'unique_visits_count' => 'integer',
'device_stats' => 'array',
'browser_stats' => 'array',
'os_stats' => 'array',
'country_stats' => 'array',
'city_stats' => 'array',
'referer_stats' => 'array',
'utm_source_stats' => 'array',
'utm_medium_stats' => 'array',
'utm_campaign_stats' => 'array',
];
public function shortUrl(): BelongsTo
{
return $this->belongsTo(ShortUrl::class, 'short_url_id');
}
}

View File

@@ -37,8 +37,15 @@ class ShortUrlVisit extends Model
'operating_system_version',
'device_type',
'referer_url',
'referer_host',
'country',
'country_code',
'city',
'utm_source',
'utm_medium',
'utm_campaign',
'utm_term',
'utm_content',
'visited_at',
];

View File

@@ -11,29 +11,31 @@ class ClientIpExtractor
*/
public static function getIp(Request $request): string
{
// 1. Cloudflare connecting IP header
if ($cfIp = $request->header('CF-Connecting-IP')) {
return trim($cfIp);
if (config('filament-short-url.trust_cdn_headers', false)) {
// 1. Cloudflare connecting IP header
if ($cfIp = $request->header('CF-Connecting-IP')) {
return trim($cfIp);
}
// 2. Akamai or other CDNs True-Client-IP header
if ($trueIp = $request->header('True-Client-IP')) {
return trim($trueIp);
}
// 3. General reverse proxy / Nginx X-Real-IP header
if ($realIp = $request->header('X-Real-IP')) {
return trim($realIp);
}
// 4. Standard X-Forwarded-For header chain (first IP is the client)
if ($forwardedFor = $request->header('X-Forwarded-For')) {
$ips = explode(',', $forwardedFor);
return trim($ips[0]);
}
}
// 2. Akamai or other CDNs True-Client-IP header
if ($trueIp = $request->header('True-Client-IP')) {
return trim($trueIp);
}
// 3. General reverse proxy / Nginx X-Real-IP header
if ($realIp = $request->header('X-Real-IP')) {
return trim($realIp);
}
// 4. Standard X-Forwarded-For header chain (first IP is the client)
if ($forwardedFor = $request->header('X-Forwarded-For')) {
$ips = explode(',', $forwardedFor);
return trim($ips[0]);
}
// 5. Fallback to standard Laravel/Symfony IP resolver
// 5. Fallback to standard Laravel/Symfony IP resolver (which respects Trusted Proxies)
return $request->ip() ?? '0.0.0.0';
}
@@ -42,19 +44,39 @@ class ClientIpExtractor
*/
public static function getCountryCode(Request $request): ?string
{
// 1. Cloudflare IP Country header
if ($cfCountry = $request->header('CF-IPCountry')) {
return strtoupper(trim($cfCountry));
if (config('filament-short-url.trust_cdn_headers', false)) {
// 1. Cloudflare IP Country header
if ($cfCountry = $request->header('CF-IPCountry')) {
return strtoupper(trim($cfCountry));
}
// 2. AWS CloudFront country header
if ($cfViewerCountry = $request->header('CloudFront-Viewer-Country')) {
return strtoupper(trim($cfViewerCountry));
}
// 3. Generic CDN / Proxy country header
if ($xCountry = $request->header('X-Country-Code')) {
return strtoupper(trim($xCountry));
}
}
// 2. AWS CloudFront country header
if ($cfViewerCountry = $request->header('CloudFront-Viewer-Country')) {
return strtoupper(trim($cfViewerCountry));
}
return null;
}
// 3. Generic CDN / Proxy country header
if ($xCountry = $request->header('X-Country-Code')) {
return strtoupper(trim($xCountry));
/**
* Extract the edge-provided city name from CDN/proxy headers.
*/
public static function getCity(Request $request): ?string
{
if (config('filament-short-url.trust_cdn_headers', false)) {
if ($cfCity = $request->header('CF-IPCity')) {
return trim($cfCity);
}
if ($cfViewerCity = $request->header('CloudFront-Viewer-City')) {
return trim($cfViewerCity);
}
}
return null;

View File

@@ -17,31 +17,32 @@ use Illuminate\Support\Facades\Log;
*/
class GeoIpService
{
private const API_URL = 'http://ip-api.com/json/%s?fields=status,country,countryCode';
private const API_URL = 'http://ip-api.com/json/%s?fields=status,country,countryCode,city';
/** @var array<string, array{country: string|null, country_code: string|null}> */
/** @var array<string, array{country: string|null, country_code: string|null, city: string|null}> */
private static array $runtimeCache = [];
/**
* Resolve country data for the given IP address.
* Resolve country and city data for the given IP address.
*
* @return array{country: string|null, country_code: string|null}
* @return array{country: string|null, country_code: string|null, city: string|null}
*/
public function resolve(string $ip, ?string $preResolvedCountryCode = null): array
public function resolve(string $ip, ?string $preResolvedCountryCode = null, ?string $preResolvedCity = null): array
{
$empty = ['country' => null, 'country_code' => null];
$empty = ['country' => null, 'country_code' => null, 'city' => null];
if (! config('filament-short-url.geo_ip.enabled', true)) {
return $empty;
}
// 1. Prioritise edge-provided CDN country code (extremely fast, zero latency)
// 1. Prioritise edge-provided CDN country code & city (extremely fast, zero latency)
if ($preResolvedCountryCode) {
$code = strtoupper(trim($preResolvedCountryCode));
return [
'country' => $this->getCountryName($code),
'country_code' => $code,
'city' => $preResolvedCity,
];
}
@@ -129,9 +130,9 @@ class GeoIpService
}
/**
* Resolve country using local MaxMind GeoIP2 DB.
* Resolve country and city using local MaxMind GeoIP2 DB.
*
* @return array{country: string, country_code: string}|null
* @return array{country: string|null, country_code: string|null, city: string|null}|null
*/
private function lookupMaxMind(string $ip): ?array
{
@@ -151,11 +152,24 @@ class GeoIpService
try {
$reader = new Reader($dbPath);
$dbType = $reader->metadata()->databaseType;
if (str_contains($dbType, 'City')) {
$record = $reader->city($ip);
return [
'country' => $record->country->name ?? null,
'country_code' => $record->country->isoCode ?? null,
'city' => $record->city->name ?? null,
];
}
$record = $reader->country($ip);
return [
'country' => $record->country->name ?? null,
'country_code' => $record->country->isoCode ?? null,
'city' => null,
];
} catch (\Throwable $e) {
Log::warning('[FilamentShortUrl] MaxMind GeoIP lookup failed', [
@@ -168,9 +182,9 @@ class GeoIpService
}
/**
* Resolve country using free ip-api.com.
* Resolve country and city using free ip-api.com.
*
* @return array{country: string, country_code: string}|null
* @return array{country: string|null, country_code: string|null, city: string|null}|null
*/
private function fetchFromApi(string $ip): ?array
{
@@ -193,6 +207,7 @@ class GeoIpService
return [
'country' => $data['country'] ?? null,
'country_code' => $data['countryCode'] ?? null,
'city' => $data['city'] ?? null,
];
} catch (\Throwable $e) {
Log::warning('[FilamentShortUrl] GeoIP lookup failed', [

View File

@@ -44,11 +44,18 @@ class ShortUrlSettingsManager
'geo_ip_driver' => config('filament-short-url.geo_ip.driver', 'headers'),
'geo_ip_cache_ttl' => config('filament-short-url.geo_ip.cache_ttl', 86400),
'geo_ip_timeout' => config('filament-short-url.geo_ip.timeout', 3),
'maxmind_database_path' => config('filament-short-url.geo_ip.maxmind.database_path', database_path('geoip/GeoLite2-Country.mmdb')),
'maxmind_database_path' => config('filament-short-url.geo_ip.maxmind.database_path', storage_path('geoip/GeoLite2-Country.mmdb')),
'ga4_api_secret' => config('filament-short-url.ga4.api_secret'),
'ga4_firebase_app_id' => config('filament-short-url.ga4.firebase_app_id'),
'queue_connection' => config('filament-short-url.queue_connection', 'sync'),
'cache_ttl' => config('filament-short-url.cache_ttl', 3600),
'counter_buffering_enabled' => config('filament-short-url.counter_buffering.enabled', false),
'trust_cdn_headers' => config('filament-short-url.trust_cdn_headers', false),
'pruning_enabled' => config('filament-short-url.pruning.enabled', true),
'pruning_retention_days' => config('filament-short-url.pruning.retention_days', 90),
'rate_limiting_enabled' => config('filament-short-url.rate_limiting.enabled', false),
'rate_limiting_max_attempts' => config('filament-short-url.rate_limiting.max_attempts', 60),
'rate_limiting_decay_seconds' => config('filament-short-url.rate_limiting.decay_seconds', 60),
], $stored);
return $this->cache;
@@ -87,6 +94,13 @@ class ShortUrlSettingsManager
'ga4_firebase_app_id',
'queue_connection',
'cache_ttl',
'counter_buffering_enabled',
'trust_cdn_headers',
'pruning_enabled',
'pruning_retention_days',
'rate_limiting_enabled',
'rate_limiting_max_attempts',
'rate_limiting_decay_seconds',
];
$filtered = array_intersect_key($data, array_flip($keys));
@@ -110,6 +124,27 @@ class ShortUrlSettingsManager
if (isset($filtered['cache_ttl'])) {
$filtered['cache_ttl'] = (int) $filtered['cache_ttl'];
}
if (isset($filtered['counter_buffering_enabled'])) {
$filtered['counter_buffering_enabled'] = (bool) $filtered['counter_buffering_enabled'];
}
if (isset($filtered['trust_cdn_headers'])) {
$filtered['trust_cdn_headers'] = (bool) $filtered['trust_cdn_headers'];
}
if (isset($filtered['pruning_enabled'])) {
$filtered['pruning_enabled'] = (bool) $filtered['pruning_enabled'];
}
if (isset($filtered['pruning_retention_days'])) {
$filtered['pruning_retention_days'] = (int) $filtered['pruning_retention_days'];
}
if (isset($filtered['rate_limiting_enabled'])) {
$filtered['rate_limiting_enabled'] = (bool) $filtered['rate_limiting_enabled'];
}
if (isset($filtered['rate_limiting_max_attempts'])) {
$filtered['rate_limiting_max_attempts'] = (int) $filtered['rate_limiting_max_attempts'];
}
if (isset($filtered['rate_limiting_decay_seconds'])) {
$filtered['rate_limiting_decay_seconds'] = (int) $filtered['rate_limiting_decay_seconds'];
}
File::put($path, json_encode($filtered, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
$this->cache = null;
@@ -138,6 +173,13 @@ class ShortUrlSettingsManager
'filament-short-url.ga4.firebase_app_id' => $settings['ga4_firebase_app_id'],
'filament-short-url.queue_connection' => $settings['queue_connection'],
'filament-short-url.cache_ttl' => $settings['cache_ttl'],
'filament-short-url.counter_buffering.enabled' => $settings['counter_buffering_enabled'],
'filament-short-url.trust_cdn_headers' => $settings['trust_cdn_headers'],
'filament-short-url.pruning.enabled' => $settings['pruning_enabled'],
'filament-short-url.pruning.retention_days' => $settings['pruning_retention_days'],
'filament-short-url.rate_limiting.enabled' => $settings['rate_limiting_enabled'],
'filament-short-url.rate_limiting.max_attempts' => $settings['rate_limiting_max_attempts'],
'filament-short-url.rate_limiting.decay_seconds' => $settings['rate_limiting_decay_seconds'],
]);
}
}

View File

@@ -23,8 +23,17 @@ class ShortUrlTracker
*
* Returns null if the visit was from a bot/crawler (we don't track those).
*/
public function record(ShortUrl $shortUrl, Request $request, ?string $preResolvedCountryCode = null): ?ShortUrlVisit
{
public function record(
ShortUrl $shortUrl,
Request $request,
?string $preResolvedCountryCode = null,
?string $preResolvedCity = null,
?string $utmSource = null,
?string $utmMedium = null,
?string $utmCampaign = null,
?string $utmTerm = null,
?string $utmContent = null,
): ?ShortUrlVisit {
$ip = ClientIpExtractor::getIp($request);
$ipHash = hash('sha256', $ip);
$ua = $request->userAgent() ?? '';
@@ -36,8 +45,8 @@ class ShortUrlTracker
}
$geo = config('filament-short-url.geo_ip.enabled', true)
? $this->geoIp->resolve($ip, $preResolvedCountryCode)
: ['country' => null, 'country_code' => null];
? $this->geoIp->resolve($ip, $preResolvedCountryCode, $preResolvedCity)
: ['country' => null, 'country_code' => null, 'city' => null];
// Determine uniqueness: first time this IP hash visits this URL.
// We check BEFORE insert to avoid a self-referential race.
@@ -56,9 +65,42 @@ class ShortUrlTracker
$visit->operating_system_version = $shortUrl->track_operating_system_version
? $parsed['operating_system_version'] : null;
$visit->device_type = $shortUrl->track_device_type ? $parsed['device_type'] : null;
$visit->referer_url = $shortUrl->track_referer_url ? $request->header('Referer') : null;
$visit->country = $geo['country'];
$visit->country_code = $geo['country_code'];
$visit->city = $geo['city'] ?? null;
$visit->utm_source = $utmSource;
$visit->utm_medium = $utmMedium;
$visit->utm_campaign = $utmCampaign;
$visit->utm_term = $utmTerm;
$visit->utm_content = $utmContent;
// Clean & normalize referer URL to host domain
if ($shortUrl->track_referer_url && $referer = $request->header('Referer')) {
$visit->referer_url = $referer;
$refererHost = parse_url($referer, PHP_URL_HOST);
if ($refererHost) {
$refererHost = preg_replace('/^www\./', '', strtolower($refererHost));
if (str_contains($refererHost, 'facebook.com')) {
$refererHost = 'facebook.com';
} elseif (str_contains($refererHost, 'linkedin.com')) {
$refererHost = 'linkedin.com';
} elseif (str_contains($refererHost, 'twitter.com') || str_contains($refererHost, 't.co')) {
$refererHost = 'twitter.com';
} elseif (str_contains($refererHost, 'google.')) {
$refererHost = 'google.com';
} elseif (str_contains($refererHost, 'instagram.com')) {
$refererHost = 'instagram.com';
} elseif (str_contains($refererHost, 'youtube.com')) {
$refererHost = 'youtube.com';
}
$visit->referer_host = $refererHost;
} else {
$visit->referer_host = 'Direct';
}
} else {
$visit->referer_host = 'Direct';
}
$visit->save();